Skip to main content

qsm_core/pipeline/
inversion.rs

1//! Dipole inversion stage
2//!
3//! Dispatcher for standard dipole inversion algorithms.
4//! Also contains TGV and QSMART pipeline runners which combine
5//! multiple stages internally.
6//!
7//! MEDI Hz↔radians conversion is handled internally — the caller
8//! passes ppm fields and receives ppm results.
9
10
11use super::config::*;
12
13/// The echo magnitudes a MEDI-toolbox method requires (MEDI's edge mask, and the
14/// `Fit_ppm_complex` noise map that MEDI and PDF weight the data by): one volume per echo, echo
15/// slowest, for evenly spaced echoes (as `Fit_ppm_complex.m` assumes). `method` names the
16/// method in the error messages.
17pub(crate) fn echo_magnitudes<'a>(
18    method: &str,
19    magnitude: Option<&'a [f64]>,
20    echo_times: &[f64],
21    n_voxels: usize,
22) -> Result<crate::inversion::EchoMagnitudes<'a>, PipelineError> {
23    let n_echoes = echo_times.len();
24    if n_echoes == 0 {
25        return Err(PipelineError::InvalidInput(format!("{method} needs the echo times")));
26    }
27    if let Some(problem) = crate::fieldmap::echo_spacing_problem(echo_times) {
28        return Err(PipelineError::InvalidInput(format!("{method}'s noise map (Fit_ppm_complex) {problem}")));
29    }
30    match magnitude {
31        Some(m) if m.len() == n_voxels * n_echoes => {
32            Ok(crate::inversion::EchoMagnitudes { data: m, n_echoes })
33        }
34        Some(m) => Err(PipelineError::InvalidInput(format!(
35            "{method} needs the magnitude of every echo ({n_echoes} volumes, {} values); got {} values",
36            n_voxels * n_echoes, m.len(),
37        ))),
38        None => Err(PipelineError::InvalidInput(format!(
39            "{method} needs the magnitude of every echo ({n_echoes} volumes)"
40        ))),
41    }
42}
43
44/// The scan's echo times for a method that converts ppm to radians at the echo time its `te`
45/// choice picks, checked here so the method itself does not panic on them.
46pub(crate) fn scan_echo_times(
47    method: &str,
48    te: crate::EchoTime,
49    metadata: &ScanMetadata,
50) -> Result<Vec<f64>, PipelineError> {
51    te.resolve(&metadata.echo_times)
52        .map_err(|e| PipelineError::InvalidInput(format!("{method}: {e}")))?;
53    Ok(metadata.echo_times.clone())
54}
55
56/// Run a dipole inversion on the field its method takes.
57///
58/// Which field that is comes from [`InversionConfig::input`]:
59///
60/// - [`InversionInput::LocalField`] (most methods): the local field after background removal,
61///   with the eroded mask background removal returned.
62/// - [`InversionInput::TotalField`]: the total field straight from field mapping, with the
63///   brain mask and no background removal. Through this function that is TFI,
64///   AutoQSM and NeXtQSM. TFI solves over the whole FOV under its preconditioner (1 inside
65///   `mask`, `precond` outside) and weights the data by the magnitude inside the mask, so the
66///   total field and the brain mask are exactly what it needs; given a local field and an
67///   eroded mask it would run, but would be solving a different problem.
68/// - [`InversionInput::WrappedPhase`] methods, and TGV and QSMART, have their own entry points
69///   and are refused here.
70///
71/// Handles MEDI's unit conversion (ppm → radians) internally.
72///
73/// # Arguments
74/// * `field_ppm` - The local or total field in ppm, as above
75/// * `mask` - The eroded mask from background removal (local field), or the brain mask
76///   (total field)
77/// * `metadata` - Scan metadata
78/// * `config` - Inversion configuration
79/// * `magnitude` - Combined magnitude image; MEDI, TFI and HD-QSM instead require the magnitude of every
80///   echo, stacked with the echo slowest
81/// * `progress` - Progress callback (current_iter, max_iter)
82///
83/// # Returns
84/// Susceptibility map in ppm (unreferenced)
85pub fn run_dipole_inversion(
86    field_ppm: &[f64],
87    mask: &[u8],
88    metadata: &ScanMetadata,
89    config: &InversionConfig,
90    magnitude: Option<&[f64]>,
91    progress: &mut dyn FnMut(usize, usize),
92) -> Result<Vec<f64>, PipelineError> {
93    if let Some(problem) = heidi_seed_problem(config) {
94        return Err(PipelineError::InvalidConfig(problem));
95    }
96    let chi = invert(field_ppm, mask, metadata, config, magnitude, &mut *progress)?;
97    if config.cone_fill_after() {
98        return run_heidi_cone_fill(&chi, field_ppm, mask, metadata, config, progress);
99    }
100    Ok(chi)
101}
102
103/// [`run_dipole_inversion`] without HEIDI's cone fill after a non-LSQR seed.
104fn invert(
105    field_ppm: &[f64],
106    mask: &[u8],
107    metadata: &ScanMetadata,
108    config: &InversionConfig,
109    magnitude: Option<&[f64]>,
110    progress: &mut dyn FnMut(usize, usize),
111) -> Result<Vec<f64>, PipelineError> {
112    // The dipole kernel is an FFT on a uniform grid, so a gapped volume is refused.
113    metadata.require_contiguous_slices()?;
114    let grid = metadata.grid();
115    let bdir = metadata.b0_direction;
116    let n_voxels = grid.n_total();
117
118    if field_ppm.len() != n_voxels {
119        return Err(PipelineError::DimensionMismatch {
120            expected: n_voxels,
121            got: field_ppm.len(),
122        });
123    }
124
125    let chi = match config.algorithm {
126        InversionAlgorithm::Tkd => {
127            crate::inversion::tkd(
128                field_ppm, mask, &grid, bdir, &config.tkd,
129            )
130        }
131        InversionAlgorithm::Tsvd => {
132            crate::inversion::tsvd(
133                field_ppm, mask, &grid, bdir, &config.tsvd,
134            )
135        }
136        InversionAlgorithm::Tikhonov => {
137            crate::inversion::tikhonov(
138                field_ppm, mask, &grid, bdir, &config.tikhonov,
139            )
140        }
141        InversionAlgorithm::Tv => {
142            crate::inversion::tv_admm(
143                field_ppm, mask, &grid, bdir, &config.tv, progress,
144            )
145        }
146        InversionAlgorithm::Rts => {
147            crate::inversion::rts(
148                field_ppm, mask, &grid, bdir, &config.rts, progress,
149            )
150        }
151        InversionAlgorithm::Nltv => {
152            // NLTV takes ppm and converts to radians itself, like NDI; field strength and echo
153            // times come from metadata, and config.nltv.te picks the echo time. The magnitude
154            // is the data weight and is required: without it the method can diverge (see the
155            // module docs).
156            let mag = magnitude.ok_or_else(|| PipelineError::InvalidInput(
157                "NLTV needs the magnitude (its data weight)".into(),
158            ))?;
159            let params = crate::inversion::NltvParams {
160                b0: metadata.field_strength,
161                echo_times: scan_echo_times("NLTV", config.nltv.te, metadata)?,
162                ..config.nltv.clone()
163            };
164            crate::inversion::nltv(
165                field_ppm, mask, mag, &grid, bdir, &params, progress,
166            )
167        }
168        InversionAlgorithm::Medi => {
169            // MEDI takes ppm like the others and converts to radians itself, at the echo time
170            // config.medi.te picks (by default MEDI's delta_TE: TE(2) - TE(1), or TE for one echo).
171            // Field strength and echo times are scan parameters from metadata. It requires the
172            // magnitude of every echo (edge mask, the Fit_ppm_complex noise map, and the ARLO R2*
173            // behind MEDI+0's CSF mask).
174            let te = &metadata.echo_times;
175            let mag = crate::pipeline::inversion::echo_magnitudes("MEDI", magnitude, te, n_voxels)?;
176            // MEDI-mSMV's vessel mask needs ARLO R2* from the echo magnitudes (three or more
177            // echoes); MEDI_L1 errors without R2*, so this is refused rather than run without it.
178            if config.medi.msmv.is_some() {
179                if let Some(problem) = crate::utils::r2star::arlo_echo_times_problem(te) {
180                    return Err(PipelineError::InvalidInput(format!(
181                        "MEDI-mSMV needs R2* for its vessel mask: {problem}"
182                    )));
183                }
184            }
185            let params = crate::inversion::MediParams {
186                b0: metadata.field_strength,
187                echo_times: scan_echo_times("MEDI", config.medi.te, metadata)?,
188                ..config.medi.clone()
189            };
190            crate::inversion::medi(field_ppm, mask, mag, &grid, bdir, &params, progress)
191        }
192        InversionAlgorithm::Tfi => {
193            // TFI is single-step: `field_ppm` is the TOTAL field and `mask` the brain mask
194            // (InversionConfig::input). Like MEDI it takes ppm and converts to radians itself,
195            // at the echo time config.tfi.te picks (by default the echo spacing, TE(2) - TE(1),
196            // or TE for one echo); field strength and echo times come from metadata. As for
197            // MEDI, the magnitude of every echo is required (edge mask and the Fit_ppm_complex
198            // noise map).
199            let te = &metadata.echo_times;
200            let mag = echo_magnitudes("TFI", magnitude, te, n_voxels)?;
201            let params = crate::inversion::TfiParams {
202                b0: metadata.field_strength,
203                echo_times: scan_echo_times("TFI", config.tfi.te, metadata)?,
204                ..config.tfi.clone()
205            };
206            crate::inversion::tfi(field_ppm, mask, mag, &grid, bdir, &params, progress)
207        }
208        InversionAlgorithm::Ilsqr => {
209            config.ilsqr.validate().map_err(PipelineError::InvalidConfig)?;
210            let (chi, _, _, _) = crate::inversion::ilsqr(
211                field_ppm, mask, &grid, bdir, &config.ilsqr, &mut *progress,
212            );
213            chi
214        }
215        InversionAlgorithm::Star => {
216            // STAR-QSM's result depends on the field's scale in radians (see the module docs),
217            // so it converts the ppm field to phase at the echo time config.star.te picks (by
218            // default the sum of the echo times, as STI Suite's GUI gives it the phase summed
219            // over the echoes) and this field strength.
220            let params = crate::inversion::StarParams {
221                echo_times: scan_echo_times("STAR-QSM", config.star.te, metadata)?,
222                b0: metadata.field_strength,
223                ..config.star.clone()
224            };
225            params.validate().map_err(PipelineError::InvalidConfig)?;
226            crate::inversion::star_qsm(field_ppm, mask, &grid, bdir, &params, &mut *progress)
227        }
228        InversionAlgorithm::Ndi => {
229            // NDI takes ppm like the others and converts to radians itself, at the echo time
230            // config.ndi.te picks. Field strength and echo times are scan parameters from
231            // metadata, not user knobs; the magnitude is the data weight and is required.
232            let magnitude = magnitude.ok_or_else(|| PipelineError::InvalidConfig(
233                "NDI needs the magnitude as its data weight".into()))?;
234            let params = crate::inversion::NdiParams {
235                b0: metadata.field_strength,
236                echo_times: scan_echo_times("NDI", config.ndi.te, metadata)?,
237                ..config.ndi.clone()
238            };
239            crate::inversion::ndi(
240                field_ppm, mask, magnitude, &grid, bdir, &params, progress,
241            )
242        }
243        InversionAlgorithm::Fansi | InversionAlgorithm::FansiTgv | InversionAlgorithm::L1qsm => {
244            // FANSI nlTV/nlTGV and L1-QSM take ppm like the others and convert to radians
245            // themselves, at the echo time their `te` picks. Field strength and echo times are
246            // scan parameters from metadata. The magnitude is the data weight and is required:
247            // with a flat weight these solvers can diverge.
248            let magnitude = magnitude.ok_or_else(|| {
249                PipelineError::InvalidInput(format!(
250                    "{:?} requires magnitude data (its data weight)", config.algorithm
251                ))
252            })?;
253            let b0 = metadata.field_strength;
254            if config.algorithm == InversionAlgorithm::L1qsm {
255                let params = crate::inversion::L1QsmParams {
256                    b0,
257                    echo_times: scan_echo_times("L1-QSM", config.l1qsm.te, metadata)?,
258                    ..config.l1qsm.clone()
259                };
260                crate::inversion::l1qsm(
261                    field_ppm, mask, magnitude, &grid, bdir, &params, progress,
262                )
263            } else {
264                let params = crate::inversion::FansiParams {
265                    b0,
266                    echo_times: scan_echo_times("FANSI", config.fansi.te, metadata)?,
267                    is_tgv: config.algorithm == InversionAlgorithm::FansiTgv,
268                    ..config.fansi.clone()
269                };
270                crate::inversion::fansi(
271                    field_ppm, mask, magnitude, &grid, bdir, &params, progress,
272                )
273            }
274        }
275        InversionAlgorithm::Whqsm => {
276            // Like NDI: ppm in and out, radians inside at the echo time config.whqsm.te picks.
277            // Field strength and echo times are scan parameters from metadata. The default
278            // weight is WH_nlTV's flat one; a magnitude weight (config.whqsm.weight) needs the
279            // magnitude.
280            if config.whqsm.weight.needs_magnitude() && magnitude.is_none() {
281                return Err(PipelineError::InvalidInput(format!(
282                    "WH-QSM's {:?} weight needs magnitude data", config.whqsm.weight
283                )));
284            }
285            let params = crate::inversion::WhQsmParams {
286                b0: metadata.field_strength,
287                echo_times: scan_echo_times("WH-QSM", config.whqsm.te, metadata)?,
288                ..config.whqsm.clone()
289            };
290            crate::inversion::whqsm(
291                field_ppm, mask, magnitude, &grid, bdir, &params, progress,
292            )
293        }
294        InversionAlgorithm::Hdqsm => {
295            // The data weight is the paper's Eq. 2 combination of every echo's magnitude
296            // (hdqsm_magnitude), as the original is run with; the echoes are required.
297            let te = &metadata.echo_times;
298            let n_echoes = te.len();
299            let echoes = match magnitude {
300                Some(m) if n_echoes > 0 && m.len() == n_voxels * n_echoes => m,
301                Some(m) => return Err(PipelineError::InvalidInput(format!(
302                    "HD-QSM needs the magnitude of every echo ({n_echoes} volumes, {} values); got {} values",
303                    n_voxels * n_echoes, m.len(),
304                ))),
305                None => return Err(PipelineError::InvalidInput(format!(
306                    "HD-QSM needs the magnitude of every echo ({n_echoes} volumes)"
307                ))),
308            };
309            let mag = crate::inversion::hdqsm_magnitude(echoes, te);
310            crate::inversion::hdqsm(
311                field_ppm, mask, Some(&mag), &grid, bdir, &config.hdqsm, progress,
312            )
313        }
314        InversionAlgorithm::AmpPe => {
315            // AMP-PE takes the local field in ppm (like NDI). `b0` (field strength)
316            // is a scan parameter sourced from metadata, not a user knob. The one magnitude
317            // this stage has serves as both the data weight and the morphology image; upstream
318            // uses the first echo's magnitude for the first and the RSS over echoes for the
319            // second, which a caller holding the echoes can pass to `amp_pe` directly.
320            let params = crate::inversion::AmpPeParams {
321                b0: metadata.field_strength,
322                ..config.amp_pe.clone()
323            };
324            crate::inversion::amp_pe(
325                field_ppm, mask, magnitude, None, &grid, bdir, &params, progress,
326            )
327            .map_err(|e| PipelineError::InvalidConfig(e.0))?
328        }
329        InversionAlgorithm::Lsqr if config.apply_heidi => {
330            // LSQR+HEIDI, as published: qsm_heidi's inversion_lsqr.m, then inversion_heidi.m on
331            // the unmasked seed and the same cropped grid. The field strength sets the seed's
332            // residual weighting.
333            let lsqr_params = crate::inversion::LsqrQsmParams {
334                b0: metadata.field_strength,
335                ..config.lsqr.clone()
336            };
337            crate::inversion::heidi(
338                field_ppm, mask, magnitude, &grid, bdir, &lsqr_params, &heidi_params(config, metadata)?, progress,
339            )
340        }
341        InversionAlgorithm::Lsqr => {
342            let params = crate::inversion::LsqrQsmParams {
343                b0: metadata.field_strength,
344                ..config.lsqr.clone()
345            };
346            crate::inversion::lsqr_qsm(
347                field_ppm, mask, magnitude, &grid, bdir, &params, progress,
348            )
349        }
350        InversionAlgorithm::Xqsm => run_xqsm(field_ppm, mask, &grid, config.tile, progress)?,
351        InversionAlgorithm::Qsmnet => run_qsmnet(field_ppm, mask, &grid, "qsmnet", config.tile, progress)?,
352        InversionAlgorithm::QsmnetPlus => run_qsmnet(field_ppm, mask, &grid, "qsmnet-plus", config.tile, progress)?,
353        InversionAlgorithm::Qsmnetme => run_qsmnetme(field_ppm, mask, &grid, bdir, config.tile)?,
354        InversionAlgorithm::Autoqsm => run_autoqsm(field_ppm, mask, &grid)?,
355        InversionAlgorithm::Qsmgan => run_qsmgan(field_ppm, mask, &grid)?,
356        InversionAlgorithm::Ir2qsm => run_ir2qsm(field_ppm, mask, &grid, config.tile, progress)?,
357        InversionAlgorithm::Lpcnn => run_lpcnn(field_ppm, mask, &grid, bdir, config.tile, progress)?,
358        InversionAlgorithm::ModlQsm => run_modl_qsm(field_ppm, mask, &grid, bdir, config.tile, progress)?,
359        // NeXtQSM is single-step (own BFR): `field_ppm` must be the total field.
360        InversionAlgorithm::Nextqsm => run_nextqsm(field_ppm, mask, metadata, config.tile, progress)?,
361        InversionAlgorithm::Tgv | InversionAlgorithm::Qsmart => {
362            return Err(PipelineError::InvalidConfig(
363                format!("{:?} should use run_tgv or run_qsmart", config.algorithm),
364            ));
365        }
366        // iQSM/iQSM+ reconstruct from wrapped phase (not a local field) — end-to-end.
367        InversionAlgorithm::Iqsm | InversionAlgorithm::IqsmPlus => {
368            return Err(PipelineError::InvalidConfig(
369                format!("{:?} should use run_iqsm / run_iqsm_plus (it takes wrapped phase)", config.algorithm),
370            ));
371        }
372    };
373
374    Ok(chi)
375}
376
377/// HEIDI's parameters with the scan's: NESTA's result depends on χ's scale in upstream's phase
378/// units (see the HEIDI module docs), so the field strength and echo times come from metadata,
379/// and `config.heidi.te` picks the echo time of the field (by default the last, as qsm_heidi's
380/// phase2susceptibilityfactor uses).
381fn heidi_params(config: &InversionConfig, metadata: &ScanMetadata) -> Result<crate::inversion::HeidiParams, PipelineError> {
382    Ok(crate::inversion::HeidiParams {
383        b0: metadata.field_strength,
384        echo_times: scan_echo_times("HEIDI", config.heidi.te, metadata)?,
385        ..config.heidi.clone()
386    })
387}
388
389/// Why HEIDI's cone fill cannot follow `config`'s inversion, if it cannot (`None` when HEIDI is
390/// off or the seed is allowed). The hard errors of [`run_heidi_cone_fill`], for hosts to check
391/// at plan time:
392///
393/// - the method has no local field to fill the cone from (the total-field and phase methods);
394/// - TKD's or TSVD's threshold is above HEIDI's cone threshold: the coefficients they truncated
395///   would be kept as data (upstream: "DipoleFilter parameter must not be larger that Cone
396///   threshold", `computesusceptibility_heidi.m` L922-923).
397///
398/// The softer advice on seeds (LSQR is the reference; regularised seeds are discouraged) is in
399/// the `heidi.*` rules ([`super::rules`]).
400pub fn heidi_seed_problem(config: &InversionConfig) -> Option<String> {
401    if !config.apply_heidi {
402        return None;
403    }
404    let cone = config.heidi.cone_threshold;
405    let truncation = match config.algorithm {
406        InversionAlgorithm::Tkd => Some(("TKD", config.tkd.threshold)),
407        InversionAlgorithm::Tsvd => Some(("TSVD", config.tsvd.threshold)),
408        _ => None,
409    };
410    if let Some((name, threshold)) = truncation {
411        if threshold > cone {
412            return Some(format!(
413                "HEIDI after {name}: {name}'s threshold ({threshold}) is above HEIDI's cone threshold \
414                 ({cone}), so coefficients {name} truncated would be kept as data; lower it to {cone} or less"
415            ));
416        }
417    }
418    (config.input(1) != InversionInput::LocalField).then(|| {
419        format!("HEIDI needs the local field the seed was inverted from; {} has none", config.algorithm.id())
420    })
421}
422
423/// HEIDI's cone fill on a seed χ map: streaking reduction after `config`'s inversion
424/// ([`crate::inversion::heidi_cone_fill`]). [`run_dipole_inversion`] calls it when
425/// [`InversionConfig::cone_fill_after`] holds; a host that combines several inversions (two-pass)
426/// runs them with `apply_heidi` off and calls this once on the combined map, for any seed.
427///
428/// # Arguments
429/// * `chi_seed` - The inversion's χ in ppm
430/// * `field_ppm` - The local field the seed was inverted from
431/// * `mask` - The mask the seed was inverted on (for two-pass, the main pass's)
432/// * `metadata` - Scan metadata (field strength, echo times)
433/// * `config` - The inversion's config: its algorithm is checked with [`heidi_seed_problem`],
434///   and `heidi` holds the cone fill's parameters
435/// * `progress` - Progress callback (current_iter, max_iter)
436pub fn run_heidi_cone_fill(
437    chi_seed: &[f64],
438    field_ppm: &[f64],
439    mask: &[u8],
440    metadata: &ScanMetadata,
441    config: &InversionConfig,
442    progress: &mut dyn FnMut(usize, usize),
443) -> Result<Vec<f64>, PipelineError> {
444    if let Some(problem) = heidi_seed_problem(&InversionConfig { apply_heidi: true, ..config.clone() }) {
445        return Err(PipelineError::InvalidConfig(problem));
446    }
447    metadata.require_contiguous_slices()?;
448    let grid = metadata.grid();
449    let n_voxels = grid.n_total();
450    for (got, what) in [(chi_seed.len(), "seed"), (field_ppm.len(), "field"), (mask.len(), "mask")] {
451        if got != n_voxels {
452            return Err(PipelineError::InvalidInput(format!(
453                "HEIDI's {what} has {got} voxels, the scan {n_voxels}"
454            )));
455        }
456    }
457    let params = heidi_params(config, metadata)?;
458    Ok(crate::inversion::heidi_cone_fill(
459        field_ppm, mask, chi_seed, &grid, metadata.b0_direction, &params, progress,
460    ))
461}
462
463/// Source the xQSM weights and run inference. Requires the `onnx` feature;
464/// weights resolve through [`crate::models`] (bytes supplied with
465/// [`crate::models::provide_weights`], then `$QSM_MODEL_DIR`/cache/download on native).
466#[cfg(feature = "onnx")]
467fn run_xqsm(
468    local_field_ppm: &[f64],
469    mask: &[u8],
470    grid: &crate::Grid,
471    tile: Option<(usize, usize)>,
472    progress: &mut dyn FnMut(usize, usize),
473) -> Result<Vec<f64>, PipelineError> {
474    let spec = crate::models::find_model("xqsm")
475        .ok_or_else(|| PipelineError::InvalidConfig("xqsm not in model registry".into()))?;
476    let bytes = crate::models::primary_weight_bytes(spec)
477        .map_err(PipelineError::InvalidConfig)?;
478    match tile {
479        Some((core, halo)) => crate::inversion::xqsm_tiled(
480            local_field_ppm, mask, grid, &bytes, &crate::inversion::TileConfig { core, halo }, progress,
481        ),
482        None => crate::inversion::xqsm(local_field_ppm, mask, grid, &bytes),
483    }
484    .map_err(|e| PipelineError::AlgorithmError(e.to_string()))
485}
486
487#[cfg(not(feature = "onnx"))]
488fn run_xqsm(
489    _local_field_ppm: &[f64],
490    _mask: &[u8],
491    _grid: &crate::Grid,
492    _tile: Option<(usize, usize)>,
493    _progress: &mut dyn FnMut(usize, usize),
494) -> Result<Vec<f64>, PipelineError> {
495    Err(PipelineError::InvalidConfig(
496        "xQSM requires building qsm-core with the 'onnx' feature".into(),
497    ))
498}
499
500/// Run NeXtQSM end-to-end from the **total** field (it does its own background
501/// removal), sourcing both weight files from the registry.
502///
503/// NeXtQSM spans BFR + dipole inversion, so unlike the other entries in
504/// [`InversionAlgorithm`] its input is the *total* field rather than a local one. It is
505/// reachable both ways: as [`InversionAlgorithm::Nextqsm`] through
506/// [`run_dipole_inversion`], and directly here for a caller that wants it standalone.
507///
508/// `total_field_ppm` and `mask` are column-major `(nx,ny,nz)`; the grid and B0 direction come
509/// from `metadata`. Requires the `onnx` feature; both weight files resolve through [`crate::models`]
510/// (supplied bytes first, as for every network). Returns susceptibility (ppm), masked.
511///
512/// Refuses a volume whose slices are not contiguous, like every other stage here: NeXtQSM's
513/// unrolled data-consistency term is an FFT dipole convolution, so a gap invalidates it as
514/// thoroughly as it does a classical inversion. See
515/// [`ScanMetadata::require_contiguous_slices`].
516#[cfg(feature = "onnx")]
517pub fn run_nextqsm(
518    total_field_ppm: &[f64],
519    mask: &[u8],
520    metadata: &ScanMetadata,
521    tile: Option<(usize, usize)>,
522    progress: &mut dyn FnMut(usize, usize),
523) -> Result<Vec<f64>, PipelineError> {
524    // Checked here rather than only in `run_dipole_inversion`, because this is also a public
525    // entry point in its own right. Reached through the dispatcher it runs twice, which costs
526    // one float comparison.
527    metadata.require_contiguous_slices()?;
528    let grid = &metadata.grid();
529    let bdir = metadata.b0_direction;
530    let spec = crate::models::find_model("nextqsm")
531        .ok_or_else(|| PipelineError::InvalidConfig("nextqsm not in model registry".into()))?;
532    let files = crate::models::all_weight_bytes(spec).map_err(PipelineError::InvalidConfig)?;
533    let [bf, vjp] = &files[..] else {
534        return Err(PipelineError::InvalidConfig(
535            format!("nextqsm expects 2 weight files (BFR, VJP), got {}", files.len()),
536        ));
537    };
538    match tile {
539        Some((core, halo)) => crate::inversion::nextqsm_tiled(
540            total_field_ppm, mask, grid, bdir, bf, vjp, &crate::inversion::TileConfig { core, halo }, progress,
541        ),
542        None => crate::inversion::nextqsm(total_field_ppm, mask, grid, bdir, bf, vjp),
543    }
544    .map_err(|e| PipelineError::AlgorithmError(e.to_string()))
545}
546
547/// Stub when built without the `onnx` feature.
548#[cfg(not(feature = "onnx"))]
549pub fn run_nextqsm(
550    _total_field_ppm: &[f64],
551    _mask: &[u8],
552    _metadata: &ScanMetadata,
553    _tile: Option<(usize, usize)>,
554    _progress: &mut dyn FnMut(usize, usize),
555) -> Result<Vec<f64>, PipelineError> {
556    Err(PipelineError::InvalidConfig(
557        "NeXtQSM requires building qsm-core with the 'onnx' feature".into(),
558    ))
559}
560
561/// Source a QSMnet-family model's weights and run inference (requires the `onnx`
562/// feature). `model_id` selects the registry entry (`qsmnet` / `qsmnet-plus`),
563/// `norm` supplies that checkpoint's normalization constants.
564#[cfg(feature = "onnx")]
565fn run_qsmnet(
566    local_field_ppm: &[f64],
567    mask: &[u8],
568    grid: &crate::Grid,
569    model_id: &str,
570    tile: Option<(usize, usize)>,
571    progress: &mut dyn FnMut(usize, usize),
572) -> Result<Vec<f64>, PipelineError> {
573    use crate::inversion::QsmnetNorm;
574    let norm = match model_id {
575        "qsmnet-plus" => QsmnetNorm::qsmnet_plus(),
576        _ => QsmnetNorm::qsmnet(),
577    };
578    let spec = crate::models::find_model(model_id).ok_or_else(|| {
579        PipelineError::InvalidConfig(format!("{model_id} not in model registry"))
580    })?;
581    let bytes = crate::models::primary_weight_bytes(spec)
582        .map_err(PipelineError::InvalidConfig)?;
583    match tile {
584        Some((core, halo)) => crate::inversion::qsmnet_tiled(
585            local_field_ppm, mask, grid, &bytes, &norm, &crate::inversion::TileConfig { core, halo }, progress,
586        ),
587        None => crate::inversion::qsmnet(local_field_ppm, mask, grid, &bytes, &norm),
588    }
589    .map_err(|e| PipelineError::AlgorithmError(e.to_string()))
590}
591
592#[cfg(not(feature = "onnx"))]
593fn run_qsmnet(
594    _local_field_ppm: &[f64],
595    _mask: &[u8],
596    _grid: &crate::Grid,
597    _model_id: &str,
598    _tile: Option<(usize, usize)>,
599    _progress: &mut dyn FnMut(usize, usize),
600) -> Result<Vec<f64>, PipelineError> {
601    Err(PipelineError::InvalidConfig(
602        "QSMnet requires building qsm-core with the 'onnx' feature".into(),
603    ))
604}
605
606/// Source the QSMnetME weights and run it by the authors' route (requires the `onnx` feature).
607/// `tile` (the deep-learning inversions' `(core, halo)`) turns the toolbox's whole-volume pass into
608/// a sliding window of `core + 2·halo` rounded up to a multiple of 16, for memory-limited hosts.
609#[cfg(feature = "onnx")]
610fn run_qsmnetme(
611    local_field_ppm: &[f64],
612    mask: &[u8],
613    grid: &crate::Grid,
614    bdir: (f64, f64, f64),
615    tile: Option<(usize, usize)>,
616) -> Result<Vec<f64>, PipelineError> {
617    let bytes = crate::models::primary_weight("qsmnetme").map_err(PipelineError::InvalidConfig)?;
618    let params = crate::inversion::QsmnetmeParams {
619        patch: tile.map(|(core, halo)| {
620            let p = (core + 2 * halo).max(16).div_ceil(16) * 16;
621            (p, p, p)
622        }),
623    };
624    crate::inversion::qsmnetme(
625        local_field_ppm, mask, grid, bdir, &bytes, &crate::inversion::QsmnetmeNorm::default(), &params,
626    )
627    .map_err(|e| PipelineError::AlgorithmError(e.to_string()))
628}
629
630#[cfg(not(feature = "onnx"))]
631fn run_qsmnetme(
632    _local_field_ppm: &[f64],
633    _mask: &[u8],
634    _grid: &crate::Grid,
635    _bdir: (f64, f64, f64),
636    _tile: Option<(usize, usize)>,
637) -> Result<Vec<f64>, PipelineError> {
638    Err(PipelineError::InvalidConfig(
639        "QSMnetME requires building qsm-core with the 'onnx' feature".into(),
640    ))
641}
642
643/// Source the AutoQSM weights and run inference (requires the `onnx` feature).
644/// `field` is the **total** field (AutoQSM does its own background removal).
645#[cfg(feature = "onnx")]
646fn run_autoqsm(
647    field: &[f64],
648    mask: &[u8],
649    grid: &crate::Grid,
650) -> Result<Vec<f64>, PipelineError> {
651    let spec = crate::models::find_model("autoqsm")
652        .ok_or_else(|| PipelineError::InvalidConfig("autoqsm not in model registry".into()))?;
653    let bytes = crate::models::primary_weight_bytes(spec).map_err(PipelineError::InvalidConfig)?;
654    crate::inversion::autoqsm(field, mask, grid, &bytes)
655        .map_err(|e| PipelineError::AlgorithmError(e.to_string()))
656}
657
658#[cfg(not(feature = "onnx"))]
659fn run_autoqsm(
660    _field: &[f64],
661    _mask: &[u8],
662    _grid: &crate::Grid,
663) -> Result<Vec<f64>, PipelineError> {
664    Err(PipelineError::InvalidConfig(
665        "AutoQSM requires building qsm-core with the 'onnx' feature".into(),
666    ))
667}
668
669/// Source the QSMGAN generator weights and run inference (requires the `onnx` feature).
670#[cfg(feature = "onnx")]
671fn run_qsmgan(local_field_ppm: &[f64], mask: &[u8], grid: &crate::Grid) -> Result<Vec<f64>, PipelineError> {
672    let bytes = crate::models::primary_weight("qsmgan").map_err(PipelineError::InvalidConfig)?;
673    crate::inversion::qsmgan(local_field_ppm, mask, grid, &bytes)
674        .map_err(|e| PipelineError::AlgorithmError(e.to_string()))
675}
676
677/// Source the IR2QSM weights and run inference (requires the `onnx` feature).
678#[cfg(feature = "onnx")]
679fn run_ir2qsm(
680    local_field_ppm: &[f64],
681    mask: &[u8],
682    grid: &crate::Grid,
683    tile: Option<(usize, usize)>,
684    progress: &mut dyn FnMut(usize, usize),
685) -> Result<Vec<f64>, PipelineError> {
686    let bytes = crate::models::primary_weight("ir2qsm").map_err(PipelineError::InvalidConfig)?;
687    match tile {
688        Some((core, halo)) => crate::inversion::ir2qsm_tiled(
689            local_field_ppm, mask, grid, &bytes, &crate::inversion::TileConfig { core, halo }, progress,
690        ),
691        None => crate::inversion::ir2qsm(local_field_ppm, mask, grid, &bytes),
692    }
693    .map_err(|e| PipelineError::AlgorithmError(e.to_string()))
694}
695
696/// Source the LPCNN proximal-CNN weights and run inference (requires the `onnx` feature).
697/// LPCNN's k-space data-consistency step uses the B0 direction.
698#[cfg(feature = "onnx")]
699fn run_lpcnn(
700    local_field_ppm: &[f64],
701    mask: &[u8],
702    grid: &crate::Grid,
703    bdir: (f64, f64, f64),
704    tile: Option<(usize, usize)>,
705    progress: &mut dyn FnMut(usize, usize),
706) -> Result<Vec<f64>, PipelineError> {
707    let bytes = crate::models::primary_weight("lpcnn").map_err(PipelineError::InvalidConfig)?;
708    match tile {
709        Some((core, halo)) => crate::inversion::lpcnn_tiled(
710            local_field_ppm, mask, grid, bdir, &bytes, &crate::inversion::TileConfig { core, halo }, progress,
711        ),
712        None => crate::inversion::lpcnn(local_field_ppm, mask, grid, bdir, &bytes),
713    }
714    .map_err(|e| PipelineError::AlgorithmError(e.to_string()))
715}
716
717/// Source the MoDL-QSM prior-CNN weights and run inference (requires the `onnx` feature).
718/// Output is the STI χ33 component.
719#[cfg(feature = "onnx")]
720fn run_modl_qsm(
721    local_field_ppm: &[f64],
722    mask: &[u8],
723    grid: &crate::Grid,
724    bdir: (f64, f64, f64),
725    tile: Option<(usize, usize)>,
726    progress: &mut dyn FnMut(usize, usize),
727) -> Result<Vec<f64>, PipelineError> {
728    let bytes = crate::models::primary_weight("modl-qsm").map_err(PipelineError::InvalidConfig)?;
729    match tile {
730        Some((core, halo)) => crate::inversion::modl_qsm_tiled(
731            local_field_ppm, mask, grid, bdir, &bytes, &crate::inversion::TileConfig { core, halo }, progress,
732        ),
733        None => crate::inversion::modl_qsm(local_field_ppm, mask, grid, bdir, &bytes),
734    }
735    .map_err(|e| PipelineError::AlgorithmError(e.to_string()))
736}
737
738#[cfg(not(feature = "onnx"))]
739fn run_qsmgan(_f: &[f64], _m: &[u8], _g: &crate::Grid) -> Result<Vec<f64>, PipelineError> {
740    Err(PipelineError::InvalidConfig("QSMGAN requires building qsm-core with the 'onnx' feature".into()))
741}
742#[cfg(not(feature = "onnx"))]
743fn run_ir2qsm(
744    _f: &[f64],
745    _m: &[u8],
746    _g: &crate::Grid,
747    _tile: Option<(usize, usize)>,
748    _progress: &mut dyn FnMut(usize, usize),
749) -> Result<Vec<f64>, PipelineError> {
750    Err(PipelineError::InvalidConfig("IR2QSM requires building qsm-core with the 'onnx' feature".into()))
751}
752#[cfg(not(feature = "onnx"))]
753fn run_lpcnn(
754    _f: &[f64],
755    _m: &[u8],
756    _g: &crate::Grid,
757    _b: (f64, f64, f64),
758    _tile: Option<(usize, usize)>,
759    _progress: &mut dyn FnMut(usize, usize),
760) -> Result<Vec<f64>, PipelineError> {
761    Err(PipelineError::InvalidConfig("LPCNN requires building qsm-core with the 'onnx' feature".into()))
762}
763#[cfg(not(feature = "onnx"))]
764fn run_modl_qsm(_f: &[f64], _m: &[u8], _g: &crate::Grid, _b: (f64, f64, f64), _tile: Option<(usize, usize)>, _progress: &mut dyn FnMut(usize, usize)) -> Result<Vec<f64>, PipelineError> {
765    Err(PipelineError::InvalidConfig("MoDL-QSM requires building qsm-core with the 'onnx' feature".into()))
766}
767
768/// The authors' fixed inference conventions for the iQSM/iQFM LoT-Unet family:
769/// phase sign `-1` and a 3-voxel mask-erosion radius. These are training-time
770/// constants, not user knobs, so the pipeline runners bake them in.
771#[cfg(feature = "onnx")]
772const IQSM_PHASE_SIGN: f64 = -1.0;
773#[cfg(feature = "onnx")]
774const IQSM_ERODED_RAD: i32 = 3;
775
776/// Run iQSM single-step reconstruction from wrapped **phase** (joint unwrap +
777/// background removal + dipole inversion). Multi-echo inputs are reconstructed
778/// per echo and combined with `echo_weighting` (default (magnitude·TE)², the inverse-variance
779/// weight; see [`IqsmEchoWeighting`](crate::inversion::IqsmEchoWeighting)). Requires the `onnx`
780/// feature + `iqsm` weights.
781///
782/// `phases`/`magnitudes` are per-echo wrapped-phase / magnitude volumes (column-major);
783/// `metadata` supplies the grid, echo times, and B0. Returns susceptibility (ppm),
784/// referenced per `reference`.
785#[cfg(feature = "onnx")]
786pub fn run_iqsm(
787    phases: &[&[f64]],
788    magnitudes: &[&[f64]],
789    mask: &[u8],
790    metadata: &ScanMetadata,
791    reference: QsmReference,
792    tile: Option<(usize, usize)>,
793    echo_weighting: crate::inversion::IqsmEchoWeighting,
794) -> Result<Vec<f64>, PipelineError> {
795    metadata.require_contiguous_slices()?;
796    let grid = metadata.grid();
797    let bytes = crate::models::primary_weight("iqsm").map_err(PipelineError::InvalidConfig)?;
798    let tile = tile.map(|(core, halo)| crate::inversion::TileConfig { core, halo });
799    let chi = crate::inversion::iqsm_multi_echo(
800        phases, magnitudes, mask, &grid, &metadata.echo_times,
801        metadata.field_strength, IQSM_PHASE_SIGN, IQSM_ERODED_RAD, &bytes, tile.as_ref(), echo_weighting,
802    ).map_err(|e| PipelineError::AlgorithmError(e.to_string()))?;
803    Ok(super::referencing::apply_reference(&chi, mask, reference))
804}
805
806#[cfg(not(feature = "onnx"))]
807pub fn run_iqsm(
808    _phases: &[&[f64]], _magnitudes: &[&[f64]], _mask: &[u8],
809    _metadata: &ScanMetadata, _reference: QsmReference, _tile: Option<(usize, usize)>,
810    _echo_weighting: crate::inversion::IqsmEchoWeighting,
811) -> Result<Vec<f64>, PipelineError> {
812    Err(PipelineError::InvalidConfig("iQSM requires building qsm-core with the 'onnx' feature".into()))
813}
814
815/// Run iQSM+ single-step reconstruction from wrapped **phase** (orientation-adaptive
816/// variant; the B0 direction is a genuine network input). See [`run_iqsm`]; the echoes are
817/// combined with (magnitude·TE)² weights, as in both of the authors' implementations.
818#[cfg(feature = "onnx")]
819pub fn run_iqsm_plus(
820    phases: &[&[f64]],
821    magnitudes: &[&[f64]],
822    mask: &[u8],
823    metadata: &ScanMetadata,
824    reference: QsmReference,
825    tile: Option<(usize, usize)>,
826) -> Result<Vec<f64>, PipelineError> {
827    metadata.require_contiguous_slices()?;
828    let grid = metadata.grid();
829    let bytes = crate::models::primary_weight("iqsm-plus").map_err(PipelineError::InvalidConfig)?;
830    let tile = tile.map(|(core, halo)| crate::inversion::TileConfig { core, halo });
831    let chi = crate::inversion::iqsm_plus_multi_echo(
832        phases, magnitudes, mask, &grid, &metadata.echo_times,
833        metadata.field_strength, metadata.b0_direction, IQSM_PHASE_SIGN, IQSM_ERODED_RAD, &bytes,
834        tile.as_ref(),
835    ).map_err(|e| PipelineError::AlgorithmError(e.to_string()))?;
836    Ok(super::referencing::apply_reference(&chi, mask, reference))
837}
838
839#[cfg(not(feature = "onnx"))]
840pub fn run_iqsm_plus(
841    _phases: &[&[f64]], _magnitudes: &[&[f64]], _mask: &[u8],
842    _metadata: &ScanMetadata, _reference: QsmReference, _tile: Option<(usize, usize)>,
843) -> Result<Vec<f64>, PipelineError> {
844    Err(PipelineError::InvalidConfig("iQSM+ requires building qsm-core with the 'onnx' feature".into()))
845}
846
847/// Run iQFM: joint unwrapping + background removal from wrapped **phase** in one
848/// network (the tissue-field head of the iQSM LoT-Unet). Returns the local
849/// (background-removed) field in ppm — feed it to any dipole inversion. This is a
850/// deep-learning replacement for the unwrap + BFR stages, not an inversion. Multi-echo
851/// inputs are combined with `echo_weighting`, as in [`run_iqsm`].
852/// Requires the `onnx` feature + `iqfm` weights.
853#[cfg(feature = "onnx")]
854pub fn run_iqfm(
855    phases: &[&[f64]],
856    magnitudes: &[&[f64]],
857    mask: &[u8],
858    metadata: &ScanMetadata,
859    tile: Option<(usize, usize)>,
860    echo_weighting: crate::inversion::IqsmEchoWeighting,
861) -> Result<Vec<f64>, PipelineError> {
862    metadata.require_contiguous_slices()?;
863    let grid = metadata.grid();
864    let bytes = crate::models::primary_weight("iqfm").map_err(PipelineError::InvalidConfig)?;
865    let tile = tile.map(|(core, halo)| crate::inversion::TileConfig { core, halo });
866    crate::inversion::iqfm_multi_echo(
867        phases, magnitudes, mask, &grid, &metadata.echo_times,
868        metadata.field_strength, IQSM_PHASE_SIGN, IQSM_ERODED_RAD, &bytes, tile.as_ref(), echo_weighting,
869    ).map_err(|e| PipelineError::AlgorithmError(e.to_string()))
870}
871
872#[cfg(not(feature = "onnx"))]
873pub fn run_iqfm(
874    _phases: &[&[f64]], _magnitudes: &[&[f64]], _mask: &[u8], _metadata: &ScanMetadata,
875    _tile: Option<(usize, usize)>, _echo_weighting: crate::inversion::IqsmEchoWeighting,
876) -> Result<Vec<f64>, PipelineError> {
877    Err(PipelineError::InvalidConfig("iQFM requires building qsm-core with the 'onnx' feature".into()))
878}
879
880/// Run TGV single-step QSM reconstruction.
881///
882/// Multi-echo data goes through field mapping first and TGV runs on the field in ppm
883/// ([`TgvInput::FieldPpm`](crate::inversion::TgvInput::FieldPpm)), expressed as phase at the
884/// echo time [`TgvParams::te`](crate::inversion::TgvParams::te) picks (by default the first).
885/// Single-echo data goes in as the wrapped phase with its echo time
886/// ([`TgvInput::WrappedPhase`](crate::inversion::TgvInput::WrappedPhase)), and TGV handles
887/// unwrapping as well. Either way the scan's field strength is passed, α are in the
888/// reference's rad units at that TE and B0, and TGV also does background removal and
889/// inversion. A multi-echo field is defined only in its
890/// [`valid_mask`](super::FieldMappingResult::valid_mask), so TGV runs (and χ is non-zero) there.
891///
892/// # Returns
893/// Susceptibility map in ppm (unreferenced — call `apply_reference` after)
894pub fn run_tgv(
895    phases: &[&[f64]],
896    magnitudes: Option<&[&[f64]]>,
897    mask: &[u8],
898    metadata: &ScanMetadata,
899    field_mapping_config: &FieldMappingConfig,
900    tgv_params: &crate::inversion::TgvParams,
901    reference: QsmReference,
902    progress: &mut dyn FnMut(usize, usize),
903) -> Result<Vec<f64>, PipelineError> {
904    use crate::inversion::TgvInput;
905
906    metadata.require_contiguous_slices()?;
907    let grid = metadata.grid();
908    let (bx, by, bz) = metadata.b0_direction;
909
910    // The reference (QuantitativeSusceptibilityMappingTGV.jl) takes one echo and its TE; a
911    // multi-echo field map is converted at the echo time tgv_params.te picks.
912    let echo_times = &metadata.echo_times;
913    let te_s = if phases.len() > 1 {
914        tgv_params.te.resolve(echo_times)
915    } else {
916        crate::EchoTime::First.resolve(echo_times)
917    }
918    .map_err(|e| PipelineError::InvalidInput(format!("TGV: {e}")))?;
919    let b0_t = metadata.field_strength;
920    if !(b0_t.is_finite() && b0_t > 0.0) {
921        return Err(PipelineError::InvalidInput(format!(
922            "TGV needs a positive field strength, got B0 = {b0_t} T"
923        )));
924    }
925
926    let (chi_ppm, chi_mask) = if phases.len() > 1 {
927        let field_result = super::field_mapping::run_field_mapping(
928            phases, magnitudes, mask, metadata,
929            field_mapping_config, &mut |_, _| {},
930        )?;
931        // The field is defined only in `valid_mask` (MCPC-3D-S may leave part of the brain mask
932        // without a phase offset), so TGV and the referencing work there.
933        let chi = crate::inversion::tgv_qsm(
934            TgvInput::FieldPpm { field: &field_result.b0_field_ppm, echo_times, b0_t },
935            &field_result.valid_mask, &grid, tgv_params, (bx, by, bz), &mut *progress,
936        )?;
937        (chi, field_result.valid_mask)
938    } else {
939        let input = TgvInput::WrappedPhase {
940            phase: phases[0],
941            te_s,
942            b0_t,
943        };
944        (crate::inversion::tgv_qsm(input, mask, &grid, tgv_params, (bx, by, bz), &mut *progress)?, mask.to_vec())
945    };
946
947    Ok(super::referencing::apply_reference(&chi_ppm, &chi_mask, reference))
948}
949
950#[cfg(test)]
951mod tests {
952    use super::*;
953
954    /// Calls the stage, rather than inspecting its source as the coverage test in `config.rs`
955    /// does, so the refusal is proven to happen for a real inversion.
956    /// NeXtQSM is reachable both as an `InversionAlgorithm` and as a standalone entry point, so
957    /// both ways in have to refuse a gap. This covers the dispatcher route, which works in any
958    /// build; the standalone route needs the `onnx` feature, because without it `run_nextqsm`
959    /// is the stub and reporting the missing feature is more use than reporting the gap. What
960    /// guarantees the standalone route in a default build is the source-level coverage test in
961    /// `config.rs`, which reads the `onnx` body and requires the call to be there.
962    ///
963    /// Note what this does *not* pin: the NeXtQSM route is now defended twice, once in the
964    /// dispatcher and once inside `run_nextqsm`, so deleting either one on its own leaves this
965    /// test passing. Checked by perturbation — removing the dispatcher's call was caught only
966    /// by the coverage test and by the default-algorithm test below, which has no second line
967    /// of defence behind it. Both call sites are pinned by the coverage test, not by this one.
968    #[test]
969    fn nextqsm_refuses_a_gapped_acquisition_through_the_dispatcher() {
970        let (nx, ny, nz) = (8, 8, 8);
971        let n = nx * ny * nz;
972        let config = InversionConfig {
973            algorithm: InversionAlgorithm::Nextqsm,
974            ..InversionConfig::default()
975        };
976        let err = run_dipole_inversion(
977            &vec![0.01; n], &vec![1u8; n], &gapped_meta(nx, ny, nz), &config, None, &mut |_, _| {},
978        )
979        .expect_err("a gapped volume must be refused");
980        let PipelineError::InvalidInput(msg) = &err else { panic!("{err:?}") };
981        assert!(msg.contains("1.0000 mm gap"), "{msg}");
982    }
983
984    /// The standalone entry point. Needs `onnx` for the real function to exist, but still does
985    /// no I/O: the check runs before the weight registry is touched, which is the whole point
986    /// of refusing before any work.
987    #[cfg(feature = "onnx")]
988    #[test]
989    fn nextqsm_refuses_a_gapped_acquisition_standalone() {
990        let (nx, ny, nz) = (8, 8, 8);
991        let n = nx * ny * nz;
992        let err = run_nextqsm(
993            &vec![0.01; n], &vec![1u8; n], &gapped_meta(nx, ny, nz), None, &mut |_, _| {},
994        )
995        .expect_err("a gapped volume must be refused");
996        let PipelineError::InvalidInput(msg) = &err else { panic!("{err:?}") };
997        assert!(msg.contains("1.0000 mm gap"), "{msg}");
998    }
999
1000    /// 3 mm pitch with 2 mm slices: a 1 mm gap.
1001    fn gapped_meta(nx: usize, ny: usize, nz: usize) -> ScanMetadata {
1002        ScanMetadata {
1003            dims: (nx, ny, nz),
1004            voxel_size: (1.0, 1.0, 3.0),
1005            echo_times: vec![0.005],
1006            field_strength: 3.0,
1007            b0_direction: (0.0, 0.0, 1.0),
1008            slice_geometry: Some(crate::grid::SliceGeometry { thickness: 2.0, axis: 2 }),
1009            affine: None,
1010        }
1011    }
1012
1013    #[test]
1014    fn a_gapped_acquisition_is_refused_before_any_work() {
1015        let (nx, ny, nz) = (8, 8, 8);
1016        let n = nx * ny * nz;
1017        let field = vec![0.01; n];
1018        let mask = vec![1u8; n];
1019        let mut meta = ScanMetadata {
1020            dims: (nx, ny, nz),
1021            voxel_size: (1.0, 1.0, 3.0),
1022            echo_times: vec![0.005],
1023            field_strength: 3.0,
1024            b0_direction: (0.0, 0.0, 1.0),
1025            slice_geometry: None,
1026            affine: None,
1027        };
1028        let config = InversionConfig::default();
1029
1030        assert!(run_dipole_inversion(&field, &mask, &meta, &config, None, &mut |_, _| {}).is_ok());
1031        meta.slice_geometry = Some(crate::grid::SliceGeometry { thickness: 3.0, axis: 2 });
1032        assert!(run_dipole_inversion(&field, &mask, &meta, &config, None, &mut |_, _| {}).is_ok());
1033
1034        meta.slice_geometry = Some(crate::grid::SliceGeometry { thickness: 2.0, axis: 2 });
1035        let err = run_dipole_inversion(&field, &mask, &meta, &config, None, &mut |_, _| {})
1036            .expect_err("a gapped volume must be refused");
1037        let PipelineError::InvalidInput(msg) = &err else { panic!("{err:?}") };
1038        assert!(msg.contains("1.0000 mm gap"), "{msg}");
1039    }
1040
1041    #[test]
1042    fn test_inversion_tkd() {
1043        let (nx, ny, nz) = (8, 8, 8);
1044        let n = nx * ny * nz;
1045        let field = vec![0.01; n];
1046        let mask = vec![1u8; n];
1047        let meta = ScanMetadata {
1048            dims: (nx, ny, nz),
1049            voxel_size: (1.0, 1.0, 1.0),
1050            echo_times: vec![0.005],
1051            field_strength: 3.0,
1052            b0_direction: (0.0, 0.0, 1.0),
1053            slice_geometry: None,
1054            affine: None,
1055        };
1056        let config = InversionConfig {
1057            algorithm: InversionAlgorithm::Tkd,
1058            ..Default::default()
1059        };
1060
1061        let result = run_dipole_inversion(
1062            &field, &mask, &meta, &config, None, &mut |_, _| {},
1063        );
1064        assert!(result.is_ok());
1065        assert_eq!(result.unwrap().len(), n);
1066    }
1067
1068    fn make_inversion_test(alg: InversionAlgorithm) -> Vec<f64> {
1069        make_inversion_test_with(InversionConfig { algorithm: alg, ..Default::default() })
1070    }
1071
1072    fn make_inversion_test_with(config: InversionConfig) -> Vec<f64> {
1073        let (nx, ny, nz) = (8, 8, 8);
1074        let n = nx * ny * nz;
1075        let field = vec![0.01; n];
1076        let mask = vec![1u8; n];
1077        let meta = ScanMetadata {
1078            dims: (nx, ny, nz), voxel_size: (1.0, 1.0, 1.0),
1079            echo_times: vec![0.005], field_strength: 3.0, b0_direction: (0.0, 0.0, 1.0),
1080            slice_geometry: None,
1081            affine: None,
1082        };
1083        run_dipole_inversion(&field, &mask, &meta, &config, None, &mut |_, _| {}).unwrap()
1084    }
1085
1086    #[test]
1087    fn test_inversion_tsvd() {
1088        let chi = make_inversion_test(InversionAlgorithm::Tsvd);
1089        assert_eq!(chi.len(), 8 * 8 * 8);
1090    }
1091
1092    #[test]
1093    fn test_inversion_tikhonov() {
1094        let chi = make_inversion_test(InversionAlgorithm::Tikhonov);
1095        assert_eq!(chi.len(), 8 * 8 * 8);
1096    }
1097
1098    #[test]
1099    fn test_inversion_tv() {
1100        let chi = make_inversion_test(InversionAlgorithm::Tv);
1101        assert_eq!(chi.len(), 8 * 8 * 8);
1102    }
1103
1104    #[test]
1105    fn test_inversion_rts() {
1106        let chi = make_inversion_test(InversionAlgorithm::Rts);
1107        assert_eq!(chi.len(), 8 * 8 * 8);
1108    }
1109
1110    #[test]
1111    fn test_inversion_nltv() {
1112        let n = 8 * 8 * 8;
1113        let field = vec![0.01; n];
1114        let mask = vec![1u8; n];
1115        let mag = vec![1.0; n];
1116        let meta = ScanMetadata {
1117            dims: (8, 8, 8), voxel_size: (1.0, 1.0, 1.0),
1118            echo_times: vec![0.005], field_strength: 3.0, b0_direction: (0.0, 0.0, 1.0),
1119            slice_geometry: None,
1120            affine: None,
1121        };
1122        let config = InversionConfig { algorithm: InversionAlgorithm::Nltv, ..Default::default() };
1123        let chi = run_dipole_inversion(&field, &mask, &meta, &config, Some(&mag), &mut |_, _| {}).unwrap();
1124        assert_eq!(chi.len(), n);
1125        // the magnitude is required
1126        let r = run_dipole_inversion(&field, &mask, &meta, &config, None, &mut |_, _| {});
1127        assert!(matches!(r, Err(PipelineError::InvalidInput(_))));
1128    }
1129
1130    /// HD-QSM takes every echo's magnitude and weights by their Eq. 2 combination: the
1131    /// pipeline's result is `hdqsm` given `hdqsm_magnitude`; a combined magnitude or none is
1132    /// refused.
1133    #[test]
1134    fn test_inversion_hdqsm_takes_the_echo_magnitudes() {
1135        let n = 8 * 8 * 8;
1136        let field: Vec<f64> = (0..n).map(|i| 0.01 * ((i % 7) as f64 - 3.0)).collect();
1137        let mask = vec![1u8; n];
1138        let echoes: Vec<f64> = (0..2 * n).map(|i| 1.0 + (i % 5) as f64).collect();
1139        let meta = ScanMetadata {
1140            dims: (8, 8, 8), voxel_size: (1.0, 1.0, 1.0),
1141            echo_times: vec![0.005, 0.01], field_strength: 3.0, b0_direction: (0.0, 0.0, 1.0),
1142            slice_geometry: None,
1143            affine: None,
1144        };
1145        let hdqsm = crate::inversion::HdQsmParams { max_iter_l1: 2, max_iter_l2: 2, ..Default::default() };
1146        let config = InversionConfig { algorithm: InversionAlgorithm::Hdqsm, hdqsm: hdqsm.clone(), ..Default::default() };
1147        let chi = run_dipole_inversion(&field, &mask, &meta, &config, Some(&echoes), &mut |_, _| {}).unwrap();
1148        let mag = crate::inversion::hdqsm_magnitude(&echoes, &meta.echo_times);
1149        let grid = meta.grid();
1150        let direct = crate::inversion::hdqsm(&field, &mask, Some(&mag), &grid, meta.b0_direction, &hdqsm, |_, _| {});
1151        assert_eq!(chi, direct);
1152        for magnitude in [Some(&echoes[..n]), None] {
1153            let r = run_dipole_inversion(&field, &mask, &meta, &config, magnitude, &mut |_, _| {});
1154            assert!(matches!(r, Err(PipelineError::InvalidInput(_))));
1155        }
1156    }
1157
1158    #[test]
1159    fn test_inversion_ilsqr() {
1160        let ilsqr = crate::inversion::IlsqrParams { max_iter: 3, ..Default::default() };
1161        let chi = make_inversion_test_with(InversionConfig { algorithm: InversionAlgorithm::Ilsqr, ilsqr, ..Default::default() });
1162        assert_eq!(chi.len(), 8 * 8 * 8);
1163    }
1164
1165    #[test]
1166    fn test_inversion_ilsqr_rejects_bad_padding() {
1167        let meta = ScanMetadata {
1168            dims: (8, 8, 8), voxel_size: (1.0, 1.0, 1.0),
1169            echo_times: vec![0.005], field_strength: 3.0, b0_direction: (0.0, 0.0, 1.0),
1170            slice_geometry: None,
1171            affine: None,
1172        };
1173        for pad_mm in [-1.0, f64::NAN, f64::INFINITY] {
1174            let ilsqr = crate::inversion::IlsqrParams { pad_mm, ..Default::default() };
1175            let config = InversionConfig { algorithm: InversionAlgorithm::Ilsqr, ilsqr, ..Default::default() };
1176            let r = run_dipole_inversion(&[0.01; 512], &[1u8; 512], &meta, &config, None, &mut |_, _| {});
1177            assert!(matches!(r, Err(PipelineError::InvalidConfig(_))), "pad {pad_mm} accepted");
1178        }
1179    }
1180
1181    #[test]
1182    fn test_inversion_star() {
1183        let star = crate::inversion::StarParams { pad: [4; 3], ..Default::default() };
1184        let chi = make_inversion_test_with(InversionConfig { algorithm: InversionAlgorithm::Star, star, ..Default::default() });
1185        assert_eq!(chi.len(), 8 * 8 * 8);
1186        assert!(chi.iter().all(|v| v.is_finite()));
1187    }
1188
1189    #[test]
1190    fn test_inversion_star_rejects_a_bad_field_strength() {
1191        let meta = ScanMetadata {
1192            dims: (8, 8, 8), voxel_size: (1.0, 1.0, 1.0),
1193            echo_times: vec![0.005], field_strength: 0.0, b0_direction: (0.0, 0.0, 1.0),
1194            slice_geometry: None,
1195            affine: None,
1196        };
1197        let config = InversionConfig { algorithm: InversionAlgorithm::Star, ..Default::default() };
1198        let r = run_dipole_inversion(&[0.01; 512], &[1u8; 512], &meta, &config, None, &mut |_, _| {});
1199        assert!(matches!(r, Err(PipelineError::InvalidConfig(_))));
1200    }
1201
1202    /// STAR uses the scan's B0 direction (not STI Suite GUI's default `[0 0 1]`): the
1203    /// pipeline's result on an oblique scan is `star_qsm` with that direction.
1204    #[test]
1205    fn test_inversion_star_uses_metadata_b0_direction() {
1206        let (nx, ny, nz) = (12, 12, 12);
1207        let n = nx * ny * nz;
1208        let field: Vec<f64> = (0..n).map(|l| 0.01 * ((l % nx) as f64 - 5.5) + 0.02 * ((l / (nx * ny)) as f64 - 5.5)).collect();
1209        let mask = vec![1u8; n];
1210        let bdir = (0.3, -0.2, 0.932);
1211        let meta = ScanMetadata {
1212            dims: (nx, ny, nz), voxel_size: (1.0, 1.0, 1.0),
1213            echo_times: vec![0.005], field_strength: 3.0, b0_direction: bdir,
1214            slice_geometry: None,
1215            affine: None,
1216        };
1217        let star = crate::inversion::StarParams { pad: [2; 3], ..Default::default() };
1218        let config = InversionConfig { algorithm: InversionAlgorithm::Star, star: star.clone(), ..Default::default() };
1219        let chi = run_dipole_inversion(&field, &mask, &meta, &config, None, &mut |_, _| {}).unwrap();
1220        let grid = crate::Grid::new(nx, ny, nz, 1.0, 1.0, 1.0);
1221        let params = crate::inversion::StarParams { echo_times: vec![0.005], b0: 3.0, ..star };
1222        let want = crate::inversion::star_qsm(&field, &mask, &grid, bdir, &params, |_, _| {});
1223        let axial = crate::inversion::star_qsm(&field, &mask, &grid, (0.0, 0.0, 1.0), &params, |_, _| {});
1224        assert_eq!(chi, want);
1225        assert_ne!(chi, axial, "the direction must matter for this test to mean anything");
1226    }
1227
1228    #[test]
1229    fn test_fansi_family_requires_magnitude() {
1230        let meta = ScanMetadata {
1231            dims: (8, 8, 8), voxel_size: (1.0, 1.0, 1.0),
1232            echo_times: vec![0.005], field_strength: 3.0, b0_direction: (0.0, 0.0, 1.0),
1233            slice_geometry: None,
1234            affine: None,
1235        };
1236        for algorithm in [InversionAlgorithm::Fansi, InversionAlgorithm::FansiTgv, InversionAlgorithm::L1qsm] {
1237            let mut config = InversionConfig { algorithm, ..Default::default() };
1238            config.fansi.max_iter = 2;
1239            config.l1qsm.max_iter = 2;
1240            let r = run_dipole_inversion(&[0.01; 512], &[1u8; 512], &meta, &config, None, &mut |_, _| {});
1241            assert!(matches!(r, Err(PipelineError::InvalidInput(_))), "{algorithm:?} ran without magnitude");
1242            let mag = [1.0; 512];
1243            let r = run_dipole_inversion(&[0.01; 512], &[1u8; 512], &meta, &config, Some(&mag), &mut |_, _| {});
1244            assert!(r.is_ok(), "{algorithm:?} failed with magnitude");
1245        }
1246    }
1247
1248    #[test]
1249    fn test_inversion_amp_pe() {
1250        let chi = make_inversion_test(InversionAlgorithm::AmpPe);
1251        assert_eq!(chi.len(), 8 * 8 * 8);
1252    }
1253
1254    #[test]
1255    fn test_inversion_lsqr() {
1256        let chi = make_inversion_test(InversionAlgorithm::Lsqr);
1257        assert_eq!(chi.len(), 8 * 8 * 8);
1258        assert!(chi.iter().all(|v| v.is_finite()));
1259    }
1260
1261    #[test]
1262    fn test_inversion_heidi() {
1263        // A short NESTA budget: the default runs all 8 x 500 iterations (tolerance eps).
1264        let heidi = crate::inversion::HeidiParams { continuation_steps: 2, inner_iterations: 20, ..Default::default() };
1265        let chi = make_inversion_test_with(InversionConfig { algorithm: InversionAlgorithm::Lsqr, apply_heidi: true, heidi, ..Default::default() });
1266        assert_eq!(chi.len(), 8 * 8 * 8);
1267        assert!(chi.iter().all(|v| v.is_finite()));
1268    }
1269
1270    /// The hard errors of HEIDI after another inversion: a truncation threshold above the
1271    /// cone's, and a method with no local field.
1272    #[test]
1273    fn heidi_seed_problems() {
1274        let mut c = InversionConfig { algorithm: InversionAlgorithm::Tkd, ..Default::default() };
1275        assert_eq!(heidi_seed_problem(&c), None, "HEIDI off");
1276        c.apply_heidi = true;
1277        assert!(heidi_seed_problem(&c).is_some(), "TKD's default 0.15 is above the cone's 0.1");
1278        c.tkd.threshold = 0.1;
1279        assert_eq!(heidi_seed_problem(&c), None);
1280        c.heidi.cone_threshold = 0.05;
1281        assert!(heidi_seed_problem(&c).is_some(), "checks the configured cone threshold");
1282        c.algorithm = InversionAlgorithm::Tsvd;
1283        c.tsvd.threshold = 0.2;
1284        assert!(heidi_seed_problem(&c).is_some());
1285        for a in [InversionAlgorithm::Tfi, InversionAlgorithm::Qsmart, InversionAlgorithm::Tgv, InversionAlgorithm::Iqsm] {
1286            let c = InversionConfig { algorithm: a, apply_heidi: true, ..Default::default() };
1287            assert!(heidi_seed_problem(&c).is_some(), "{a:?} has no local field");
1288        }
1289        for a in [InversionAlgorithm::Lsqr, InversionAlgorithm::Rts, InversionAlgorithm::Medi, InversionAlgorithm::Ndi] {
1290            let c = InversionConfig { algorithm: a, apply_heidi: true, ..Default::default() };
1291            assert_eq!(heidi_seed_problem(&c), None, "{a:?}");
1292        }
1293    }
1294
1295    /// HEIDI after TKD: `run_dipole_inversion` fills the cone of TKD's result, and refuses a TKD
1296    /// threshold above the cone's.
1297    #[test]
1298    fn heidi_after_tkd() {
1299        let heidi = crate::inversion::HeidiParams { continuation_steps: 2, inner_iterations: 5, ..Default::default() };
1300        let mut config = InversionConfig { algorithm: InversionAlgorithm::Tkd, apply_heidi: true, heidi, ..Default::default() };
1301        assert!(config.cone_fill_after());
1302        let meta = ScanMetadata {
1303            dims: (8, 8, 8), voxel_size: (1.0, 1.0, 1.0),
1304            echo_times: vec![0.005], field_strength: 3.0, b0_direction: (0.0, 0.0, 1.0),
1305            slice_geometry: None,
1306            affine: None,
1307        };
1308        let r = run_dipole_inversion(&[0.01; 512], &[1u8; 512], &meta, &config, None, &mut |_, _| {});
1309        assert!(matches!(r, Err(PipelineError::InvalidConfig(_))), "TKD 0.15 > cone 0.1 must be refused");
1310        config.tkd.threshold = 0.1;
1311        let chi = make_inversion_test_with(config.clone());
1312        assert!(chi.iter().all(|v| v.is_finite()));
1313        config.apply_heidi = false;
1314        let tkd = make_inversion_test_with(config);
1315        assert_ne!(chi, tkd, "the cone was not filled");
1316    }
1317
1318    /// HEIDI runs LSQR first, so its progress must advance monotonically across
1319    /// both stages and end at the combined total rather than restarting.
1320    #[test]
1321    fn test_heidi_progress_is_monotonic() {
1322        let (nx, ny, nz) = (8, 8, 8);
1323        let n = nx * ny * nz;
1324        let meta = ScanMetadata {
1325            dims: (nx, ny, nz), voxel_size: (1.0, 1.0, 1.0),
1326            echo_times: vec![0.005], field_strength: 3.0, b0_direction: (0.0, 0.0, 1.0),
1327            slice_geometry: None,
1328            affine: None,
1329        };
1330        let config = InversionConfig {
1331            algorithm: InversionAlgorithm::Lsqr,
1332            apply_heidi: true,
1333            heidi: crate::inversion::HeidiParams {
1334                continuation_steps: 2,
1335                inner_iterations: 4,
1336                ..Default::default()
1337            },
1338            ..Default::default()
1339        };
1340
1341        let mut seen: Vec<(usize, usize)> = Vec::new();
1342        run_dipole_inversion(
1343            &vec![0.01; n], &vec![1u8; n], &meta, &config, None,
1344            &mut |i, t| seen.push((i, t)),
1345        )
1346        .unwrap();
1347
1348        assert!(!seen.is_empty(), "no progress was reported");
1349        assert!(
1350            seen.windows(2).all(|w| w[0].0 <= w[1].0),
1351            "progress went backwards: {seen:?}"
1352        );
1353        let total = seen[0].1;
1354        assert!(seen.iter().all(|&(_, t)| t == total), "total changed mid-run");
1355        assert!(seen.last().unwrap().0 <= total);
1356    }
1357
1358    /// MEDI gets the ppm field, the field strength and the echo times, and needs the magnitude
1359    /// of every echo.
1360    #[test]
1361    fn test_inversion_medi() {
1362        let (nx, ny, nz) = (8, 8, 8);
1363        let n = nx * ny * nz;
1364        let field: Vec<f64> = (0..n).map(|i| 0.01 * ((i * 7) % 5) as f64).collect();
1365        let mask = vec![1u8; n];
1366        let mag: Vec<f64> = (0..n).map(|i| 1.0 + ((i * 3) % 7) as f64).collect();
1367        let medi = crate::inversion::MediParams { max_iter: 2, cg_max_iter: 5, smv: false, ..Default::default() };
1368        let config = InversionConfig { algorithm: InversionAlgorithm::Medi, medi: medi.clone(), ..Default::default() };
1369        use crate::inversion::EchoMagnitudes;
1370        let echoes: Vec<f64> = (0..3 * n).map(|i| 1.0 + ((i * 5) % 9) as f64).collect();
1371        let cases: [(Vec<f64>, &[f64], usize); 2] = [
1372            (vec![0.004, 0.010, 0.016], &echoes, 3),
1373            (vec![0.02], &mag, 1),
1374        ];
1375        for (tes, given, n_echoes) in cases {
1376            let meta = ScanMetadata {
1377                dims: (nx, ny, nz), voxel_size: (1.0, 1.0, 1.0),
1378                echo_times: tes, field_strength: 7.0, b0_direction: (0.0, 0.0, 1.0),
1379                slice_geometry: None,
1380                affine: None,
1381            };
1382            let via_stage = run_dipole_inversion(&field, &mask, &meta, &config, Some(given), &mut |_, _| {}).unwrap();
1383            let params = crate::inversion::MediParams { b0: 7.0, echo_times: meta.echo_times.clone(), ..medi.clone() };
1384            let direct = crate::inversion::medi(
1385                &field, &mask, EchoMagnitudes { data: given, n_echoes }, &meta.grid(), meta.b0_direction, &params, |_, _| {},
1386            );
1387            assert_eq!(via_stage, direct);
1388            assert!(run_dipole_inversion(&field, &mask, &meta, &config, None, &mut |_, _| {}).is_err());
1389            assert!(run_dipole_inversion(&field, &mask, &meta, &config, Some(&given[..n - 1]), &mut |_, _| {}).is_err());
1390        }
1391        // a combined magnitude of multi-echo data, or uneven echoes, are refused
1392        let meta = |tes: Vec<f64>| ScanMetadata {
1393            dims: (nx, ny, nz), voxel_size: (1.0, 1.0, 1.0),
1394            echo_times: tes, field_strength: 7.0, b0_direction: (0.0, 0.0, 1.0),
1395            slice_geometry: None,
1396            affine: None,
1397        };
1398        assert!(run_dipole_inversion(&field, &mask, &meta(vec![0.004, 0.010, 0.016]), &config, Some(&mag), &mut |_, _| {}).is_err());
1399        assert!(run_dipole_inversion(&field, &mask, &meta(vec![0.004, 0.010, 0.020]), &config, Some(&echoes), &mut |_, _| {}).is_err());
1400
1401        // MEDI-mSMV needs R2* (three or more echoes) for its vessel mask: refused with one echo,
1402        // and with three it is medi() with mSMV on
1403        let msmv = InversionConfig {
1404            medi: crate::inversion::MediParams { msmv: Some(crate::inversion::MsmvParams::default()), ..medi.clone() },
1405            ..config.clone()
1406        };
1407        let err = run_dipole_inversion(&field, &mask, &meta(vec![0.02]), &msmv, Some(&mag), &mut |_, _| {});
1408        assert!(matches!(err, Err(PipelineError::InvalidInput(ref m)) if m.contains("mSMV")), "{err:?}");
1409        let m3 = meta(vec![0.004, 0.010, 0.016]);
1410        let via_stage = run_dipole_inversion(&field, &mask, &m3, &msmv, Some(&echoes), &mut |_, _| {}).unwrap();
1411        let params = crate::inversion::MediParams { b0: 7.0, echo_times: m3.echo_times.clone(), ..msmv.medi.clone() };
1412        let direct = crate::inversion::medi(
1413            &field, &mask, EchoMagnitudes { data: &echoes, n_echoes: 3 }, &m3.grid(), m3.b0_direction, &params, |_, _| {},
1414        );
1415        assert_eq!(via_stage, direct);
1416    }
1417
1418    /// TFI is a total-field method (`InversionConfig::input`): the dispatcher hands `tfi` the
1419    /// field in ppm with the brain mask, the field strength and the echo spacing from metadata,
1420    /// and refuses to run without a magnitude.
1421    /// Each method's echo time for the ppm→rad conversion defaults to its reference's choice
1422    /// (sources on the parameters).
1423    #[test]
1424    fn echo_time_defaults_are_the_references() {
1425        use crate::EchoTime::{First, Last, Spacing, Sum};
1426        let c = InversionConfig::default();
1427        assert_eq!(c.ndi.te, First);
1428        assert_eq!(c.fansi.te, Spacing);
1429        assert_eq!(c.l1qsm.te, First);
1430        assert_eq!(c.whqsm.te, First);
1431        assert_eq!(c.whqsm.weight, crate::inversion::WhQsmWeight::Flat);
1432        assert_eq!(c.nltv.te, First);
1433        assert_eq!(c.tgv.te, First);
1434        assert_eq!(c.star.te, Sum);
1435        assert_eq!(c.medi.te, Spacing);
1436        assert_eq!(c.tfi.te, Spacing);
1437        assert_eq!(c.heidi.te, Last);
1438    }
1439
1440    /// The dispatcher hands a method the scan's echo times and the method's own `te` choice,
1441    /// and refuses echo times the choice cannot use.
1442    #[test]
1443    fn dispatcher_converts_at_the_chosen_echo_time() {
1444        let (nx, ny, nz) = (8, 8, 8);
1445        let n = nx * ny * nz;
1446        let field: Vec<f64> = (0..n).map(|i| 0.05 * ((i as f64) * 0.37).sin()).collect();
1447        let mask = vec![1u8; n];
1448        let mag = vec![1.0; n];
1449        let meta = ScanMetadata {
1450            dims: (nx, ny, nz), voxel_size: (1.0, 1.0, 1.0),
1451            echo_times: vec![0.004, 0.012, 0.020], field_strength: 3.0, b0_direction: (0.0, 0.0, 1.0),
1452            slice_geometry: None,
1453            affine: None,
1454        };
1455        let ndi = crate::inversion::NdiParams { max_iter: 3, ..Default::default() };
1456        let run = |te| {
1457            let config = InversionConfig {
1458                algorithm: InversionAlgorithm::Ndi,
1459                ndi: crate::inversion::NdiParams { te, ..ndi.clone() },
1460                ..Default::default()
1461            };
1462            run_dipole_inversion(&field, &mask, &meta, &config, Some(&mag), &mut |_, _| {})
1463        };
1464        let direct = |te_s: f64| crate::inversion::ndi(
1465            &field, &mask, &mag, &meta.grid(), meta.b0_direction,
1466            &crate::inversion::NdiParams { echo_times: vec![te_s], te: crate::EchoTime::First, ..ndi.clone() },
1467            |_, _| {},
1468        );
1469        // the echo spacing, 8 ms, and the last echo
1470        assert_eq!(run(crate::EchoTime::Spacing).unwrap(), direct(0.012 - 0.004));
1471        assert_eq!(run(crate::EchoTime::Last).unwrap(), direct(0.020));
1472        assert_ne!(direct(0.004), direct(0.012 - 0.004));
1473        let meta_none = ScanMetadata { echo_times: vec![], ..meta.clone() };
1474        let config = InversionConfig { algorithm: InversionAlgorithm::Ndi, ndi, ..Default::default() };
1475        let err = run_dipole_inversion(&field, &mask, &meta_none, &config, Some(&mag), &mut |_, _| {})
1476            .unwrap_err();
1477        assert!(err.to_string().contains("first echo time"), "{err}");
1478        // WH-QSM's default flat weight needs no magnitude; a magnitude weight does
1479        let whqsm = crate::inversion::WhQsmParams { max_iter: 2, ..Default::default() };
1480        let config = InversionConfig { algorithm: InversionAlgorithm::Whqsm, whqsm: whqsm.clone(), ..Default::default() };
1481        assert!(run_dipole_inversion(&field, &mask, &meta, &config, None, &mut |_, _| {}).is_ok());
1482        let config = InversionConfig {
1483            whqsm: crate::inversion::WhQsmParams { weight: crate::inversion::WhQsmWeight::MaxNormalised, ..whqsm },
1484            ..config
1485        };
1486        let err = run_dipole_inversion(&field, &mask, &meta, &config, None, &mut |_, _| {}).unwrap_err();
1487        assert!(err.to_string().contains("needs magnitude data"), "{err}");
1488    }
1489
1490    #[test]
1491    fn tfi_gets_the_total_field_and_scan_parameters() {
1492        let (nx, ny, nz) = (10, 10, 10);
1493        let n = nx * ny * nz;
1494        // a background ramp plus a local bump: a total field
1495        let field: Vec<f64> = (0..n)
1496            .map(|i| {
1497                let (x, y, z) = ((i % nx) as f64, ((i / nx) % ny) as f64, (i / (nx * ny)) as f64);
1498                0.5 * (z - 4.5) / 4.5 + 0.02 * (-((x - 4.5).powi(2) + (y - 4.5).powi(2) + (z - 4.5).powi(2)) / 4.0).exp()
1499            })
1500            .collect();
1501        let mask: Vec<u8> = (0..n)
1502            .map(|i| {
1503                let (x, y, z) = (i % nx, (i / nx) % ny, i / (nx * ny));
1504                u8::from((2..8).contains(&x) && (2..8).contains(&y) && (2..8).contains(&z))
1505            })
1506            .collect();
1507        let mag: Vec<f64> = (0..n).map(|i| 1.0 + (i % 3) as f64).collect();
1508        let meta = ScanMetadata {
1509            dims: (nx, ny, nz), voxel_size: (1.0, 1.0, 1.0),
1510            echo_times: vec![0.004, 0.010, 0.016], field_strength: 7.0, b0_direction: (0.0, 0.0, 1.0),
1511            slice_geometry: None,
1512            affine: None,
1513        };
1514        let config = InversionConfig { algorithm: InversionAlgorithm::Tfi, ..Default::default() };
1515        assert_eq!(config.input(2), InversionInput::TotalField);
1516        let tfi = crate::inversion::TfiParams { max_iter: 2, cg_max_iter: 5, ..config.tfi.clone() };
1517        let config = InversionConfig { tfi: tfi.clone(), ..config };
1518        // the echo spacing, TE(2) - TE(1), as MEDI's readers set delta_TE
1519        let params = crate::inversion::TfiParams {
1520            b0: 7.0, echo_times: vec![0.006], te: crate::EchoTime::First, ..tfi
1521        };
1522        let echoes: Vec<f64> = (0..3 * n).map(|i| 1.0 + ((i * 5) % 9) as f64).collect();
1523        let via_stage = run_dipole_inversion(&field, &mask, &meta, &config, Some(&echoes), &mut |_, _| {}).unwrap();
1524        let direct = crate::inversion::tfi(
1525            &field, &mask, crate::inversion::EchoMagnitudes { data: &echoes, n_echoes: 3 },
1526            &meta.grid(), meta.b0_direction, &params, |_, _| {},
1527        );
1528        assert_eq!(via_stage, direct);
1529        assert!(via_stage.iter().all(|v| v.is_finite()));
1530        // a combined magnitude of multi-echo data is refused
1531        assert!(run_dipole_inversion(&field, &mask, &meta, &config, Some(&mag), &mut |_, _| {}).is_err());
1532        assert!(run_dipole_inversion(&field, &mask, &meta, &config, Some(&mag[..n - 1]), &mut |_, _| {}).is_err());
1533        assert!(run_dipole_inversion(&field, &mask, &meta, &config, None, &mut |_, _| {}).is_err());
1534    }
1535
1536    #[test]
1537    fn test_inversion_rejects_tgv() {
1538        let n = 64;
1539        let meta = ScanMetadata {
1540            dims: (4, 4, 4),
1541            voxel_size: (1.0, 1.0, 1.0),
1542            echo_times: vec![0.005],
1543            field_strength: 3.0,
1544            b0_direction: (0.0, 0.0, 1.0),
1545            slice_geometry: None,
1546            affine: None,
1547        };
1548        let config = InversionConfig {
1549            algorithm: InversionAlgorithm::Tgv,
1550            ..Default::default()
1551        };
1552
1553        let result = run_dipole_inversion(
1554            &vec![0.0; n], &vec![1u8; n], &meta, &config, None, &mut |_, _| {},
1555        );
1556        assert!(result.is_err());
1557    }
1558}