1use super::config::*;
67use super::phase_utils::{hz_to_ppm, rads_to_ppm};
68use crate::utils::B0WeightType;
69
70pub 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 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 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 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 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 field_mapping_direct(
241 phases, mag_slices, mask, metadata, config, n_voxels, nx, ny, nz, vsx, vsy, vsz, progress,
242 )
243 } else {
244 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#[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 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 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 progress(2, 4);
299 if config.unwrapping_algorithm != UnwrappingAlgorithm::Romeo {
300 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 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 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 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 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 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#[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 progress(1, 4);
488 let is_laplacian = config.unwrapping_algorithm == UnwrappingAlgorithm::Laplacian;
489 let region = LaplacianRegion::new(config, mask);
490 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 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 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 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
587fn 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
612struct 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 fn keep<'a>(&'a self, mask: &'a [u8]) -> &'a [u8] {
633 self.everywhere.as_deref().unwrap_or(mask)
634 }
635}
636
637fn 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
650fn 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
655fn 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 UnwrappingAlgorithm::Romeo | UnwrappingAlgorithm::Prelude | UnwrappingAlgorithm::MediRegionGrowing => {
671 crate::unwrap::correct_multi_echo_wraps(unwrapped, tes, mask);
672 }
673 }
674}
675
676fn 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#[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#[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
738fn 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 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
766pub 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
815fn 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 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#[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 UnwrappingAlgorithm::MediRegionGrowing => {
921 crate::unwrap::unwrap_medi_region_growing(phase, mag, &grid)
922 }
923 }
924}
925
926fn 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
932fn 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 #[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 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 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 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 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 #[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 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 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 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 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 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 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 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 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 #[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 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 let meta = make_test_metadata(1);
1212 let te = meta.echo_times[0]; 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; 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 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 let meta = make_test_metadata(3); let n = 8 * 8 * 8;
1246 let mask = vec![1u8; n];
1247 let slope = 100.0; 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 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 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]) .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 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 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 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 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 #[test]
1357 fn complex_fit_returns_the_raw_fit_and_its_noise() {
1358 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, ¶ms).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 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 let meta = make_test_metadata(3);
1448 let n = 8 * 8 * 8;
1449 let mask = vec![1u8; n];
1450 let slope = 80.0; 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 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 const TES4: [f64; 4] = [0.004, 0.009, 0.014, 0.019];
1987 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 #[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 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 #[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 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, ¶ms);
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 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, ¶ms);
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 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, ¶ms);
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 #[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 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 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 #[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}