1use serde::Serialize;
108
109use super::config::{QsmPipelineConfig, SeparationConfig};
110use crate::bet::{hd_bet_memory_bytes, HdBetInference, HdBetParams};
111use crate::models::Heap;
112use crate::segment::synthseg::{synthseg_network_grid, SynthSegInference, SynthSegParams};
113
114#[derive(Serialize, Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
121#[serde(rename_all = "snake_case")]
122pub enum Strength {
123 Reference,
126 Discouraged,
128 Recommended,
131 Required,
133}
134
135impl Strength {
136 pub const fn kind(self) -> Kind {
139 match self {
140 Strength::Reference => Kind::Reference,
141 Strength::Discouraged | Strength::Recommended | Strength::Required => Kind::Reasoned,
142 }
143 }
144}
145
146#[derive(Serialize, Clone, Copy, Debug, PartialEq, Eq, Hash)]
149#[serde(rename_all = "snake_case")]
150pub enum Kind {
151 Reference,
155 Reasoned,
158}
159
160#[derive(Serialize, Clone, Copy, Debug, PartialEq, Eq, Hash)]
162#[serde(rename_all = "snake_case")]
163pub enum Basis {
164 Stated,
168 Measured,
170 ReferenceChoice,
172 Implementation,
175}
176
177#[derive(Serialize, Clone, Copy, Debug, PartialEq)]
180#[serde(untagged)]
181pub enum Value {
182 Bool(bool),
183 Number(f64),
184 Choice(&'static str),
186 Vector(&'static [f64]),
188}
189
190#[derive(Serialize, Clone, Debug, PartialEq)]
192#[serde(untagged)]
193pub enum Current {
194 Bool(bool),
195 Number(f64),
196 Choice(&'static str),
197 Vector(Vec<f64>),
198}
199
200impl Current {
201 fn number(&self) -> Option<f64> {
202 match self {
203 Current::Number(x) => Some(*x),
204 _ => None,
205 }
206 }
207
208 fn equals(&self, v: &Value) -> bool {
209 match (self, v) {
210 (Current::Bool(a), Value::Bool(b)) => a == b,
211 (Current::Number(a), Value::Number(b)) => a == b,
212 (Current::Choice(a), Value::Choice(b)) => a == b,
213 (Current::Vector(a), Value::Vector(b)) => a.as_slice() == *b,
214 _ => false,
215 }
216 }
217}
218
219#[derive(Serialize, Clone, Copy, Debug, PartialEq)]
222#[serde(tag = "test", rename_all = "snake_case")]
223pub enum Test {
224 OneOf { values: &'static [Value] },
226 NoneOf { values: &'static [Value] },
228 Range { min: Option<f64>, max: Option<f64> },
230}
231
232impl Test {
233 fn passes(&self, v: &Current) -> bool {
234 match self {
235 Test::OneOf { values } => values.iter().any(|x| v.equals(x)),
236 Test::NoneOf { values } => !values.iter().any(|x| v.equals(x)),
237 Test::Range { min, max } => v.number().is_some_and(|x| {
238 min.is_none_or(|m| x >= m) && max.is_none_or(|m| x <= m)
239 }),
240 }
241 }
242}
243
244#[derive(Serialize, Clone, Copy, Debug, PartialEq, Eq)]
246#[serde(rename_all = "snake_case")]
247pub enum Fact {
248 FieldStrength,
250 EchoCount,
252 MinVoxelSize,
254 MaxVoxelSize,
256 Target,
258 HdBetWasmBytes,
261 SynthSegNetworkVoxels,
263 Manufacturer,
266}
267
268#[derive(Serialize, Clone, Copy, Debug, PartialEq, Eq)]
270#[serde(rename_all = "snake_case")]
271pub enum Manufacturer {
272 Siemens,
273 Ge,
274 Philips,
275 Other,
277}
278
279impl Manufacturer {
280 pub fn from_name(name: &str) -> Self {
286 let m = name.trim().to_uppercase();
287 if m.starts_with("SIEMENS") {
288 Manufacturer::Siemens
289 } else if m == "GE" || m.starts_with("GE ") || m.contains("GENERAL ELECTRIC") {
290 Manufacturer::Ge
291 } else if m.starts_with("PHILIPS") {
292 Manufacturer::Philips
293 } else {
294 Manufacturer::Other
295 }
296 }
297
298 pub fn id(self) -> &'static str {
299 match self {
300 Manufacturer::Siemens => "siemens",
301 Manufacturer::Ge => "ge",
302 Manufacturer::Philips => "philips",
303 Manufacturer::Other => "other",
304 }
305 }
306}
307
308impl Fact {
309 pub fn needs_scan(self) -> bool {
311 !matches!(self, Fact::Target)
312 }
313}
314
315#[derive(Serialize, Clone, Copy, Debug, PartialEq)]
317#[serde(tag = "on", rename_all = "snake_case")]
318pub enum Condition {
319 Setting { setting: &'static str, is: Test },
323 Fact { fact: Fact, is: Test },
325}
326
327#[derive(Serialize, Clone, Copy, Debug, PartialEq, Eq)]
329#[serde(rename_all = "snake_case")]
330pub enum Unit {
331 None,
333 Mm,
334 Voxels,
335 Seconds,
336 Bytes,
337}
338
339#[derive(Clone, Copy, Debug, PartialEq)]
343pub struct Rule {
344 pub id: &'static str,
346 pub when: &'static [Condition],
349 pub setting: &'static str,
351 pub expect: Test,
353 pub default: Value,
355 pub unit: Unit,
357 pub strength: Strength,
358 pub basis: Basis,
361 pub reason: &'static str,
363 pub source: &'static str,
366}
367
368impl Rule {
369 pub fn trigger(&self) -> &'static Condition {
371 &self.when[0]
372 }
373
374 pub fn kind(&self) -> Kind {
376 self.strength.kind()
377 }
378
379 pub fn needs_scan(&self) -> bool {
381 self.when.iter().any(|c| matches!(c, Condition::Fact { fact, .. } if fact.needs_scan()))
382 }
383}
384
385impl Serialize for Rule {
386 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
387 use serde::ser::SerializeStruct;
388 let mut m = s.serialize_struct("Rule", 11)?;
389 m.serialize_field("id", self.id)?;
390 m.serialize_field("when", self.when)?;
391 m.serialize_field("setting", self.setting)?;
392 m.serialize_field("expect", &self.expect)?;
393 m.serialize_field("default", &self.default)?;
394 m.serialize_field("unit", &self.unit)?;
395 m.serialize_field("kind", &self.kind())?;
396 m.serialize_field("strength", &self.strength)?;
397 m.serialize_field("basis", &self.basis)?;
398 m.serialize_field("reason", self.reason)?;
399 m.serialize_field("source", self.source)?;
400 m.end()
401 }
402}
403
404#[derive(Serialize, Clone, Debug, PartialEq)]
406pub struct Finding {
407 #[serde(flatten)]
408 pub rule: &'static Rule,
409 pub current: Vec<Current>,
411}
412
413#[derive(Serialize, Clone, Copy, Debug, PartialEq, Eq, Default)]
415#[serde(rename_all = "snake_case")]
416pub enum Target {
417 #[default]
418 Native,
419 Wasm,
420}
421
422impl Target {
423 pub fn current() -> Self {
425 if cfg!(target_arch = "wasm32") { Target::Wasm } else { Target::Native }
426 }
427
428 pub fn id(self) -> &'static str {
429 match self {
430 Target::Native => "native",
431 Target::Wasm => "wasm",
432 }
433 }
434}
435
436pub const WASM32_HEAP_BYTES: u64 = 1 << 32;
439
440#[derive(Clone, Debug, Default, PartialEq)]
443pub struct ScanFacts {
444 pub field_strength: Option<f64>,
445 pub echo_count: Option<usize>,
446 pub voxel_size: Option<(f64, f64, f64)>,
448 pub dims: Option<(usize, usize, usize)>,
450 pub manufacturer: Option<Manufacturer>,
452 pub target: Target,
453}
454
455impl ScanFacts {
456 pub fn from_metadata(m: &super::config::ScanMetadata, target: Target) -> Self {
459 Self {
460 field_strength: Some(m.field_strength),
461 echo_count: Some(m.echo_times.len()),
462 voxel_size: Some(m.voxel_size),
463 dims: Some(m.dims),
464 manufacturer: None,
465 target,
466 }
467 }
468
469 fn grid(&self) -> Option<crate::Grid> {
470 Some(crate::Grid { dims: self.dims?, voxel_size: self.voxel_size? })
471 }
472
473 pub fn fact(&self, fact: Fact, input: &RuleInput) -> Option<Current> {
475 let vs = |f: fn(f64, f64) -> f64| self.voxel_size.map(|(x, y, z)| f(f(x, y), z));
476 match fact {
477 Fact::FieldStrength => self.field_strength.map(Current::Number),
478 Fact::EchoCount => self.echo_count.map(|n| Current::Number(n as f64)),
479 Fact::MinVoxelSize => vs(f64::min).map(Current::Number),
480 Fact::MaxVoxelSize => vs(f64::max).map(Current::Number),
481 Fact::Target => Some(Current::Choice(self.target.id())),
482 Fact::HdBetWasmBytes => {
483 let grid = self.grid()?;
484 hd_bet_steps(input.pipeline)
485 .map(|p| hd_bet_memory_bytes(&grid, p, Heap::Wasm32))
486 .max()
487 .map(|b| Current::Number(b as f64))
488 }
489 Fact::SynthSegNetworkVoxels => {
490 let grid = self.grid()?;
491 let d = synthseg_network_grid(&grid, input.segmentation?);
492 Some(Current::Number(d.iter().map(|&v| v as f64).product()))
493 }
494 Fact::Manufacturer => self.manufacturer.map(|m| Current::Choice(m.id())),
495 }
496 }
497}
498
499#[derive(Clone, Copy, Debug)]
501pub struct RuleInput<'a> {
502 pub pipeline: &'a QsmPipelineConfig,
503 pub separation: Option<&'a SeparationConfig>,
505 pub segmentation: Option<&'a SynthSegParams>,
507}
508
509impl<'a> RuleInput<'a> {
510 pub fn pipeline(pipeline: &'a QsmPipelineConfig) -> Self {
512 Self { pipeline, separation: None, segmentation: None }
513 }
514}
515
516pub const SETTINGS: &[(&str, &str)] = &[
522 ("masking.generator", "Each mask section's generator (MaskOp::id)"),
523 ("masking.hd_bet.inference", "Each HD-BET step's inference: full-volume, sliding-window (HdBetInference::SLIDING_WINDOW), low-memory (HdBetInference::LOW_MEMORY) or custom-window"),
524 ("field_mapping.unwrapping_algorithm", "Phase unwrapping (UnwrappingAlgorithm::id)"),
525 ("field_mapping.b0_estimation", "Echo combination (B0EstimationMethod::id)"),
526 ("field_mapping.b0_estimation.weighting", "Weighted average's per-echo weight (B0WeightType, by `weight_id`); only with weighted-avg"),
527 ("field_mapping.phase_offset_removal", "MCPC-3D-S phase-offset removal before unwrapping (bool)"),
528 ("field_mapping.phase_offset_sigma_mm", "MCPC-3D-S smoothing sigma per axis, mm; only with phase-offset removal"),
529 ("field_mapping.bipolar_correction", "Bipolar readout correction (bool)"),
530 ("field_mapping.laplacian_solver", "Laplacian unwrapping's solver: dct, fft-12 (LaplacianSolver::FFT_DEFAULT_PAD), fft (other padding) or mirror; only with Laplacian unwrapping"),
531 ("field_mapping.laplacian_unmasked", "Laplacian unwrapping on the raw phase over the whole volume, not the phase zeroed outside the mask (bool); only with Laplacian unwrapping"),
532 ("bg_removal.algorithm", "Background field removal (BgRemovalAlgorithm::id)"),
533 ("bg_removal.vsharp.deconvolve", "V-SHARP's TSVD deconvolution (bool); only with V-SHARP"),
534 ("bg_removal.vsharp.max_radius", "V-SHARP's largest kernel radius, mm; only with V-SHARP"),
535 ("bg_removal.vsharp.min_radius", "V-SHARP's smallest kernel radius, mm; only with V-SHARP"),
536 ("bg_removal.vsharp.radius_step", "V-SHARP's step between kernel radii, mm; only with V-SHARP"),
537 ("bg_removal.vsharp.grid", "V-SHARP's working grid: native, isotropic-finest, isotropic-1mm or isotropic (other spacing); only with V-SHARP"),
538 ("bg_removal.ismv.variant", "iSMV's implementation: wen2014 or qsmjl (IsmvVariant::id); only with iSMV"),
539 ("bg_removal.ismv.tol", "iSMV's stopping tolerance (IsmvParams::tol); only with iSMV"),
540 ("bg_removal.ismv.max_iter", "iSMV's iteration cap (IsmvParams::max_iter); no cap reads as +∞ (null in JSON); only with iSMV"),
541 ("bg_removal.resharp.radius", "RESHARP's kernel radius, mm; only with RESHARP"),
542 ("bg_removal.resharp.max_iter", "RESHARP's cgs iteration cap; only with RESHARP"),
543 ("bg_removal.pdf.n_cg", "PDF's CG iteration cap (PdfParams::n_cg); only with PDF"),
544 ("bg_removal.lbv.tol", "LBV's coarsest-grid stopping tolerance, radians over the echo spacing (LbvParams::tol); only with LBV"),
545 ("inversion.algorithm", "Dipole inversion (InversionAlgorithm::id)"),
546 ("inversion.star.pad", "STAR-QSM padding per side, voxels (StarParams::pad)"),
547 ("inversion.ilsqr.max_iter", "iLSQR's LSQR iteration cap (IlsqrParams::max_iter); only with iLSQR"),
548 ("inversion.ilsqr.pad_mm", "iLSQR's padding, mm (IlsqrParams::pad_mm); only with iLSQR"),
549 ("inversion.whqsm.weight", "WH-QSM's data weight: flat, max-normalised or in-vivo (WhQsmWeight); only with WH-QSM"),
550 ("inversion.star.te", "Echo time STAR-QSM converts at (EchoTime: first, last, spacing, sum, seconds); only with STAR"),
551 ("inversion.fansi.te", "Echo time FANSI nlTV/nlTGV convert at (EchoTime); only with fansi or fansi-tgv"),
552 ("inversion.ndi.te", "Echo time NDI converts at (EchoTime); only with NDI"),
553 ("inversion.l1qsm.te", "Echo time L1-QSM converts at (EchoTime); only with L1-QSM"),
554 ("inversion.whqsm.te", "Echo time WH-QSM converts at (EchoTime); only with WH-QSM"),
555 ("inversion.medi.msmv", "MEDI-mSMV: MediParams::msmv is set (bool); only with MEDI"),
556 ("inversion.medi.msmv.prefilter", "MEDI-mSMV's SMV prefilter (MsmvParams::prefilter, bool); only with MEDI-mSMV"),
557 ("inversion.medi.te", "Echo time MEDI converts at (EchoTime); only with MEDI"),
558 ("inversion.tfi.te", "Echo time TFI converts at (EchoTime); only with TFI"),
559 ("inversion.apply_heidi", "HEIDI's cone fill after the inversion (InversionConfig::apply_heidi, bool)"),
560 ("inversion.heidi.cone_threshold", "HEIDI's cone threshold, |D| (HeidiParams::cone_threshold); only with HEIDI"),
561 ("inversion.heidi.te", "Echo time HEIDI converts at (EchoTime); only with HEIDI"),
562 ("inversion.tkd.threshold", "TKD's truncation threshold, |D| (TkdParams::threshold); only with TKD"),
563 ("inversion.tsvd.threshold", "TSVD's truncation threshold, |D| (TkdParams::threshold); only with TSVD"),
564 ("inversion.qsmart.inversion", "QSMART's inner inversion (InversionAlgorithm::id); only with QSMART"),
565 ("inversion.qsmart.ilsqr_max_iter", "QSMART's iLSQR iteration count; only with QSMART"),
566 ("inversion.qsmart.ilsqr_pad_mm", "QSMART's iLSQR padding, mm; only with QSMART"),
567 ("reference", "QSM referencing (QsmReference: mean or none)"),
568 ("qsm_per_echo", "A QSM per echo, and their mean, instead of one QSM from the echo-combined field (QsmPipelineConfig::qsm_per_echo, bool)"),
569 ("tukey_window", "Taper ratio of the χ-separation toolbox's k-space Tukey window on the complex echoes, 0 = none (QsmPipelineConfig::tukey_window)"),
570 ("separation.algorithm", "χ-separation (SeparationAlgorithm::id)"),
571 ("separation.chi_sepnet.route", "χ-sepnet's input route: toolbox or native (ChiSepNetRoute::id); only with χ-sepnet"),
572 ("separation.decompose.unwrap_phase", "DECOMPOSE's phase unwrapping along the echoes (DecomposeParams::unwrap_phase, bool); only with DECOMPOSE"),
573 ("separation.decompose.chi_bound", "DECOMPOSE's bound on |χ| in the fit (DecomposeChiBound::id): ppm (a fixed bound, the authors' 0.5) or echo-spacing (π / ((2/3)·γ·B0·ΔTE_max), computed from the scan); only with DECOMPOSE"),
574 ("segmentation.inference", "SynthSeg inference: full-volume, low-memory (SynthSegInference::LOW_MEMORY) or sliding-window"),
575 ("segmentation.inference.patch", "SynthSeg window edge, voxels at 1 mm (SlidingWindow::patch)"),
576];
577
578pub fn echo_time_id(t: crate::EchoTime) -> &'static str {
581 match t {
582 crate::EchoTime::First => "first",
583 crate::EchoTime::Last => "last",
584 crate::EchoTime::Spacing => "spacing",
585 crate::EchoTime::Sum => "sum",
586 crate::EchoTime::Seconds(_) => "seconds",
587 }
588}
589
590pub fn weight_id(w: &crate::utils::multi_echo::B0WeightType) -> &'static str {
592 use crate::utils::multi_echo::B0WeightType as W;
593 match w {
594 W::PhaseSNR => "phase-snr",
595 W::PhaseVar => "phase-var",
596 W::Average => "average",
597 W::TEs => "tes",
598 W::Mag => "mag",
599 W::AssumedDecay { .. } => "assumed-decay",
600 }
601}
602
603fn hd_bet_steps(c: &QsmPipelineConfig) -> impl Iterator<Item = &HdBetParams> {
605 c.masking.sections.iter().flat_map(|s| std::iter::once(&s.generator).chain(&s.refinements)).filter_map(
606 |op| match op {
607 super::config::MaskOp::HdBet(p) => Some(p),
608 _ => None,
609 },
610 )
611}
612
613pub fn current(input: &RuleInput, facts: &ScanFacts, setting: &str) -> Option<Vec<Current>> {
627 let c = input.pipeline;
628 let inv_input = c.inversion.input(if c.qsm_per_echo { 1 } else { facts.echo_count.unwrap_or(1) });
629 let combines = inv_input.needs_field_mapping() && !c.qsm_per_echo;
631 let one = |v: Current| vec![v];
632 let gated = |on: bool, v: Current| if on { vec![v] } else { vec![] };
633 let bg = |a: super::config::BgRemovalAlgorithm| inv_input.needs_bg_removal() && c.bg_removal.algorithm == a;
634 let vsharp = bg(super::config::BgRemovalAlgorithm::Vsharp);
635 let resharp = bg(super::config::BgRemovalAlgorithm::Resharp);
636 let ismv = bg(super::config::BgRemovalAlgorithm::Ismv);
637 let pdf = bg(super::config::BgRemovalAlgorithm::Pdf);
638 let lbv = bg(super::config::BgRemovalAlgorithm::Lbv);
639 use super::config::InversionAlgorithm as A;
640 let alg = |a: &[A]| a.contains(&c.inversion.algorithm);
641 let qsmart = alg(&[A::Qsmart]);
642 Some(match setting {
643 "masking.generator" => {
644 c.masking.sections.iter().map(|s| Current::Choice(s.generator.id())).collect()
645 }
646 "masking.hd_bet.inference" => hd_bet_steps(c)
647 .map(|p| {
648 Current::Choice(match p.inference {
649 HdBetInference::FullVolume => "full-volume",
650 i if i == HdBetInference::SLIDING_WINDOW => "sliding-window",
651 i if i == HdBetInference::LOW_MEMORY => "low-memory",
652 HdBetInference::SlidingWindow { .. } => "custom-window",
653 })
654 })
655 .collect(),
656 "field_mapping.unwrapping_algorithm" => gated(
657 inv_input.needs_field_mapping(),
658 Current::Choice(c.field_mapping.unwrapping_algorithm.id()),
659 ),
660 "field_mapping.b0_estimation" => gated(combines, Current::Choice(c.field_mapping.b0_estimation.id())),
661 "field_mapping.b0_estimation.weighting" => match &c.field_mapping.b0_estimation {
662 super::config::B0EstimationMethod::WeightedAvg { weighting } if combines => {
663 one(Current::Choice(weight_id(weighting)))
664 }
665 _ => vec![],
666 },
667 "field_mapping.phase_offset_removal" => gated(combines, Current::Bool(c.field_mapping.phase_offset_removal)),
668 "field_mapping.bipolar_correction" => gated(combines, Current::Bool(c.field_mapping.bipolar_correction)),
669 "qsm_per_echo" => one(Current::Bool(c.qsm_per_echo)),
670 "tukey_window" => one(Current::Number(c.tukey_window)),
671 "reference" => one(Current::Choice(match c.reference {
672 super::config::QsmReference::Mean => "mean",
673 super::config::QsmReference::None => "none",
674 })),
675 "field_mapping.laplacian_solver" => {
676 use crate::unwrap::LaplacianSolver as L;
677 gated(
678 inv_input.needs_field_mapping()
679 && c.field_mapping.unwrapping_algorithm == super::config::UnwrappingAlgorithm::Laplacian,
680 Current::Choice(match c.field_mapping.laplacian_solver {
681 L::Dct => "dct",
682 s if s == L::FFT_DEFAULT_PAD => "fft-12",
683 L::Fft { .. } => "fft",
684 L::Mirror => "mirror",
685 }),
686 )
687 }
688 "field_mapping.laplacian_unmasked" => gated(
689 inv_input.needs_field_mapping()
690 && c.field_mapping.unwrapping_algorithm == super::config::UnwrappingAlgorithm::Laplacian,
691 Current::Bool(c.field_mapping.laplacian_unmasked),
692 ),
693 "bg_removal.vsharp.deconvolve" => gated(vsharp, Current::Bool(c.bg_removal.vsharp.deconvolve)),
694 "bg_removal.vsharp.max_radius" => gated(vsharp, Current::Number(c.bg_removal.vsharp.max_radius)),
695 "bg_removal.vsharp.min_radius" => gated(vsharp, Current::Number(c.bg_removal.vsharp.min_radius)),
696 "bg_removal.vsharp.radius_step" => gated(vsharp, Current::Number(c.bg_removal.vsharp.radius_step)),
697 "field_mapping.phase_offset_sigma_mm" => gated(
698 combines && c.field_mapping.phase_offset_removal,
699 Current::Vector(c.field_mapping.phase_offset_sigma_mm.to_vec()),
700 ),
701 "inversion.ilsqr.max_iter" => gated(alg(&[A::Ilsqr]), Current::Number(c.inversion.ilsqr.max_iter as f64)),
702 "inversion.ilsqr.pad_mm" => gated(alg(&[A::Ilsqr]), Current::Number(c.inversion.ilsqr.pad_mm)),
703 "bg_removal.vsharp.grid" => gated(
704 vsharp,
705 Current::Choice(match c.bg_removal.vsharp.grid {
706 crate::bgremove::VsharpGrid::Native => "native",
707 crate::bgremove::VsharpGrid::IsotropicFinest => "isotropic-finest",
708 crate::bgremove::VsharpGrid::Isotropic { spacing_mm: 1.0 } => "isotropic-1mm",
709 crate::bgremove::VsharpGrid::Isotropic { .. } => "isotropic",
710 }),
711 ),
712 "bg_removal.ismv.variant" => gated(ismv, Current::Choice(c.bg_removal.ismv.variant.id())),
713 "bg_removal.ismv.tol" => gated(ismv, Current::Number(c.bg_removal.ismv.tol)),
714 "bg_removal.ismv.max_iter" => gated(
715 ismv,
716 Current::Number(c.bg_removal.ismv.max_iter.map_or(f64::INFINITY, |n| n as f64)),
717 ),
718 "bg_removal.resharp.radius" => gated(resharp, Current::Number(c.bg_removal.resharp.radius)),
719 "bg_removal.resharp.max_iter" => gated(resharp, Current::Number(c.bg_removal.resharp.max_iter as f64)),
720 "bg_removal.pdf.n_cg" => gated(pdf, Current::Number(c.bg_removal.pdf.n_cg as f64)),
721 "bg_removal.lbv.tol" => gated(lbv, Current::Number(c.bg_removal.lbv.tol)),
722 "inversion.whqsm.weight" => gated(
723 alg(&[A::Whqsm]),
724 Current::Choice(match c.inversion.whqsm.weight {
725 crate::inversion::WhQsmWeight::Flat => "flat",
726 crate::inversion::WhQsmWeight::MaxNormalised => "max-normalised",
727 crate::inversion::WhQsmWeight::InVivo => "in-vivo",
728 }),
729 ),
730 "inversion.star.te" => gated(alg(&[A::Star]), Current::Choice(echo_time_id(c.inversion.star.te))),
731 "inversion.fansi.te" => {
732 gated(alg(&[A::Fansi, A::FansiTgv]), Current::Choice(echo_time_id(c.inversion.fansi.te)))
733 }
734 "inversion.ndi.te" => gated(alg(&[A::Ndi]), Current::Choice(echo_time_id(c.inversion.ndi.te))),
735 "inversion.l1qsm.te" => gated(alg(&[A::L1qsm]), Current::Choice(echo_time_id(c.inversion.l1qsm.te))),
736 "inversion.whqsm.te" => gated(alg(&[A::Whqsm]), Current::Choice(echo_time_id(c.inversion.whqsm.te))),
737 "inversion.medi.msmv" => gated(alg(&[A::Medi]), Current::Bool(c.inversion.medi.msmv.is_some())),
738 "inversion.medi.msmv.prefilter" => match &c.inversion.medi.msmv {
739 Some(m) if alg(&[A::Medi]) => one(Current::Bool(m.prefilter)),
740 _ => vec![],
741 },
742 "inversion.medi.te" => gated(alg(&[A::Medi]), Current::Choice(echo_time_id(c.inversion.medi.te))),
743 "inversion.tfi.te" => gated(alg(&[A::Tfi]), Current::Choice(echo_time_id(c.inversion.tfi.te))),
744 "inversion.apply_heidi" => one(Current::Bool(c.inversion.apply_heidi)),
745 "inversion.heidi.cone_threshold" => gated(c.inversion.apply_heidi, Current::Number(c.inversion.heidi.cone_threshold)),
746 "inversion.heidi.te" => gated(c.inversion.apply_heidi, Current::Choice(echo_time_id(c.inversion.heidi.te))),
747 "inversion.tkd.threshold" => gated(alg(&[A::Tkd]), Current::Number(c.inversion.tkd.threshold)),
748 "inversion.tsvd.threshold" => gated(alg(&[A::Tsvd]), Current::Number(c.inversion.tsvd.threshold)),
749 "inversion.qsmart.inversion" => gated(qsmart, Current::Choice(c.inversion.qsmart.inversion.id())),
750 "inversion.qsmart.ilsqr_max_iter" => {
751 gated(qsmart, Current::Number(c.inversion.qsmart.ilsqr_max_iter as f64))
752 }
753 "inversion.qsmart.ilsqr_pad_mm" => gated(qsmart, Current::Number(c.inversion.qsmart.ilsqr_pad_mm)),
754 "bg_removal.algorithm" => gated(
755 inv_input.needs_bg_removal(),
756 Current::Choice(c.bg_removal.algorithm.id()),
757 ),
758 "inversion.algorithm" => one(Current::Choice(c.inversion.algorithm.id())),
759 "inversion.star.pad" => gated(
760 c.inversion.algorithm == super::config::InversionAlgorithm::Star,
761 Current::Vector(c.inversion.star.pad.iter().map(|&p| p as f64).collect()),
762 ),
763 "separation.algorithm" => {
764 input.separation.map(|s| Current::Choice(s.algorithm.id())).into_iter().collect()
765 }
766 "separation.chi_sepnet.route" => input
767 .separation
768 .filter(|s| s.algorithm == super::config::SeparationAlgorithm::ChiSepNet)
769 .map(|s| Current::Choice(s.chi_sepnet.route.id()))
770 .into_iter()
771 .collect(),
772 "separation.decompose.unwrap_phase" => input
773 .separation
774 .filter(|s| s.algorithm == super::config::SeparationAlgorithm::Decompose)
775 .map(|s| Current::Bool(s.decompose.unwrap_phase))
776 .into_iter()
777 .collect(),
778 "separation.decompose.chi_bound" => input
779 .separation
780 .filter(|s| s.algorithm == super::config::SeparationAlgorithm::Decompose)
781 .map(|s| Current::Choice(s.decompose.chi_bound.id()))
782 .into_iter()
783 .collect(),
784 "segmentation.inference" => input
785 .segmentation
786 .map(|s| {
787 Current::Choice(match s.inference {
788 SynthSegInference::FullVolume => "full-volume",
789 i if i == SynthSegInference::LOW_MEMORY => "low-memory",
790 SynthSegInference::SlidingWindow { .. } => "sliding-window",
791 })
792 })
793 .into_iter()
794 .collect(),
795 "segmentation.inference.patch" => input
796 .segmentation
797 .and_then(|s| match s.inference {
798 SynthSegInference::SlidingWindow { patch, .. } => Some(Current::Number(patch as f64)),
799 SynthSegInference::FullVolume => None,
800 })
801 .into_iter()
802 .collect(),
803 _ => return None,
804 })
805}
806
807fn holds(cond: &Condition, input: &RuleInput, facts: &ScanFacts) -> bool {
812 match cond {
813 Condition::Setting { setting, is } => {
814 current(input, facts, setting).unwrap_or_default().iter().any(|v| is.passes(v))
815 }
816 Condition::Fact { fact, is } => facts.fact(*fact, input).is_some_and(|v| is.passes(&v)),
817 }
818}
819
820pub fn check(rule: &'static Rule, input: &RuleInput, facts: &ScanFacts) -> Option<Finding> {
822 if !rule.when.iter().all(|c| holds(c, input, facts)) {
823 return None;
824 }
825 let current = current(input, facts, rule.setting).unwrap_or_default();
826 if current.is_empty() || current.iter().all(|v| rule.expect.passes(v)) {
827 return None;
828 }
829 Some(Finding { rule, current })
830}
831
832pub fn evaluate(input: &RuleInput, facts: &ScanFacts) -> Vec<Finding> {
834 RULES.iter().filter_map(|r| check(r, input, facts)).collect()
835}
836
837
838use Condition as C;
843use Value::{Bool, Choice, Number};
844
845const fn method(setting: &'static str, values: &'static [Value]) -> Condition {
846 C::Setting { setting, is: Test::OneOf { values } }
847}
848const fn inv(values: &'static [Value]) -> Condition {
849 method("inversion.algorithm", values)
850}
851const fn bg(values: &'static [Value]) -> Condition {
852 method("bg_removal.algorithm", values)
853}
854const fn sep(values: &'static [Value]) -> Condition {
855 method("separation.algorithm", values)
856}
857const fn fact_at_least(fact: Fact, min: f64) -> Condition {
858 C::Fact { fact, is: Test::Range { min: Some(min), max: None } }
859}
860const fn fact_at_most(fact: Fact, max: f64) -> Condition {
861 C::Fact { fact, is: Test::Range { min: None, max: Some(max) } }
862}
863const fn manufacturer(values: &'static [Value]) -> Condition {
864 C::Fact { fact: Fact::Manufacturer, is: Test::OneOf { values } }
865}
866const ON_WASM: Condition = C::Fact { fact: Fact::Target, is: Test::OneOf { values: &[Choice("wasm")] } };
867const MULTI_ECHO: Condition = fact_at_least(Fact::EchoCount, 2.0);
868const DECOMPOSE_UNWRAP_FROM_T: f64 = 6.5;
871
872const fn one_of(
875 id: &'static str,
876 when: &'static [Condition],
877 setting: &'static str,
878 values: &'static [Value],
879 strength: Strength,
880 basis: Basis,
881 reason: &'static str,
882 source: &'static str,
883) -> Rule {
884 Rule {
885 id,
886 when,
887 setting,
888 expect: Test::OneOf { values },
889 default: values[0],
890 unit: Unit::None,
891 strength,
892 basis,
893 reason,
894 source,
895 }
896}
897
898const fn recommend(
901 id: &'static str,
902 when: &'static [Condition],
903 setting: &'static str,
904 values: &'static [Value],
905 reason: &'static str,
906 source: &'static str,
907) -> Rule {
908 one_of(id, when, setting, values, Strength::Recommended, Basis::Stated, reason, source)
909}
910
911const fn measured_better(
913 id: &'static str,
914 when: &'static [Condition],
915 setting: &'static str,
916 values: &'static [Value],
917 reason: &'static str,
918 source: &'static str,
919) -> Rule {
920 one_of(id, when, setting, values, Strength::Recommended, Basis::Measured, reason, source)
921}
922
923const fn reference(
926 id: &'static str,
927 when: &'static [Condition],
928 setting: &'static str,
929 values: &'static [Value],
930 reason: &'static str,
931 source: &'static str,
932) -> Rule {
933 one_of(id, when, setting, values, Strength::Reference, Basis::ReferenceChoice, reason, source)
934}
935
936const fn reference_stated(
939 id: &'static str,
940 when: &'static [Condition],
941 setting: &'static str,
942 values: &'static [Value],
943 reason: &'static str,
944 source: &'static str,
945) -> Rule {
946 one_of(id, when, setting, values, Strength::Reference, Basis::Stated, reason, source)
947}
948
949const fn discourage(
951 id: &'static str,
952 when: &'static [Condition],
953 setting: &'static str,
954 avoid: &'static [Value],
955 default: Value,
956 basis: Basis,
957 reason: &'static str,
958 source: &'static str,
959) -> Rule {
960 Rule {
961 id,
962 when,
963 setting,
964 expect: Test::NoneOf { values: avoid },
965 default,
966 unit: Unit::None,
967 strength: Strength::Discouraged,
968 basis,
969 reason,
970 source,
971 }
972}
973
974const BET: &[Value] = &[Choice("bet")];
975const PDF_SUGGESTED_N_CG: f64 = 300.0;
977const PDF: &[Value] = &[Choice("pdf")];
978const LBV: &[Value] = &[Choice("lbv")];
979const VSHARP: &[Value] = &[Choice("vsharp")];
980const LAPLACIAN: &[Value] = &[Choice("laplacian")];
981const ROMEO: &[Value] = &[Choice("romeo")];
982const COMPLEX_FIT: &[Value] = &[Choice("complex-fit")];
983const WEIGHTED_AVG: &[Value] = &[Choice("weighted-avg")];
984const CHI_SEP_TOOLBOX: &[Value] = &[Choice("chi-sep-ilsqr"), Choice("chi-sep-medi"), Choice("chi-sepnet")];
985const CHI_SEPNET_TOOLBOX_ROUTE: Condition = method("separation.chi_sepnet.route", &[Choice("toolbox")]);
987pub const CHISEP_TUKEY_SIEMENS_GE: f64 = 0.4;
989const TUKEY_SOURCE: &str = "χ-separation toolbox v1.1.3 Chisep_script.m:69-73 \
990 (\"tukey_fac = 0.4; % Recommendation: Siemens, GE: 0.4, Philips: 0\"); QSM.rs utils::tukey_window docs";
991const TUKEY_SIEMENS_GE_REASON: &str = "The χ-separation toolbox Tukey-windows the complex echoes in \
992 k-space before everything else, with a taper ratio of 0.4 for Siemens and GE data";
993const TUKEY_PHILIPS_REASON: &str = "The χ-separation toolbox uses no Tukey windowing (a taper ratio of 0) \
994 for Philips data";
995const CHI_SEP_TOOLBOX_ITERATIVE: &[Value] = &[Choice("chi-sep-ilsqr"), Choice("chi-sep-medi")];
998const QSMNETME: &[Value] = &[Choice("qsmnetme")];
1000const STI: &[Value] = &[Choice("star"), Choice("ilsqr")];
1001const HEIDI_ON: Condition = method("inversion.apply_heidi", &[Bool(true)]);
1003const HEIDI_NO_LOCAL_FIELD: &[Value] = &[
1005 Choice("tfi"), Choice("tgv"), Choice("qsmart"), Choice("autoqsm"), Choice("nextqsm"),
1006 Choice("iqsm"), Choice("iqsm-plus"),
1007];
1008const HEIDI_UNREGULARISED_SEEDS: &[Value] =
1011 &[Choice("tkd"), Choice("tsvd"), Choice("tikhonov"), Choice("ndi")];
1012const HEIDI_REGULARISED_SEEDS: &[Value] = &[
1014 Choice("medi"), Choice("tv"), Choice("nltv"), Choice("fansi"), Choice("fansi-tgv"),
1015 Choice("l1qsm"), Choice("whqsm"), Choice("hdqsm"), Choice("amp-pe"), Choice("ilsqr"),
1016 Choice("star"), Choice("xqsm"), Choice("qsmnet"), Choice("qsmnet-plus"), Choice("qsmnetme"),
1017 Choice("qsmgan"), Choice("ir2qsm"), Choice("lpcnn"), Choice("modl-qsm"),
1018];
1019const HEIDI_CONE_SOURCE: &str = "qsm_heidi computesusceptibility_heidi.m L922-923 (4592631); QSM.rs heidi.rs docs (Any seed)";
1021
1022const HEIDI_DEFAULT_CONE: Condition = C::Setting {
1025 setting: "inversion.heidi.cone_threshold",
1026 is: Test::Range { min: Some(0.1), max: Some(0.1) },
1027};
1028
1029const fn heidi_truncation(id: &'static str, when: &'static [Condition], setting: &'static str) -> Rule {
1031 Rule {
1032 id,
1033 when,
1034 setting,
1035 expect: Test::Range { min: None, max: Some(0.1) },
1036 default: Number(0.1),
1037 unit: Unit::None,
1038 strength: Strength::Required,
1039 basis: Basis::Stated,
1040 reason: "Coefficients the seed truncated (|D| below its threshold) would be kept as data \
1041 where they lie outside HEIDI's cone; upstream stops with an error",
1042 source: HEIDI_CONE_SOURCE,
1043 }
1044}
1045
1046const FANSI_FAMILY: &[Value] =
1047 &[Choice("ndi"), Choice("fansi"), Choice("fansi-tgv"), Choice("l1qsm"), Choice("whqsm")];
1048
1049pub static RULES: &[Rule] = &[
1053 reference("medi.mask-bet", &[inv(&[Choice("medi")])], "masking.generator", BET,
1055 "MEDI's pipeline masks with FSL BET (bet2, f 0.5) on the root-sum-of-squares magnitude",
1056 "MEDI toolbox 2024.11.26 README.m:80,118; functions/bet2/bet2.cpp:136"),
1057 reference("tfi.mask-bet", &[inv(&[Choice("tfi")])], "masking.generator", BET,
1058 "TFI's in-vivo ROI is a BET brain mask",
1059 "Liu et al. 2017 MRM 79:2950 (TFI), Methods"),
1060 reference("pdf.mask-bet", &[bg(PDF)], "masking.generator", BET,
1061 "MEDI's PDF runs inside the BET mask",
1062 "MEDI toolbox 2024.11.26 README.m:118,124"),
1063 reference("amp-pe.mask-bet", &[inv(&[Choice("amp-pe")])], "masking.generator", BET,
1064 "AMP-PE's pipeline masks with BET (0.5) on the RSS magnitude, then erodes 2 mm",
1065 "QSM_AMP_PE qsm_multi_echo_combined.m L71-81"),
1066 reference_stated("qsmnet.mask-bet", &[inv(&[Choice("qsmnet"), Choice("qsmnet-plus")])], "masking.generator", BET,
1067 "QSMnet's README instructs BET (FSL) brain extraction before its input is made",
1068 "SNU-LIST/QSMnet README.md:64-68 (Process flow)"),
1069 reference("qsmgan.mask-bet", &[inv(&[Choice("qsmgan")])], "masking.generator", BET,
1070 "QSMGAN's training inputs were masked with FSL BET",
1071 "Chen et al. 2020 NeuroImage 207:116389 (QSMGAN), Methods"),
1072 reference("lpcnn.mask-bet", &[inv(&[Choice("lpcnn")])], "masking.generator", BET,
1073 "LPCNN's data were masked with FSL BET",
1074 "Lai et al. MICCAI 2020 (LPCNN, arXiv:2008.05024), Dataset Acquisition and Preprocessing"),
1075 reference("nextqsm.mask-bet", &[inv(&[Choice("nextqsm")])], "masking.generator", BET,
1076 "NeXtQSM's in-vivo pipeline masks with FSL BET",
1077 "Cognolato et al. 2023 Med. Image Anal. 84:102700 (NeXtQSM), §3.2"),
1078 reference("iqsm.mask-bet", &[inv(&[Choice("iqsm"), Choice("iqsm-plus")])], "masking.generator", BET,
1079 "iQSM's default mask is bet2 -f 0.5 on the first-echo magnitude",
1080 "sunhongfu/iQSM run.py:499-506, bet2_utils.py:47,64"),
1081 reference("bfrnet.mask-bet", &[bg(&[Choice("bfrnet")])], "masking.generator", BET,
1082 "BFRnet's in-vivo masks are bet2 -f 0.2 -w 2, eroded 3 voxels",
1083 "Zhu et al. 2023 Z. Med. Phys. (BFRnet), Methods"),
1084 reference("chi-sep.mask-bet", &[sep(CHI_SEP_TOOLBOX)], "masking.generator", BET,
1085 "The χ-separation toolbox and χ-sepnet mask with BET",
1086 "χ-separation toolbox Chisep_script.m:79-82; Kim et al. 2025 (χ-sepnet, arXiv:2409.14030) §2.1.1"),
1087 reference("qsmnetme.mask-bet", &[inv(QSMNETME)], "masking.generator", BET,
1088 "QSMnetME runs in the χ-separation toolbox, which masks with BET",
1089 "χ-separation toolbox Chisep_script.m:79-82; Kim et al. 2025 (χ-sepnet) §2.1.1"),
1090 reference("qsmart.mask-bet", &[inv(&[Choice("qsmart")])], "masking.generator", BET,
1091 "QSMART's authors mask with FSL bet2 -f 0.2 on the N4-corrected first-echo magnitude",
1092 "Yaghmaie et al. 2021 NeuroImage 231:117701 (QSMART) §3.1; QSMART brainmask.m l. 7-33"),
1093 reference("decompose.mask-bet", &[sep(&[Choice("decompose")])], "masking.generator", BET,
1094 "DECOMPOSE's pipeline masks with FSL BET",
1095 "Chen et al. 2021 NeuroImage 242:118477 (DECOMPOSE), §3.6"),
1096 Rule {
1097 id: "mindgrab.wasm-heap",
1098 when: &[method("masking.generator", &[Choice("mindgrab")]), ON_WASM],
1099 setting: "masking.generator",
1100 expect: Test::NoneOf { values: &[Choice("mindgrab")] },
1101 default: Choice("bet"),
1102 unit: Unit::None,
1103 strength: Strength::Required,
1104 basis: Basis::Measured,
1105 reason: "MindGrab always runs a whole 256³ volume and cannot be tiled; its WASM peak \
1106 (about 5.6 GB) exceeds a 4 GiB heap",
1107 source: "QSM.rs models registry (mindgrab memory figures) and bet::mindgrab docs",
1108 },
1109 Rule {
1110 id: "hd-bet.wasm-heap",
1111 when: &[
1112 method("masking.generator", &[Choice("hd-bet")]),
1113 ON_WASM,
1114 fact_at_least(Fact::HdBetWasmBytes, (WASM32_HEAP_BYTES + 1) as f64),
1115 ],
1116 setting: "masking.hd_bet.inference",
1117 expect: Test::OneOf { values: &[Choice("low-memory")] },
1118 default: Choice("low-memory"),
1119 unit: Unit::None,
1120 strength: Strength::Required,
1121 basis: Basis::Measured,
1122 reason: "The configured HD-BET step needs more than a 4 GiB WASM heap on this scan; \
1123 (128, 128, 64) sliding windows peak at about 1.6 GB and agree with the trained \
1124 patch at Dice ≈0.99",
1125 source: "QSM.rs bet::hdbet, HdBetParams::low_memory and hd_bet_memory_bytes (#280)",
1126 },
1127 Rule {
1129 id: "synthseg.wasm-windows",
1130 when: &[method("segmentation.inference", &[Choice("full-volume")]), ON_WASM],
1131 setting: "segmentation.inference",
1132 expect: Test::NoneOf { values: &[Choice("full-volume")] },
1133 default: Choice("low-memory"),
1134 unit: Unit::None,
1135 strength: Strength::Required,
1136 basis: Basis::Measured,
1137 reason: "SynthSeg's whole-volume pass needs 6–12 GB on 1 mm head scans; no whole-volume \
1138 setting fits a 4 GB WASM heap",
1139 source: "QSM.rs segment::synthseg module docs and SynthSegInference::LOW_MEMORY (#281)",
1140 },
1141 Rule {
1142 id: "synthseg.wasm-128-window",
1143 when: &[
1144 C::Setting {
1145 setting: "segmentation.inference.patch",
1146 is: Test::Range { min: Some(128.0), max: None },
1147 },
1148 ON_WASM,
1149 fact_at_least(Fact::SynthSegNetworkVoxels, (192 * 256 * 128 + 1) as f64),
1150 ],
1151 setting: "segmentation.inference.patch",
1152 expect: Test::Range { min: None, max: Some(96.0) },
1153 default: Number(96.0),
1154 unit: Unit::Voxels,
1155 strength: Strength::Required,
1156 basis: Basis::Measured,
1157 reason: "128³ SynthSeg windows took a 192×256×128 grid to 3.83 GB of the 4 GB WASM heap; \
1158 a larger grid does not fit, 96³ windows do",
1159 source: "QSM.rs SynthSegInference::LOW_MEMORY docs (#281)",
1160 },
1161 discourage("fansi.no-laplacian-multi-echo", &[inv(FANSI_FAMILY), MULTI_ECHO],
1163 "field_mapping.unwrapping_algorithm", LAPLACIAN, Choice("romeo"), Basis::Stated,
1164 "FANSI's authors advise against Laplacian unwrapping for multi-echo data: it removes \
1165 harmonic components of the phase",
1166 "FANSI-toolbox script_DICOM_Philips.m L196-197 (93044a3)"),
1167 reference_stated("qsmnet.unwrap-laplacian", &[inv(&[Choice("qsmnet"), Choice("qsmnet-plus")])],
1168 "field_mapping.unwrapping_algorithm", LAPLACIAN,
1169 "QSMnet's README instructs Laplacian phase unwrapping (STI Suite)",
1170 "SNU-LIST/QSMnet README.md:64-68 (Process flow)"),
1171 reference("autoqsm.unwrap-laplacian", &[inv(&[Choice("autoqsm")])],
1172 "field_mapping.unwrapping_algorithm", LAPLACIAN,
1173 "AutoQSM was trained on Laplacian-unwrapped total phase",
1174 "Wei et al. 2019 NeuroImage 201:116064 (AutoQSM), Methods"),
1175 reference("qsmgan.unwrap-laplacian", &[inv(&[Choice("qsmgan")])],
1176 "field_mapping.unwrapping_algorithm", LAPLACIAN,
1177 "QSMGAN's training inputs were Laplacian-unwrapped",
1178 "Chen et al. 2020 NeuroImage 207:116389 (QSMGAN), Methods"),
1179 reference("decompose.unwrap-laplacian", &[sep(&[Choice("decompose")])],
1180 "field_mapping.unwrapping_algorithm", LAPLACIAN,
1181 "DECOMPOSE's pipeline uses Laplacian unwrapping",
1182 "Chen et al. 2021 NeuroImage 242:118477 (DECOMPOSE), §3.6"),
1183 reference("chi-sep.unwrap-romeo", &[sep(CHI_SEP_TOOLBOX)],
1184 "field_mapping.unwrapping_algorithm", ROMEO,
1185 "The χ-separation toolbox and χ-sepnet unwrap each echo with ROMEO",
1186 "χ-separation toolbox Chisep_script.m:99-111; Kim et al. 2025 (χ-sepnet) §2.1.1"),
1187 reference("qsmnetme.unwrap-romeo", &[inv(QSMNETME)],
1188 "field_mapping.unwrapping_algorithm", ROMEO,
1189 "QSMnetME's toolbox unwraps each echo with ROMEO",
1190 "χ-separation toolbox Chisep_script.m:99-111; Kim et al. 2025 (χ-sepnet) §2.1.1"),
1191 reference("sti.unwrap-laplacian", &[inv(STI)], "field_mapping.unwrapping_algorithm", LAPLACIAN,
1192 "STI Suite's QSM_GUI unwraps each echo with MRPhaseUnwrap (Laplacian)",
1193 "STI Suite 3.0 QSM_GUI, probed headless (recorded in QSM.rs laplacian.rs docs)"),
1194 reference("sti.laplacian-solver",
1195 &[inv(STI), method("field_mapping.unwrapping_algorithm", LAPLACIAN)],
1196 "field_mapping.laplacian_solver", &[Choice("fft-12")],
1197 "MRPhaseUnwrap is the FFT Schofield-Zhu solver with 12 voxels of padding",
1198 "STI Suite 3.0 MRPhaseUnwrap, probed (recorded in QSM.rs LaplacianSolver::Fft docs)"),
1199 reference("sti.unwrap-unmasked",
1200 &[inv(STI), method("field_mapping.unwrapping_algorithm", LAPLACIAN)],
1201 "field_mapping.laplacian_unmasked", &[Bool(true)],
1202 "STI Suite's QSM_GUI gives MRPhaseUnwrap the raw wrapped phase, not the phase masked first",
1203 "STI Suite 3.0 QSM_GUI, probed headless (recorded in QSM.rs laplacian.rs docs)"),
1204 reference("qsmart.unwrap-laplacian", &[inv(&[Choice("qsmart")])],
1205 "field_mapping.unwrapping_algorithm", LAPLACIAN,
1206 "QSMART unwraps each echo with Laplacian unwrapping (lapunwrap.m)",
1207 "QSMART Demo_QSMART.m l. 32; unwrap_phase.m l. 5-10"),
1208 reference("qsmart.laplacian-solver",
1209 &[inv(&[Choice("qsmart")]), method("field_mapping.unwrapping_algorithm", LAPLACIAN)],
1210 "field_mapping.laplacian_solver", &[Choice("mirror")],
1211 "QSMART's lapunwrap.m solves on the mirrored volume",
1212 "QSMART unwrap_phase.m l. 5-10, lapunwrap.m"),
1213 reference("qsmart.unwrap-unmasked",
1214 &[inv(&[Choice("qsmart")]), method("field_mapping.unwrapping_algorithm", LAPLACIAN)],
1215 "field_mapping.laplacian_unmasked", &[Bool(true)],
1216 "QSMART unwraps each echo's raw phase over the whole volume, and its echo fit fits every voxel",
1217 "QSMART unwrap_phase.m l. 5-10; echofit.m"),
1218 reference("qsmart.field-echofit", &[inv(&[Choice("qsmart")])],
1220 "field_mapping.b0_estimation", &[Choice("qsmart-echofit")],
1221 "QSMART's total field and reliability map R_0 come from echofit.m",
1222 "QSMART QSMART.m l. 26; echofit.m"),
1223 reference("sti.echo-combination", &[inv(STI), MULTI_ECHO],
1224 "field_mapping.b0_estimation", WEIGHTED_AVG,
1225 "STI Suite's QSM_GUI sums the unwrapped echoes and divides by the summed echo times",
1226 "STI Suite 3.0 QSM_GUI, probed headless (recorded in QSM.rs star.rs docs)"),
1227 reference("sti.echo-weights", &[inv(STI), MULTI_ECHO],
1228 "field_mapping.b0_estimation.weighting", &[Choice("tes")],
1229 "Σφ/ΣTE is the TE-weighted mean of φ/TE",
1230 "STI Suite 3.0 QSM_GUI, probed headless (recorded in QSM.rs star.rs docs)"),
1231 recommend("field-mapping.phase-offsets",
1232 &[method("field_mapping.b0_estimation", WEIGHTED_AVG),
1233 method("field_mapping.unwrapping_algorithm", ROMEO), MULTI_ECHO],
1234 "field_mapping.phase_offset_removal", &[Bool(true)],
1235 "The B0 calculation's authors state that phase offsets have to be removed before it; \
1236 ROMEO removes them (MCPC-3D-S) whenever it computes B0 from multi-echo data",
1237 "MriResearchTools.jl romeofunctions.jl:3-7; ROMEO.jl caller.jl:80-91"),
1238 Rule {
1239 id: "field-mapping.phase-offset-sigma",
1240 when: &[method("field_mapping.phase_offset_removal", &[Bool(true)])],
1241 setting: "field_mapping.phase_offset_sigma_mm",
1242 expect: Test::OneOf { values: &[Value::Vector(&super::config::ROMEO_CLI_PHASE_OFFSET_SIGMA_MM)] },
1243 default: Value::Vector(&super::config::ROMEO_CLI_PHASE_OFFSET_SIGMA_MM),
1244 unit: Unit::Mm,
1245 strength: Strength::Reference,
1246 basis: Basis::ReferenceChoice,
1247 reason: "The ROMEO command line smooths the MCPC-3D-S phase offsets with σ = 7 mm per axis",
1248 source: "ROMEO.jl 08776d4 argparse.jl l. 79-84, 217-224; caller.jl l. 137-138 (#293)",
1249 },
1250 discourage("field-mapping.bipolar-needs-three-echoes",
1251 &[method("field_mapping.bipolar_correction", &[Bool(true)]), fact_at_most(Fact::EchoCount, 2.0)],
1252 "field_mapping.bipolar_correction", &[Bool(true)], Bool(false), Basis::Stated,
1253 "Bipolar correction needs echoes 1–3; with fewer it is skipped",
1254 "ROMEO.jl argparse.jl:71-76; MriResearchTools.jl mcpc3ds.jl:110-119"),
1255 discourage("field-mapping.bipolar-not-with-laplacian",
1256 &[method("field_mapping.bipolar_correction", &[Bool(true)]),
1257 method("field_mapping.unwrapping_algorithm", LAPLACIAN)],
1258 "field_mapping.bipolar_correction", &[Bool(true)], Bool(false), Basis::Implementation,
1259 "Bipolar correction is not applied on the Laplacian path",
1260 "QSM.rs pipeline::field_mapping docs"),
1261 reference("complex-fit.unwrap-region-growing", &[method("field_mapping.b0_estimation", COMPLEX_FIT)],
1265 "field_mapping.unwrapping_algorithm", &[Choice("medi-region-growing")],
1266 "MEDI's pipeline unwraps Fit_ppm_complex's field with its region-growing unwrapPhase \
1267 (mexUnwrap.c), on the root-sum-of-squares magnitude",
1268 "MEDI toolbox 2024.11.26 README.m:80-95; functions/unwrapPhase.m, mexUnwrap.c (QSM.rs \
1269 unwrap::medi_region_growing docs)"),
1270 reference("medi.field-complex-fit", &[inv(&[Choice("medi")])],
1271 "field_mapping.b0_estimation", COMPLEX_FIT,
1272 "MEDI's field map and its noise weight N_std come from Fit_ppm_complex",
1273 "MEDI toolbox 2024.11.26 README.m:87,140; dataterm_mask.m:23-27"),
1274 reference("pdf.field-complex-fit", &[bg(PDF)],
1275 "field_mapping.b0_estimation", COMPLEX_FIT,
1276 "PDF is weighted by Fit_ppm_complex's noise map N_std",
1277 "MEDI toolbox 2024.11.26 README.m:87,124; PDF.m:74-76"),
1278 reference("r2star-qsm.field-complex-fit", &[sep(&[Choice("r2star-qsm")])],
1279 "field_mapping.b0_estimation", COMPLEX_FIT,
1280 "R2*-QSM's field was a nonlinear complex fit",
1281 "Dimov et al. 2022 Tomography 8:1544, Methods"),
1282 reference("amp-pe.echo-combination", &[inv(&[Choice("amp-pe")]), MULTI_ECHO],
1283 "field_mapping.b0_estimation", WEIGHTED_AVG,
1284 "AMP-PE's pipeline averages φ/TE over the echoes",
1285 "QSM_AMP_PE qsm_multi_echo_combined.m L100-104"),
1286 reference("amp-pe.echo-weights", &[inv(&[Choice("amp-pe")]), MULTI_ECHO],
1287 "field_mapping.b0_estimation.weighting", &[Choice("average")],
1288 "AMP-PE's average of φ/TE is unweighted",
1289 "QSM_AMP_PE qsm_multi_echo_combined.m L100-104"),
1290 reference("qsmnet.echo-combination", &[inv(&[Choice("qsmnet"), Choice("qsmnet-plus")]), MULTI_ECHO],
1291 "field_mapping.b0_estimation", WEIGHTED_AVG,
1292 "QSMnet's input is the summed phase over the summed echo times",
1293 "SNU-LIST/QSMnet save_input_data_for_QSMnet.m:22"),
1294 reference("qsmnet.echo-weights", &[inv(&[Choice("qsmnet"), Choice("qsmnet-plus")]), MULTI_ECHO],
1295 "field_mapping.b0_estimation.weighting", &[Choice("tes")],
1296 "Σφ/ΣTE is the TE-weighted mean of φ/TE",
1297 "SNU-LIST/QSMnet save_input_data_for_QSMnet.m:22"),
1298 reference("autoqsm.echo-combination", &[inv(&[Choice("autoqsm")]), MULTI_ECHO],
1299 "field_mapping.b0_estimation", WEIGHTED_AVG,
1300 "AutoQSM's input is ψ = Σφ/(γB0ΣTE)",
1301 "Wei et al. 2019 NeuroImage 201:116064 (AutoQSM), Methods"),
1302 reference("autoqsm.echo-weights", &[inv(&[Choice("autoqsm")]), MULTI_ECHO],
1303 "field_mapping.b0_estimation.weighting", &[Choice("tes")],
1304 "Σφ/ΣTE is the TE-weighted mean of φ/TE",
1305 "Wei et al. 2019 NeuroImage 201:116064 (AutoQSM), Methods"),
1306 reference("chi-sep.echo-combination", &[sep(CHI_SEP_TOOLBOX), MULTI_ECHO],
1307 "field_mapping.b0_estimation", WEIGHTED_AVG,
1308 "The χ-separation toolbox combines the unwrapped echoes by a weighted average",
1309 "χ-separation toolbox Chisep_script.m:113-123; Kim et al. 2025 (χ-sepnet) §2.1.1"),
1310 reference("chi-sep.echo-weights", &[sep(CHI_SEP_TOOLBOX), MULTI_ECHO],
1311 "field_mapping.b0_estimation.weighting", &[Choice("assumed-decay")],
1312 "The toolbox weights the echoes by TE·exp(−TE/T2*) with T2* = 40 ms (AssumedDecay, 0.04 s)",
1313 "χ-separation toolbox Chisep_script.m:113-123"),
1314 reference("qsmnetme.echo-combination", &[inv(QSMNETME), MULTI_ECHO],
1315 "field_mapping.b0_estimation", WEIGHTED_AVG,
1316 "QSMnetME's toolbox combines the unwrapped echoes by a weighted average",
1317 "χ-separation toolbox Chisep_script.m:113-123; Kim et al. 2025 (χ-sepnet) §2.1.1"),
1318 reference("qsmnetme.echo-weights", &[inv(QSMNETME), MULTI_ECHO],
1319 "field_mapping.b0_estimation.weighting", &[Choice("assumed-decay")],
1320 "QSMnetME's toolbox weights the echoes by TE·exp(−TE/T2*) with T2* = 40 ms (AssumedDecay, 0.04 s)",
1321 "χ-separation toolbox Chisep_script.m:113-123"),
1322 reference("hdqsm.bg-pdf", &[inv(&[Choice("hdqsm")])], "bg_removal.algorithm", PDF,
1324 "HD-QSM's in-vivo pipeline removes the background with PDF",
1325 "Lambert et al. 2022 MRM 88:1600 (HD-QSM), §3.3"),
1326 reference("amp-pe.bg-pdf", &[inv(&[Choice("amp-pe")])], "bg_removal.algorithm", PDF,
1327 "AMP-PE's pipeline removes the background with PDF",
1328 "QSM_AMP_PE README (Overview, step 4); qsm_multi_echo_combined.m L112-139"),
1329 reference("lsqr.bg-sharp", &[inv(&[Choice("lsqr")])], "bg_removal.algorithm",
1330 &[Choice("sharp")],
1331 "The authors' LSQR/HEIDI pipeline inverts a SHARP local field",
1332 "qsm_heidi makefile L14-20, def.sh (4592631)"),
1333 reference("rts.bg-vsharp", &[inv(&[Choice("rts")])], "bg_removal.algorithm", VSHARP,
1334 "RTS's author's toolbox (QSM.jl) runs V-SHARP before it",
1335 "QSM.jl README.md:49 (fde4b92)"),
1336 reference("xqsm.bg-resharp", &[inv(&[Choice("xqsm")])], "bg_removal.algorithm", &[Choice("resharp")],
1337 "xQSM's in-vivo pipeline removes the background with RESHARP",
1338 "Gao et al. 2021 NMR Biomed 34:e4461 (xQSM), in-vivo experiments"),
1339 reference_stated("qsmnet.bg-vsharp", &[inv(&[Choice("qsmnet"), Choice("qsmnet-plus")])], "bg_removal.algorithm",
1340 VSHARP, "QSMnet's README instructs 3D V-SHARP (STI Suite) background removal",
1341 "SNU-LIST/QSMnet README.md:64-68 (Process flow), :75"),
1342 reference("qsmgan.bg-vsharp", &[inv(&[Choice("qsmgan")])], "bg_removal.algorithm", VSHARP,
1343 "QSMGAN was trained on V-SHARP local fields",
1344 "Chen et al. 2020 NeuroImage 207:116389 (QSMGAN), Methods; checkpoint config input_algo"),
1345 reference("lpcnn.bg-vsharp", &[inv(&[Choice("lpcnn")])], "bg_removal.algorithm", VSHARP,
1346 "LPCNN's local fields were from V-SHARP",
1347 "Lai et al. MICCAI 2020 (LPCNN), Dataset Acquisition and Preprocessing"),
1348 reference("chi-sep.bg-vsharp", &[sep(CHI_SEP_TOOLBOX)], "bg_removal.algorithm", VSHARP,
1349 "The χ-separation toolbox and χ-sepnet remove the background with V-SHARP",
1350 "χ-separation toolbox Chisep_script.m:136-139; Kim et al. 2025 (χ-sepnet) §2.1.1"),
1351 reference("qsmnetme.bg-vsharp", &[inv(QSMNETME)], "bg_removal.algorithm", VSHARP,
1352 "QSMnetME's toolbox removes the background with V-SHARP",
1353 "χ-separation toolbox Chisep_script.m:136-139; Kim et al. 2025 (χ-sepnet) §2.1.1"),
1354 reference("r2star-qsm.bg-pdf", &[sep(&[Choice("r2star-qsm")])], "bg_removal.algorithm", PDF,
1355 "R2*-QSM's local field was from PDF",
1356 "Dimov et al. 2022 Tomography 8:1544, Methods"),
1357 reference("decompose.bg-vsharp", &[sep(&[Choice("decompose")])], "bg_removal.algorithm", VSHARP,
1358 "DECOMPOSE's pipeline removes the background with V-SHARP",
1359 "Chen et al. 2021 NeuroImage 242:118477 (DECOMPOSE), §3.6"),
1360 reference("sti.bg-vsharp", &[inv(STI)], "bg_removal.algorithm", VSHARP,
1361 "STI Suite's QSM_GUI feeds iLSQR and STAR-QSM the V-SHARP local field",
1362 "STI Suite 3.0 QSM_GUI, probed headless (recorded in QSM.rs star.rs and ilsqr.rs docs)"),
1363 reference("sti.vsharp-no-deconvolve", &[inv(STI), bg(VSHARP)], "bg_removal.vsharp.deconvolve",
1364 &[Bool(false)],
1365 "STI Suite's V_SHARP does not deconvolve",
1366 "STI Suite 3.0 V_SHARP, probed (recorded in QSM.rs vsharp.rs docs)"),
1367 Rule {
1368 id: "sti.vsharp-radius",
1369 when: &[inv(STI), bg(VSHARP)],
1370 setting: "bg_removal.vsharp.max_radius",
1371 expect: Test::OneOf { values: &[Number(12.0)] },
1372 default: Number(12.0),
1373 unit: Unit::Mm,
1374 strength: Strength::Reference,
1375 basis: Basis::ReferenceChoice,
1376 reason: "STI Suite's QSM_GUI runs V_SHARP with smvsize 12 mm (also V_SHARP's default)",
1377 source: "STI Suite 3.0 QSM_GUI and V_SHARP, probed (recorded in QSM.rs vsharp.rs docs)",
1378 },
1379 reference("sti.vsharp-grid", &[inv(STI), bg(VSHARP)], "bg_removal.vsharp.grid",
1380 &[Choice("isotropic-1mm")],
1381 "STI Suite's V_SHARP estimates the background on a 1 mm grid",
1382 "STI Suite 3.0 V_SHARP (recorded in QSM.rs vsharp.rs docs)"),
1383 Rule {
1384 id: "sti.vsharp-min-radius",
1385 when: &[inv(STI), bg(VSHARP)],
1386 setting: "bg_removal.vsharp.min_radius",
1387 expect: Test::OneOf { values: &[Number(crate::bgremove::vsharp::STI_MIN_RADIUS_MM)] },
1388 default: Number(crate::bgremove::vsharp::STI_MIN_RADIUS_MM),
1389 unit: Unit::Mm,
1390 strength: Strength::Reference,
1391 basis: Basis::ReferenceChoice,
1392 reason: "STI Suite's V_SHARP drops the voxels only its 1 mm ball fits, so its radii in \
1393 effect run down to 2 mm",
1394 source: "STI Suite 3.0 V_SHARP, probed (QSM.rs VsharpParams, #293)",
1395 },
1396 Rule {
1397 id: "sti.vsharp-radius-step",
1398 when: &[inv(STI), bg(VSHARP)],
1399 setting: "bg_removal.vsharp.radius_step",
1400 expect: Test::OneOf { values: &[Number(crate::bgremove::vsharp::STI_RADIUS_STEP_MM)] },
1401 default: Number(crate::bgremove::vsharp::STI_RADIUS_STEP_MM),
1402 unit: Unit::Mm,
1403 strength: Strength::Reference,
1404 basis: Basis::ReferenceChoice,
1405 reason: "STI Suite's V_SHARP steps its kernel radius by 1 mm",
1406 source: "STI Suite 3.0 V_SHARP, probed (QSM.rs VsharpParams, #293)",
1407 },
1408 Rule {
1409 id: "sti.ilsqr-iterations",
1410 when: &[inv(&[Choice("ilsqr")])],
1411 setting: "inversion.ilsqr.max_iter",
1412 expect: Test::OneOf { values: &[Number(crate::inversion::STI_DEFAULT_NITER as f64)] },
1413 default: Number(crate::inversion::STI_DEFAULT_NITER as f64),
1414 unit: Unit::None,
1415 strength: Strength::Reference,
1416 basis: Basis::ReferenceChoice,
1417 reason: "QSM_iLSQR runs 100 iterations when called without niter, as STI Suite's STI_Templates.m calls it",
1418 source: "STI Suite 3.0 QSM_iLSQR default, probed (bitwise equal to niter 100); STI_Templates.m l. 115; UK Biobank pipeline (QSM.rs IlsqrParams::max_iter)",
1419 },
1420 Rule {
1421 id: "sti.ilsqr-pad",
1422 when: &[inv(&[Choice("ilsqr")])],
1423 setting: "inversion.ilsqr.pad_mm",
1424 expect: Test::OneOf { values: &[Number(crate::inversion::STI_GUI_PAD_MM)] },
1425 default: Number(crate::inversion::STI_GUI_PAD_MM),
1426 unit: Unit::Mm,
1427 strength: Strength::Reference,
1428 basis: Basis::ReferenceChoice,
1429 reason: "STI Suite's QSM_GUI calls QSM_iLSQR with padsize [12 12 12]",
1430 source: "STI Suite 3.0 QSM_GUI, probed headless (QSM.rs IlsqrParams::pad_mm, #293)",
1431 },
1432 reference("resharp.radius", &[bg(&[Choice("resharp")])], "bg_removal.resharp.radius",
1433 &[Number(3.0)],
1434 "RESHARP's author's code defaults to a 3 mm kernel",
1435 "sunhongfu/QSM background_field_removal/resharp.m l. 24-26"),
1436 Rule {
1437 id: "resharp.iterations",
1438 when: &[bg(&[Choice("resharp")])],
1439 setting: "bg_removal.resharp.max_iter",
1440 expect: Test::Range { min: Some(200.0), max: None },
1441 default: Number(200.0),
1442 unit: Unit::None,
1443 strength: Strength::Reference,
1444 basis: Basis::ReferenceChoice,
1445 reason: "RESHARP's author's code allows its cgs solve up to 200 iterations",
1446 source: "sunhongfu/QSM background_field_removal/resharp.m l. 32-34, 92",
1447 },
1448 Rule {
1449 id: "pdf.cg-iterations",
1450 when: &[bg(PDF)],
1451 setting: "bg_removal.pdf.n_cg",
1452 expect: Test::Range { min: Some(PDF_SUGGESTED_N_CG), max: None },
1453 default: Number(PDF_SUGGESTED_N_CG),
1454 unit: Unit::None,
1455 strength: Strength::Recommended,
1456 basis: Basis::Measured,
1457 reason: "A tuning value: PDF.m's 30 CG iterations can stop far from convergence. At 300, \
1458 a Philips 3 T scan's local field correlated 0.83 with V-SHARP's, against 0.54 \
1459 at 30 (a GE scan: 0.84 against 0.85), and on QSM-CI's simulated head NRMSE was \
1460 44 %, against about 100 % for MEDI's PDF.m at 30; neither scan converged \
1461 within 300. Costs about 30 s more per 1 mm whole-brain scan",
1462 source: "QSM.rs PdfParams::n_cg docs (travelling-head scans); QSMxT/QSM-CI#322 (pdf-cornell \
1463 vs pdf-qsmrs on the simulated head); PDF.m default n_CG 30, MEDI toolbox \
1464 2024.11.26",
1465 },
1466 reference("lbv.tolerance", &[bg(LBV)], "bg_removal.lbv.tol", &[Number(0.01)],
1469 "LBV.m stops its coarsest grid's relaxation when the summed absolute update falls to 0.01 \
1470 (radians over the echo spacing)",
1471 "MEDI toolbox 2024.11.26 functions/_LBV/LBV.m:29-31; QSM.rs LbvParams::tol"),
1472 reference("ismv-qsmjl.tolerance", &[method("bg_removal.ismv.variant", &[Choice("qsmjl")])],
1473 "bg_removal.ismv.tol", &[Number(1e-3)],
1474 "QSM.jl's ismv stops when the residual falls to 1e-3 of the first",
1475 "QSM.jl src/bgremove/ismv.jl:39 (fde4b92); QSM.rs IsmvParams::qsmjl"),
1476 reference("ismv-qsmjl.max-iterations", &[method("bg_removal.ismv.variant", &[Choice("qsmjl")])],
1477 "bg_removal.ismv.max_iter", &[Number(500.0)],
1478 "QSM.jl's ismv stops after at most 500 iterations",
1479 "QSM.jl src/bgremove/ismv.jl:40 (fde4b92); QSM.rs IsmvParams::qsmjl"),
1480 Rule {
1482 id: "star.padding",
1483 when: &[inv(&[Choice("star")])],
1484 setting: "inversion.star.pad",
1485 expect: Test::OneOf { values: &[Value::Vector(&[8.0, 8.0, 20.0])] },
1486 default: Value::Vector(&[8.0, 8.0, 20.0]),
1487 unit: Unit::Voxels,
1488 strength: Strength::Reference,
1489 basis: Basis::ReferenceChoice,
1490 reason: "STI Suite 3.0's QSM_GUI passes padsize [8 8 20] to QSM_star on every grid",
1491 source: "STI Suite 3.0 QSM_GUI (probed headless, recorded in QSM.rs StarParams::pad, #283)",
1492 },
1493 reference("qsmart.inner-inversion", &[inv(&[Choice("qsmart")])], "inversion.qsmart.inversion",
1494 &[Choice("ilsqr")],
1495 "QSMART's two stages invert with STI Suite's iLSQR",
1496 "Yaghmaie et al. 2021 §3.4; QSMART QSMART.m l. 32-39"),
1497 Rule {
1498 id: "qsmart.ilsqr-iterations",
1499 when: &[inv(&[Choice("qsmart")])],
1500 setting: "inversion.qsmart.ilsqr_max_iter",
1501 expect: Test::OneOf { values: &[Number(50.0)] },
1502 default: Number(50.0),
1503 unit: Unit::None,
1504 strength: Strength::Reference,
1505 basis: Basis::ReferenceChoice,
1506 reason: "QSMART calls QSM_iLSQR with 'niter', 50",
1507 source: "QSMART QSMART.m l. 32-33; Yaghmaie et al. 2021 §3.4",
1508 },
1509 Rule {
1510 id: "qsmart.ilsqr-pad",
1511 when: &[inv(&[Choice("qsmart")])],
1512 setting: "inversion.qsmart.ilsqr_pad_mm",
1513 expect: Test::OneOf { values: &[Number(crate::inversion::STI_DEFAULT_PAD_MM)] },
1514 default: Number(crate::inversion::STI_DEFAULT_PAD_MM),
1515 unit: Unit::Mm,
1516 strength: Strength::Reference,
1517 basis: Basis::ReferenceChoice,
1518 reason: "QSMART calls QSM_iLSQR without 'padsize', so STI Suite's default padding applies",
1519 source: "QSMART QSMART.m l. 32; STI Suite QSM_iLSQR default, probed (QSM.rs ilsqr.rs docs)",
1520 },
1521 reference("medi.bg-pdf",
1523 &[inv(&[Choice("medi")]), method("inversion.medi.msmv", &[Bool(false)])],
1524 "bg_removal.algorithm", PDF,
1525 "MEDI's pipeline inverts the PDF local field (also InversionConfig::reference_bg_removal)",
1526 "MEDI toolbox 2024.11.26 README.m:124,144"),
1527 Rule {
1529 id: "medi-msmv.three-echoes",
1530 when: &[inv(&[Choice("medi")]), method("inversion.medi.msmv", &[Bool(true)]),
1531 fact_at_most(Fact::EchoCount, 2.0)],
1532 setting: "inversion.medi.msmv",
1533 expect: Test::OneOf { values: &[Bool(false)] },
1534 default: Bool(false),
1535 unit: Unit::None,
1536 strength: Strength::Required,
1537 basis: Basis::Implementation,
1538 reason: "MEDI-mSMV's vessel mask needs R2* from arlo.m, which needs at least three echoes; \
1539 the pipeline refuses fewer",
1540 source: "MEDI toolbox arlo.m:22-26; QSM.rs MediParams::msmv docs (#285)",
1541 },
1542 one_of("medi-msmv.bg-removal",
1543 &[inv(&[Choice("medi")]), method("inversion.medi.msmv", &[Bool(true)])],
1544 "bg_removal.algorithm", &[Choice("pdf"), Choice("vsharp"), Choice("lbv")],
1545 Strength::Reference, Basis::ReferenceChoice,
1546 "The mSMV authors run it on a local field from PDF, V-SHARP or LBV (their README's \
1547 example uses PDF; it lists these as examples, \"by PDF, VSHARP, LBV, etc.\", without \
1548 recommending one)",
1549 "agr78/mSMV README (in_file; MEDI example: PDF(...,0,100)); Roberts et al. 2024 MRM, Methods"),
1550 discourage("medi-msmv.prefilter",
1551 &[inv(&[Choice("medi")]), method("inversion.medi.msmv", &[Bool(true)])],
1552 "inversion.medi.msmv.prefilter", &[Bool(false)], Bool(true), Basis::Measured,
1553 "With the prefilter off, MEDI fits the unfiltered field with the SMV-filtered kernel: \
1554 NRMSE 280-740 % on the phantom after PDF, V-SHARP or LBV, and 481 % in the authors' MATLAB",
1555 "QSM.rs inversion::msmv docs and MsmvParams::prefilter (#285)"),
1556 measured_better("fansi.te-spacing", &[inv(&[Choice("fansi"), Choice("fansi-tgv")]), MULTI_ECHO],
1559 "inversion.fansi.te", &[Choice("spacing")],
1560 "FANSI's multi-echo script converts at a TE difference; at the echo spacing nlTV and nlTGV \
1561 did better than at the first echo on every metric on the phantom",
1562 "FANSI-toolbox script_DICOM_Philips.m L364, L370; QSM.rs FansiParams::te docs (#276, #418)"),
1563 measured_better("ndi.te-first", &[inv(&[Choice("ndi")]), MULTI_ECHO],
1564 "inversion.ndi.te", &[Choice("first")],
1565 "FANSI's script allows any single echo time; the first echo gave NDI its best NRMSE and \
1566 correlation on the phantom",
1567 "FANSI-toolbox script_DICOM_Philips.m L364; QSM.rs NdiParams::te docs (#276, #418)"),
1568 measured_better("l1qsm.te-first", &[inv(&[Choice("l1qsm")]), MULTI_ECHO],
1569 "inversion.l1qsm.te", &[Choice("first")],
1570 "FANSI's script allows any single echo time; the first echo did better than the spacing \
1571 on the phantom (XSIM 0.776 vs 0.764, NRMSE 38.9 vs 46.9 %)",
1572 "FANSI-toolbox script_DICOM_Philips.m L364; QSM.rs L1QsmParams::te docs (#276, #418)"),
1573 discourage("whqsm.flat-weight-te-spacing",
1574 &[inv(&[Choice("whqsm")]), method("inversion.whqsm.weight", &[Choice("flat")]), MULTI_ECHO],
1575 "inversion.whqsm.te", &[Choice("spacing")], Choice("first"), Basis::Measured,
1576 "With the flat weight, WH-QSM did worse at the echo spacing than at the first echo on the \
1577 simulated head (NRMSE 42.0 vs 33.6 %, r 0.914 vs 0.945)",
1578 "QSM.rs WhQsmParams::te docs (#276)"),
1579 measured_better("whqsm.magnitude-weight-te-spacing",
1580 &[inv(&[Choice("whqsm")]),
1581 method("inversion.whqsm.weight", &[Choice("max-normalised"), Choice("in-vivo")]), MULTI_ECHO],
1582 "inversion.whqsm.te", &[Choice("spacing")],
1583 "With a magnitude weight, the echo spacing did better than the first echo on the phantom \
1584 (XSIM 0.676 vs 0.638); FANSI's script converts at a TE difference with a magnitude-derived weight",
1585 "QSM.rs WhQsmParams::te docs (#276, #418); FANSI-toolbox script_DICOM_Philips.m L364, L436"),
1586 reference("star.te-sum", &[inv(&[Choice("star")]), MULTI_ECHO], "inversion.star.te", &[Choice("sum")],
1587 "STI Suite's QSM_GUI gives QSM_star the summed phase with TE = ΣTE",
1588 "STI Suite 3.0 QSM_GUI, probed (recorded in QSM.rs StarParams::te, #276)"),
1589 reference("medi.te-spacing", &[inv(&[Choice("medi")]), MULTI_ECHO], "inversion.medi.te", &[Choice("spacing")],
1590 "MEDI_L1 converts with delta_TE = TE(2) − TE(1)",
1591 "MEDI toolbox 2024.11.26 MEDI_L1.m:202; Read_DICOM.m:166-168"),
1592 reference("tfi.te-spacing", &[inv(&[Choice("tfi")]), MULTI_ECHO], "inversion.tfi.te", &[Choice("spacing")],
1593 "TFI runs through MEDI_L1, which converts with delta_TE",
1594 "MEDI toolbox 2024.11.26 MEDI_L1.m:202 (QSM.rs TfiParams::te, #276)"),
1595 reference("heidi.te-last", &[HEIDI_ON, MULTI_ECHO], "inversion.heidi.te", &[Choice("last")],
1596 "The authors' HEIDI pipeline converts at the last echo time",
1597 "qsm_heidi phase2susceptibilityfactor.m L70-71 (QSM.rs HeidiParams::te, #276)"),
1598 Rule {
1600 id: "heidi.local-field",
1601 when: &[HEIDI_ON],
1602 setting: "inversion.algorithm",
1603 expect: Test::NoneOf { values: HEIDI_NO_LOCAL_FIELD },
1604 default: Choice("lsqr"),
1605 unit: Unit::None,
1606 strength: Strength::Required,
1607 basis: Basis::Implementation,
1608 reason: "HEIDI's homogeneity weights and cone fill take the local field the seed was \
1609 inverted from; this method has none",
1610 source: "QSM.rs heidi.rs docs (Any seed); run_heidi_cone_fill",
1611 },
1612 Rule {
1617 id: "heidi.seed-lsqr",
1618 when: &[HEIDI_ON],
1619 setting: "inversion.algorithm",
1620 expect: Test::NoneOf { values: HEIDI_UNREGULARISED_SEEDS },
1621 default: Choice("lsqr"),
1622 unit: Unit::None,
1623 strength: Strength::Recommended,
1624 basis: Basis::Stated,
1625 reason: "HEIDI as published keeps the well-conditioned k-space of a minimally regularised \
1626 LSQR solution and fills only the cone",
1627 source: "Schweser et al. 2012 NeuroImage 62:2083 (HEIDI); qsm_heidi makefile, inversion_lsqr.m then inversion_heidi.m (4592631)",
1628 },
1629 Rule {
1630 id: "heidi.seed-rts",
1631 when: &[HEIDI_ON],
1632 setting: "inversion.algorithm",
1633 expect: Test::NoneOf { values: &[Choice("rts")] },
1634 default: Choice("lsqr"),
1635 unit: Unit::None,
1636 strength: Strength::Recommended,
1637 basis: Basis::Stated,
1638 reason: "HEIDI's reference seed is LSQR; RTS already splits k-space at the cone and fills it \
1639 with TV of its own",
1640 source: "Schweser et al. 2012 NeuroImage 62:2083 (HEIDI); Kames et al. 2018 NeuroImage 167:276 (RTS)",
1641 },
1642 discourage("heidi.seed-regularised", &[HEIDI_ON], "inversion.algorithm",
1643 HEIDI_REGULARISED_SEEDS, Choice("lsqr"), Basis::Stated,
1644 "HEIDI replaces only the dipole cone, so this method's regularisation outside the cone is \
1645 kept; HEIDI takes that region from a minimally regularised, near data-consistent solution",
1646 "Schweser et al. 2012 NeuroImage 62:2083 (HEIDI); QSM.rs heidi.rs docs (Any seed)"),
1647 heidi_truncation("heidi.tkd-cone", &[HEIDI_ON, inv(&[Choice("tkd")]), HEIDI_DEFAULT_CONE],
1648 "inversion.tkd.threshold"),
1649 heidi_truncation("heidi.tsvd-cone", &[HEIDI_ON, inv(&[Choice("tsvd")]), HEIDI_DEFAULT_CONE],
1650 "inversion.tsvd.threshold"),
1651 reference("chi-sep-ilsqr.qsm-ilsqr", &[sep(&[Choice("chi-sep-ilsqr")])], "inversion.algorithm",
1653 &[Choice("ilsqr")],
1654 "The χ-separation toolbox feeds it STI Suite iLSQR QSM",
1655 "χ-separation toolbox v1.1.3 Chisep_script.m:145,170"),
1656 reference("chi-sep-medi.qsm-ilsqr", &[sep(&[Choice("chi-sep-medi")])], "inversion.algorithm",
1657 &[Choice("ilsqr")],
1658 "The χ-separation toolbox initialises χ-separation-MEDI from STI Suite iLSQR QSM",
1659 "χ-separation toolbox v1.1.3 Chisep_script.m:145,158"),
1660 reference("r2star-qsm.qsm-medi", &[sep(&[Choice("r2star-qsm")])], "inversion.algorithm",
1661 &[Choice("medi")],
1662 "R2*-QSM's χ was reconstructed with MEDI",
1663 "Dimov et al. 2022 Tomography 8:1544, Methods"),
1664 reference("decompose.qsm-star", &[sep(&[Choice("decompose")])], "inversion.algorithm",
1665 &[Choice("star")],
1666 "DECOMPOSE's χ of each echo was reconstructed with STAR-QSM",
1667 "Chen et al. 2021 NeuroImage 242:118477 (DECOMPOSE), §3.6"),
1668 recommend("decompose.qsm-per-echo", &[sep(&[Choice("decompose")])], "qsm_per_echo", &[Bool(true)],
1669 "DECOMPOSE's authors reconstruct a QSM for each echo, χ(t), rather than one QSM from all \
1670 echoes combined, and state that a TE-dependent QSM input is necessary",
1671 "Chen et al. 2021 NeuroImage 242:118477 (DECOMPOSE), §2.2, §4.1; STAR-QSM per echo §3.6"),
1672 measured_better("decompose.unwrap-7t",
1673 &[sep(&[Choice("decompose")]), fact_at_least(Fact::FieldStrength, DECOMPOSE_UNWRAP_FROM_T)],
1674 "separation.decompose.unwrap_phase", &[Bool(true)],
1675 "DECOMPOSE was designed for 3 T (its χ bound keeps the phase under 2π at TE 25 ms and \
1676 3 T); at 7 T the phase wraps between echoes and χ+ partly collapses unless the phase is \
1677 unwrapped along the echoes (with decompose.chi-bound-7t's bound)",
1678 "Chen et al. 2021 NeuroImage 242:118477 (DECOMPOSE), §2.2; measured on the QSM-CI 7 T \
1679 chisep phantom, χ+ r 0.48 off, 0.66 on, 0.90 on with the echo-spacing χ bound (QSM.rs \
1680 separation::decompose docs, At 7 T)"),
1681 measured_better("decompose.chi-bound-7t",
1682 &[sep(&[Choice("decompose")]), fact_at_least(Fact::FieldStrength, DECOMPOSE_UNWRAP_FROM_T)],
1683 "separation.decompose.chi_bound", &[Choice("echo-spacing")],
1684 "A tuning value: bound the χ search at π / ((2/3)·γ·B0·ΔTE_max), computed from the scan \
1685 (0.31 ppm at 7 T with 8 ms spacing), the largest |χ| whose phase step between echoes \
1686 stays under π so that unwrapping along the echoes is unambiguous; the authors' 0.5 ppm \
1687 was set for TE 25 ms at 3 T",
1688 "Chen et al. 2021 NeuroImage 242:118477 (DECOMPOSE), §2.2; measured on the QSM-CI 7 T \
1689 chisep phantom, χ− r 0.75 → 0.85 with this bound alone, χ+ r 0.66 → 0.90 with \
1690 unwrapping (QSM.rs separation::decompose docs, At 7 T)"),
1691 reference("decompose.reference-mean", &[sep(&[Choice("decompose")])], "reference",
1692 &[Choice("mean")],
1693 "DECOMPOSE's χ is referenced to the whole-brain mean",
1694 "Chen et al. 2021 NeuroImage 242:118477 (DECOMPOSE), §3.6"),
1695 reference("susep-net.qsm-iqsm-plus", &[sep(&[Choice("susep-net")])], "inversion.algorithm",
1696 &[Choice("iqsm-plus")],
1697 "SUSEP-Net's in-vivo QSM input is from iQSM+",
1698 "SUSEP-Net (arXiv:2506.13293) §3.2"),
1699 discourage("chi-sepnet.min-resolution",
1700 &[sep(&[Choice("chi-sepnet")]), fact_at_most(Fact::MinVoxelSize, 0.6)],
1701 "separation.algorithm", &[Choice("chi-sepnet")], Choice("chi-sep-ilsqr"), Basis::Stated,
1702 "χ-sepnet's authors state the networks process resolutions coarser than 0.6 mm",
1703 "SNU-LIST/chi-separation README; Kim et al. 2024 (arXiv:2410.12239) §4"),
1704 reference_stated("chi-sep.tukey-siemens-ge",
1708 &[sep(CHI_SEP_TOOLBOX_ITERATIVE), manufacturer(&[Choice("siemens"), Choice("ge")])],
1709 "tukey_window", &[Number(CHISEP_TUKEY_SIEMENS_GE)], TUKEY_SIEMENS_GE_REASON, TUKEY_SOURCE),
1710 reference_stated("chi-sep.tukey-philips",
1711 &[sep(CHI_SEP_TOOLBOX_ITERATIVE), manufacturer(&[Choice("philips")])],
1712 "tukey_window", &[Number(0.0)], TUKEY_PHILIPS_REASON, TUKEY_SOURCE),
1713 reference_stated("chi-sepnet.tukey-siemens-ge",
1714 &[sep(&[Choice("chi-sepnet")]), CHI_SEPNET_TOOLBOX_ROUTE, manufacturer(&[Choice("siemens"), Choice("ge")])],
1715 "tukey_window", &[Number(CHISEP_TUKEY_SIEMENS_GE)], TUKEY_SIEMENS_GE_REASON, TUKEY_SOURCE),
1716 reference_stated("chi-sepnet.tukey-philips",
1717 &[sep(&[Choice("chi-sepnet")]), CHI_SEPNET_TOOLBOX_ROUTE, manufacturer(&[Choice("philips")])],
1718 "tukey_window", &[Number(0.0)], TUKEY_PHILIPS_REASON, TUKEY_SOURCE),
1719];
1720
1721#[cfg(test)]
1722mod tests {
1723 use super::*;
1724 use crate::pipeline::config::*;
1725
1726 fn facts() -> ScanFacts {
1727 ScanFacts {
1728 field_strength: Some(3.0),
1729 echo_count: Some(4),
1730 voxel_size: Some((1.0, 1.0, 1.0)),
1731 dims: Some((192, 256, 160)),
1732 manufacturer: Some(Manufacturer::Siemens),
1733 target: Target::Native,
1734 }
1735 }
1736
1737 #[derive(Clone)]
1739 struct Case {
1740 cfg: QsmPipelineConfig,
1741 sep: Option<SeparationConfig>,
1742 seg: Option<SynthSegParams>,
1743 facts: ScanFacts,
1744 }
1745
1746 impl Case {
1747 fn new(f: impl FnOnce(&mut Case)) -> Case {
1748 let mut c = Case { cfg: QsmPipelineConfig::default(), sep: None, seg: None, facts: facts() };
1749 f(&mut c);
1750 c
1751 }
1752 fn input(&self) -> RuleInput<'_> {
1753 RuleInput { pipeline: &self.cfg, separation: self.sep.as_ref(), segmentation: self.seg.as_ref() }
1754 }
1755 fn ids(&self) -> Vec<&'static str> {
1756 evaluate(&self.input(), &self.facts).iter().map(|f| f.rule.id).collect()
1757 }
1758 }
1759
1760 fn inversion(a: InversionAlgorithm) -> impl FnOnce(&mut Case) {
1761 move |c| c.cfg.inversion.algorithm = a
1762 }
1763 fn separation(a: SeparationAlgorithm) -> impl FnOnce(&mut Case) {
1764 move |c| c.sep = Some(SeparationConfig { algorithm: a, ..Default::default() })
1765 }
1766 fn background(a: BgRemovalAlgorithm) -> impl FnOnce(&mut Case) {
1767 move |c| c.cfg.bg_removal.algorithm = a
1768 }
1769 fn linear_fit(c: &mut Case) {
1770 c.cfg.field_mapping.b0_estimation = B0EstimationMethod::LinearFit(Default::default());
1771 }
1772
1773 #[test]
1776 fn heidi_offers_lsqr_for_every_other_seed() {
1777 for &a in InversionAlgorithm::VARIANTS {
1778 let mut case = Case::new(|c| { inversion(a)(c); c.cfg.inversion.apply_heidi = true });
1779 case.cfg.inversion.tkd.threshold = 0.1;
1781 case.cfg.inversion.tsvd.threshold = 0.1;
1782 let lsqr = evaluate(&case.input(), &case.facts).into_iter().filter(|f| {
1783 f.rule.setting == "inversion.algorithm" && f.rule.id.starts_with("heidi.")
1784 && f.rule.default == Choice("lsqr") && f.rule.strength > Strength::Reference
1785 }).count();
1786 assert_eq!(lsqr > 0, a != InversionAlgorithm::Lsqr, "{a:?}: {:?}", case.ids());
1787 }
1788 }
1789
1790 fn violations() -> Vec<(&'static str, Case)> {
1792 use BgRemovalAlgorithm as B;
1793 use InversionAlgorithm as I;
1794 use SeparationAlgorithm as S;
1795 vec![
1796 ("medi.mask-bet", Case::new(inversion(I::Medi))),
1797 ("tfi.mask-bet", Case::new(inversion(I::Tfi))),
1798 ("pdf.mask-bet", Case::new(background(B::Pdf))),
1799 ("amp-pe.mask-bet", Case::new(inversion(I::AmpPe))),
1800 ("qsmnet.mask-bet", Case::new(inversion(I::QsmnetPlus))),
1801 ("qsmgan.mask-bet", Case::new(inversion(I::Qsmgan))),
1802 ("lpcnn.mask-bet", Case::new(inversion(I::Lpcnn))),
1803 ("nextqsm.mask-bet", Case::new(inversion(I::Nextqsm))),
1804 ("iqsm.mask-bet", Case::new(inversion(I::Iqsm))),
1805 ("bfrnet.mask-bet", Case::new(background(B::Bfrnet))),
1806 ("chi-sep.mask-bet", Case::new(separation(S::ChiSepMedi))),
1807 ("qsmnetme.mask-bet", Case::new(inversion(I::Qsmnetme))),
1808 ("decompose.mask-bet", Case::new(separation(S::Decompose))),
1809 ("mindgrab.wasm-heap", Case::new(|c| {
1810 c.cfg.masking.sections[0].generator = MaskOp::MindGrab(Default::default());
1811 c.facts.target = Target::Wasm;
1812 })),
1813 ("hd-bet.wasm-heap", Case::new(|c| {
1814 c.cfg.masking.sections[0].generator = MaskOp::HdBet(crate::bet::HdBetParams::default());
1815 c.facts.target = Target::Wasm;
1816 })),
1817 ("synthseg.wasm-windows", Case::new(|c| {
1818 c.seg = Some(SynthSegParams::default());
1819 c.facts.target = Target::Wasm;
1820 })),
1821 ("synthseg.wasm-128-window", Case::new(|c| {
1822 c.seg = Some(SynthSegParams {
1823 inference: SynthSegInference::SlidingWindow { patch: 128, overlap: 48 },
1824 ..SynthSegParams::low_memory()
1825 });
1826 c.facts.target = Target::Wasm;
1827 })),
1828 ("fansi.no-laplacian-multi-echo", Case::new(|c| {
1829 c.cfg.inversion.algorithm = I::Ndi;
1830 c.cfg.field_mapping.unwrapping_algorithm = UnwrappingAlgorithm::Laplacian;
1831 })),
1832 ("qsmnet.unwrap-laplacian", Case::new(inversion(I::Qsmnet))),
1833 ("autoqsm.unwrap-laplacian", Case::new(inversion(I::Autoqsm))),
1834 ("qsmgan.unwrap-laplacian", Case::new(inversion(I::Qsmgan))),
1835 ("decompose.unwrap-laplacian", Case::new(separation(S::Decompose))),
1836 ("chi-sep.unwrap-romeo", Case::new(|c| {
1837 separation(S::ChiSepIlsqr)(c);
1838 c.cfg.field_mapping.unwrapping_algorithm = UnwrappingAlgorithm::Laplacian;
1839 })),
1840 ("qsmnetme.unwrap-romeo", Case::new(|c| {
1841 inversion(I::Qsmnetme)(c);
1842 c.cfg.field_mapping.unwrapping_algorithm = UnwrappingAlgorithm::Laplacian;
1843 })),
1844 ("field-mapping.phase-offsets", Case::new(|c| c.cfg.field_mapping.phase_offset_removal = false)),
1845 ("field-mapping.bipolar-needs-three-echoes", Case::new(|c| {
1846 c.cfg.field_mapping.bipolar_correction = true;
1847 c.facts.echo_count = Some(2);
1848 })),
1849 ("field-mapping.bipolar-not-with-laplacian", Case::new(|c| {
1850 c.cfg.field_mapping.bipolar_correction = true;
1851 c.cfg.field_mapping.unwrapping_algorithm = UnwrappingAlgorithm::Laplacian;
1852 })),
1853 ("medi.field-complex-fit", Case::new(inversion(I::Medi))),
1854 ("pdf.field-complex-fit", Case::new(background(B::Pdf))),
1855 ("r2star-qsm.field-complex-fit", Case::new(separation(S::R2starQsm))),
1856 ("amp-pe.echo-combination", Case::new(|c| { inversion(I::AmpPe)(c); linear_fit(c) })),
1857 ("amp-pe.echo-weights", Case::new(inversion(I::AmpPe))),
1858 ("qsmnet.echo-combination", Case::new(|c| { inversion(I::Qsmnet)(c); linear_fit(c) })),
1859 ("qsmnet.echo-weights", Case::new(inversion(I::Qsmnet))),
1860 ("autoqsm.echo-combination", Case::new(|c| { inversion(I::Autoqsm)(c); linear_fit(c) })),
1861 ("autoqsm.echo-weights", Case::new(inversion(I::Autoqsm))),
1862 ("chi-sep.echo-combination", Case::new(|c| { separation(S::ChiSepNet)(c); linear_fit(c) })),
1863 ("chi-sep.echo-weights", Case::new(separation(S::ChiSepIlsqr))),
1864 ("qsmnetme.echo-combination", Case::new(|c| { inversion(I::Qsmnetme)(c); linear_fit(c) })),
1865 ("qsmnetme.echo-weights", Case::new(inversion(I::Qsmnetme))),
1866 ("hdqsm.bg-pdf", Case::new(inversion(I::Hdqsm))),
1867 ("amp-pe.bg-pdf", Case::new(inversion(I::AmpPe))),
1868 ("lsqr.bg-sharp", Case::new(inversion(I::Lsqr))),
1869 ("heidi.local-field", Case::new(|c| { inversion(I::Tfi)(c); c.cfg.inversion.apply_heidi = true })),
1870 ("heidi.seed-lsqr", Case::new(|c| {
1871 inversion(I::Tkd)(c);
1872 c.cfg.inversion.apply_heidi = true;
1873 c.cfg.inversion.tkd.threshold = 0.1;
1874 })),
1875 ("heidi.seed-rts", Case::new(|c| { inversion(I::Rts)(c); c.cfg.inversion.apply_heidi = true })),
1876 ("heidi.seed-regularised", Case::new(|c| { inversion(I::Medi)(c); c.cfg.inversion.apply_heidi = true })),
1877 ("heidi.tkd-cone", Case::new(|c| { inversion(I::Tkd)(c); c.cfg.inversion.apply_heidi = true })),
1878 ("heidi.tsvd-cone", Case::new(|c| { inversion(I::Tsvd)(c); c.cfg.inversion.apply_heidi = true })),
1879 ("rts.bg-vsharp", Case::new(background(B::Pdf))),
1880 ("xqsm.bg-resharp", Case::new(inversion(I::Xqsm))),
1881 ("qsmnet.bg-vsharp", Case::new(|c| { inversion(I::Qsmnet)(c); background(B::Lbv)(c) })),
1882 ("qsmgan.bg-vsharp", Case::new(|c| { inversion(I::Qsmgan)(c); background(B::Lbv)(c) })),
1883 ("lpcnn.bg-vsharp", Case::new(|c| { inversion(I::Lpcnn)(c); background(B::Lbv)(c) })),
1884 ("chi-sep.bg-vsharp", Case::new(|c| { separation(S::ChiSepIlsqr)(c); background(B::Pdf)(c) })),
1885 ("qsmnetme.bg-vsharp", Case::new(|c| { inversion(I::Qsmnetme)(c); background(B::Pdf)(c) })),
1886 ("r2star-qsm.bg-pdf", Case::new(separation(S::R2starQsm))),
1887 ("decompose.bg-vsharp", Case::new(|c| { separation(S::Decompose)(c); background(B::Pdf)(c) })),
1888 ("star.padding", Case::new(|c| {
1889 c.cfg.inversion.algorithm = I::Star;
1890 c.cfg.inversion.star.pad = [12; 3];
1891 })),
1892 ("chi-sep-ilsqr.qsm-ilsqr", Case::new(separation(S::ChiSepIlsqr))),
1893 ("chi-sep-medi.qsm-ilsqr", Case::new(separation(S::ChiSepMedi))),
1894 ("medi.bg-pdf", Case::new(inversion(I::Medi))),
1895 ("sti.vsharp-min-radius", Case::new(|c| { inversion(I::Ilsqr)(c); c.cfg.bg_removal.vsharp.min_radius = 1.0 })),
1896 ("sti.vsharp-radius-step", Case::new(|c| { inversion(I::Star)(c); c.cfg.bg_removal.vsharp.radius_step = 2.0 })),
1897 ("sti.ilsqr-iterations", Case::new(|c| { inversion(I::Ilsqr)(c); c.cfg.inversion.ilsqr.max_iter = 40 })),
1898 ("sti.ilsqr-pad", Case::new(|c| { inversion(I::Ilsqr)(c); c.cfg.inversion.ilsqr.pad_mm = 6.0 })),
1899 ("field-mapping.phase-offset-sigma", Case::new(|c| c.cfg.field_mapping.phase_offset_sigma_mm = [10.0, 10.0, 5.0])),
1900 ("medi-msmv.three-echoes", Case::new(|c| {
1901 inversion(I::Medi)(c);
1902 c.cfg.inversion.medi.msmv = Some(Default::default());
1903 c.facts.echo_count = Some(2);
1904 })),
1905 ("medi-msmv.bg-removal", Case::new(|c| {
1906 inversion(I::Medi)(c);
1907 c.cfg.inversion.medi.msmv = Some(Default::default());
1908 background(B::Sharp)(c);
1909 })),
1910 ("medi-msmv.prefilter", Case::new(|c| {
1911 inversion(I::Medi)(c);
1912 c.cfg.inversion.medi.msmv =
1913 Some(crate::inversion::MsmvParams { prefilter: false, ..Default::default() });
1914 })),
1915 ("fansi.te-spacing", Case::new(|c| { inversion(I::FansiTgv)(c); c.cfg.inversion.fansi.te = crate::EchoTime::First })),
1916 ("ndi.te-first", Case::new(|c| { inversion(I::Ndi)(c); c.cfg.inversion.ndi.te = crate::EchoTime::Spacing })),
1917 ("l1qsm.te-first", Case::new(|c| { inversion(I::L1qsm)(c); c.cfg.inversion.l1qsm.te = crate::EchoTime::Sum })),
1918 ("whqsm.flat-weight-te-spacing", Case::new(|c| { inversion(I::Whqsm)(c); c.cfg.inversion.whqsm.te = crate::EchoTime::Spacing })),
1919 ("whqsm.magnitude-weight-te-spacing", Case::new(|c| {
1920 inversion(I::Whqsm)(c);
1921 c.cfg.inversion.whqsm.weight = crate::inversion::WhQsmWeight::InVivo;
1922 })),
1923 ("star.te-sum", Case::new(|c| { inversion(I::Star)(c); c.cfg.inversion.star.te = crate::EchoTime::First })),
1924 ("medi.te-spacing", Case::new(|c| { inversion(I::Medi)(c); c.cfg.inversion.medi.te = crate::EchoTime::First })),
1925 ("tfi.te-spacing", Case::new(|c| { inversion(I::Tfi)(c); c.cfg.inversion.tfi.te = crate::EchoTime::Last })),
1926 ("heidi.te-last", Case::new(|c| {
1927 inversion(I::Lsqr)(c);
1928 c.cfg.inversion.apply_heidi = true;
1929 c.cfg.inversion.heidi.te = crate::EchoTime::First;
1930 })),
1931 ("sti.bg-vsharp", Case::new(|c| { inversion(I::Star)(c); background(B::Pdf)(c) })),
1932 ("sti.vsharp-no-deconvolve", Case::new(|c| { inversion(I::Ilsqr)(c); c.cfg.bg_removal.vsharp.deconvolve = true })),
1933 ("sti.vsharp-radius", Case::new(|c| { inversion(I::Ilsqr)(c); c.cfg.bg_removal.vsharp.max_radius = 8.0 })),
1934 ("sti.vsharp-grid", Case::new(|c| { inversion(I::Star)(c); c.cfg.bg_removal.vsharp.grid = crate::bgremove::VsharpGrid::Native })),
1935 ("resharp.radius", Case::new(|c| { background(B::Resharp)(c); c.cfg.bg_removal.resharp.radius = 6.0 })),
1936 ("resharp.iterations", Case::new(|c| { background(B::Resharp)(c); c.cfg.bg_removal.resharp.max_iter = 30 })),
1937 ("pdf.cg-iterations", Case::new(background(B::Pdf))),
1938 ("lbv.tolerance", Case::new(|c| { background(B::Lbv)(c); c.cfg.bg_removal.lbv.tol = 1e-6 })),
1939 ("ismv-qsmjl.tolerance", Case::new(|c| { background(B::Ismv)(c); c.cfg.bg_removal.ismv.variant = crate::bgremove::IsmvVariant::QsmJl })),
1940 ("ismv-qsmjl.max-iterations", Case::new(|c| { background(B::Ismv)(c); c.cfg.bg_removal.ismv.variant = crate::bgremove::IsmvVariant::QsmJl })),
1941 ("sti.unwrap-laplacian", Case::new(inversion(I::Ilsqr))),
1942 ("sti.laplacian-solver", Case::new(|c| {
1943 inversion(I::Star)(c);
1944 c.cfg.field_mapping.unwrapping_algorithm = UnwrappingAlgorithm::Laplacian;
1945 })),
1946 ("qsmart.unwrap-laplacian", Case::new(inversion(I::Qsmart))),
1947 ("qsmart.laplacian-solver", Case::new(|c| {
1948 inversion(I::Qsmart)(c);
1949 c.cfg.field_mapping.unwrapping_algorithm = UnwrappingAlgorithm::Laplacian;
1950 })),
1951 ("qsmart.field-echofit", Case::new(inversion(I::Qsmart))),
1952 ("qsmart.unwrap-unmasked", Case::new(|c| {
1953 inversion(I::Qsmart)(c);
1954 c.cfg.field_mapping.unwrapping_algorithm = UnwrappingAlgorithm::Laplacian;
1955 })),
1956 ("sti.unwrap-unmasked", Case::new(|c| {
1957 inversion(I::Ilsqr)(c);
1958 c.cfg.field_mapping.unwrapping_algorithm = UnwrappingAlgorithm::Laplacian;
1959 })),
1960 ("sti.echo-combination", Case::new(|c| { inversion(I::Star)(c); linear_fit(c) })),
1961 ("sti.echo-weights", Case::new(inversion(I::Ilsqr))),
1962 ("qsmart.mask-bet", Case::new(inversion(I::Qsmart))),
1963 ("qsmart.inner-inversion", Case::new(|c| {
1964 inversion(I::Qsmart)(c);
1965 c.cfg.inversion.qsmart.inversion = I::Star;
1966 })),
1967 ("qsmart.ilsqr-iterations", Case::new(|c| { inversion(I::Qsmart)(c); c.cfg.inversion.qsmart.ilsqr_max_iter = 100 })),
1968 ("qsmart.ilsqr-pad", Case::new(|c| { inversion(I::Qsmart)(c); c.cfg.inversion.qsmart.ilsqr_pad_mm = 12.0 })),
1969 ("r2star-qsm.qsm-medi", Case::new(separation(S::R2starQsm))),
1970 ("decompose.qsm-star", Case::new(separation(S::Decompose))),
1971 ("decompose.qsm-per-echo", Case::new(separation(S::Decompose))),
1972 ("decompose.unwrap-7t", Case::new(|c| {
1973 separation(S::Decompose)(c);
1974 c.facts.field_strength = Some(7.0);
1975 })),
1976 ("decompose.chi-bound-7t", Case::new(|c| {
1977 separation(S::Decompose)(c);
1978 c.facts.field_strength = Some(7.0);
1979 })),
1980 ("decompose.reference-mean", Case::new(|c| {
1981 separation(S::Decompose)(c);
1982 c.cfg.reference = QsmReference::None;
1983 })),
1984 ("susep-net.qsm-iqsm-plus", Case::new(separation(S::SusepNet))),
1985 ("chi-sepnet.min-resolution", Case::new(|c| {
1986 separation(S::ChiSepNet)(c);
1987 c.facts.voxel_size = Some((0.5, 0.5, 0.5));
1988 })),
1989 ("chi-sepnet.tukey-siemens-ge", Case::new(separation(S::ChiSepNet))),
1990 ("chi-sep.tukey-siemens-ge", Case::new(separation(S::ChiSepMedi))),
1991 ("chi-sep.tukey-philips", Case::new(|c| {
1992 separation(S::ChiSepIlsqr)(c);
1993 c.facts.manufacturer = Some(Manufacturer::Philips);
1994 c.cfg.tukey_window = 0.4;
1995 })),
1996 ("complex-fit.unwrap-region-growing", Case::new(|c| {
1997 c.cfg.field_mapping.b0_estimation = B0EstimationMethod::ComplexFit(Default::default());
1998 })),
1999 ("chi-sepnet.tukey-philips", Case::new(|c| {
2000 separation(S::ChiSepNet)(c);
2001 c.facts.manufacturer = Some(Manufacturer::Philips);
2002 c.cfg.tukey_window = 0.4;
2003 })),
2004 ]
2005 }
2006
2007 #[test]
2010 fn rules_are_well_formed() {
2011 let cfg = QsmPipelineConfig::default();
2012 let input = RuleInput::pipeline(&cfg);
2013 let facts = ScanFacts::default();
2014 let known = |p: &str| SETTINGS.iter().any(|(s, _)| *s == p);
2015 let mut ids = std::collections::HashSet::new();
2016 for r in RULES {
2017 assert!(ids.insert(r.id), "duplicate rule id {}", r.id);
2018 assert!(!r.when.is_empty(), "{}: no trigger", r.id);
2019 assert!(matches!(r.trigger(), Condition::Setting { .. }), "{}: the trigger is a choice", r.id);
2020 assert!(known(r.setting), "{}: {} not in SETTINGS", r.id, r.setting);
2021 for c in r.when {
2022 if let Condition::Setting { setting, .. } = c {
2023 assert!(known(setting), "{}: {setting} not in SETTINGS", r.id);
2024 }
2025 }
2026 let d = match r.default {
2027 Value::Bool(b) => Current::Bool(b),
2028 Value::Number(x) => Current::Number(x),
2029 Value::Choice(s) => Current::Choice(s),
2030 Value::Vector(v) => Current::Vector(v.to_vec()),
2031 };
2032 assert!(r.expect.passes(&d), "{}: default does not pass its own test", r.id);
2033 assert!(!r.reason.is_empty() && !r.source.is_empty(), "{}: reason and source", r.id);
2034 }
2035 for (p, _) in SETTINGS {
2036 assert!(current(&input, &facts, p).is_some(), "{p} in SETTINGS but unreadable");
2037 }
2038 }
2039
2040 #[test]
2045 fn default_config_raises_nothing() {
2046 for facts in [ScanFacts::default(), facts()] {
2047 let c = Case { seg: Some(SynthSegParams::default()), facts, ..Case::new(|_| {}) };
2048 assert!(c.ids().is_empty(), "{:?}", c.ids());
2049 }
2050 }
2051
2052 #[test]
2054 fn each_rule_fires_on_a_violation() {
2055 let cases = violations();
2056 for r in RULES {
2057 assert!(cases.iter().any(|(id, _)| *id == r.id), "{}: no violating case", r.id);
2058 }
2059 for (id, case) in &cases {
2060 assert!(RULES.iter().any(|r| r.id == *id), "{id}: not a rule");
2061 assert!(case.ids().contains(id), "{id} did not fire: {:?}", case.ids());
2062 }
2063 }
2064
2065 #[test]
2068 fn kinds_follow_strengths() {
2069 for r in RULES {
2070 assert_eq!(r.kind() == Kind::Reference, r.strength == Strength::Reference, "{}", r.id);
2071 }
2072 let json = serde_json::to_value(RULES).unwrap();
2073 for (r, j) in RULES.iter().zip(json.as_array().unwrap()) {
2074 assert_eq!(j["kind"], serde_json::to_value(r.kind()).unwrap(), "{}", r.id);
2075 }
2076 let kind = |id: &str| RULES.iter().find(|r| r.id == id).unwrap().kind();
2077 assert_eq!(kind("complex-fit.unwrap-region-growing"), Kind::Reference);
2079 assert_eq!(kind("chi-sepnet.tukey-siemens-ge"), Kind::Reference);
2080 assert_eq!(kind("qsmnet.bg-vsharp"), Kind::Reference);
2081 assert_eq!(kind("heidi.seed-lsqr"), Kind::Reasoned);
2082 assert_eq!(kind("pdf.cg-iterations"), Kind::Reasoned);
2083 }
2084
2085 #[test]
2087 fn strengths_order_and_rest_on_their_basis() {
2088 use Strength::*;
2089 assert!(Required > Recommended && Recommended > Discouraged && Discouraged > Reference);
2090 for r in RULES {
2091 match r.strength {
2092 Reference => assert!(matches!(r.basis, Basis::ReferenceChoice | Basis::Stated), "{}", r.id),
2093 Recommended => assert!(matches!(r.basis, Basis::Stated | Basis::Measured), "{}", r.id),
2094 Required | Discouraged => assert_ne!(r.basis, Basis::ReferenceChoice, "{}", r.id),
2095 }
2096 }
2097 }
2098
2099 #[test]
2101 fn unknown_facts_do_not_fire() {
2102 for (id, case) in violations() {
2103 let r = RULES.iter().find(|r| r.id == id).unwrap();
2104 let blind = Case { facts: ScanFacts { target: case.facts.target, ..Default::default() }, ..case };
2105 if r.needs_scan() {
2106 assert!(!blind.ids().contains(&id), "{id} fired without scan facts");
2107 }
2108 }
2109 }
2110
2111 #[test]
2112 fn hd_bet_low_memory_resolves_it() {
2113 let c = Case::new(|c| {
2114 c.cfg.masking.sections[0].generator = MaskOp::HdBet(crate::bet::HdBetParams::low_memory());
2115 c.facts.target = Target::Wasm;
2116 });
2117 assert!(!c.ids().contains(&"hd-bet.wasm-heap"));
2118 }
2119
2120 #[test]
2121 fn synthseg_window_scales_with_the_grid() {
2122 let c = Case::new(|c| {
2123 c.seg = Some(SynthSegParams {
2124 inference: SynthSegInference::SlidingWindow { patch: 128, overlap: 48 },
2125 ..SynthSegParams::low_memory()
2126 });
2127 c.facts.target = Target::Wasm;
2128 c.facts.dims = Some((184, 256, 128));
2129 });
2130 assert!(!c.ids().contains(&"synthseg.wasm-128-window"));
2131 let low = Case { seg: Some(SynthSegParams::low_memory()), ..c };
2132 assert!(low.ids().is_empty(), "{:?}", low.ids());
2133 }
2134
2135 #[test]
2138 fn rules_are_scoped() {
2139 let c = Case::new(|c| c.cfg.inversion.star.pad = [12; 3]);
2140 assert!(c.ids().is_empty(), "{:?}", c.ids());
2141 let c = Case::new(|c| {
2143 c.cfg.inversion.algorithm = InversionAlgorithm::Autoqsm;
2144 c.cfg.bg_removal.algorithm = BgRemovalAlgorithm::Lbv;
2145 });
2146 assert!(!c.ids().iter().any(|id| id.contains(".bg-")), "{:?}", c.ids());
2147 let c = Case::new(|c| {
2149 c.cfg.inversion.algorithm = InversionAlgorithm::Hdqsm;
2150 c.cfg.bg_removal.algorithm = BgRemovalAlgorithm::Pdf;
2151 c.cfg.masking.sections[0].generator = MaskOp::Bet(Default::default());
2152 c.cfg.field_mapping.b0_estimation = B0EstimationMethod::ComplexFit(Default::default());
2153 c.cfg.field_mapping.unwrapping_algorithm = UnwrappingAlgorithm::MediRegionGrowing;
2154 c.cfg.bg_removal.pdf.n_cg = 300;
2155 });
2156 assert!(c.ids().is_empty(), "{:?}", c.ids());
2157 }
2158
2159 #[test]
2162 fn pdf_suggests_more_iterations() {
2163 let id = "pdf.cg-iterations";
2164 let r = RULES.iter().find(|r| r.id == id).unwrap();
2165 assert_eq!((r.strength, r.basis, r.default), (Strength::Recommended, Basis::Measured, Number(300.0)));
2166 assert_eq!(crate::bgremove::PdfParams::default().n_cg, 30);
2167 let with = |n: usize| Case::new(|c| {
2168 c.cfg.bg_removal.algorithm = BgRemovalAlgorithm::Pdf;
2169 c.cfg.bg_removal.pdf.n_cg = n;
2170 }).ids().contains(&id);
2171 assert!(with(30) && with(299));
2172 assert!(!with(300) && !with(1000));
2173 assert!(!Case::new(|c| c.cfg.bg_removal.pdf.n_cg = 30).ids().contains(&id));
2175 let total_field = Case::new(|c| {
2176 c.cfg.inversion.algorithm = InversionAlgorithm::Autoqsm;
2177 c.cfg.bg_removal.algorithm = BgRemovalAlgorithm::Pdf;
2178 });
2179 assert!(!total_field.ids().contains(&id), "{:?}", total_field.ids());
2180 }
2181
2182 #[test]
2187 fn chi_sepnet_suggests_the_vendor_tukey_window() {
2188 use crate::separation::ChiSepNetRoute;
2189 let ids = |m: Option<Manufacturer>, route: ChiSepNetRoute, tukey: f64| Case::new(|c| {
2190 separation(SeparationAlgorithm::ChiSepNet)(c);
2191 c.sep.as_mut().unwrap().chi_sepnet.route = route;
2192 c.facts.manufacturer = m;
2193 c.cfg.tukey_window = tukey;
2194 }).ids().into_iter().filter(|id| id.starts_with("chi-sepnet.tukey")).collect::<Vec<_>>();
2195 let (sg, ph) = ("chi-sepnet.tukey-siemens-ge", "chi-sepnet.tukey-philips");
2196 use Manufacturer::*;
2197 use ChiSepNetRoute::*;
2198 assert_eq!(ids(Some(Siemens), Toolbox, 0.0), [sg]);
2199 assert_eq!(ids(Some(Ge), Toolbox, 0.2), [sg]);
2200 assert!(ids(Some(Ge), Toolbox, 0.4).is_empty());
2201 assert_eq!(ids(Some(Philips), Toolbox, 0.4), [ph]);
2202 assert!(ids(Some(Philips), Toolbox, 0.0).is_empty());
2203 assert!(ids(Some(Other), Toolbox, 0.0).is_empty());
2204 assert!(ids(None, Toolbox, 0.0).is_empty());
2205 assert!(ids(Some(Siemens), Native, 0.0).is_empty());
2206 let r = RULES.iter().find(|r| r.id == sg).unwrap();
2207 assert_eq!((r.strength, r.basis, r.default), (Strength::Reference, Basis::Stated, Number(0.4)));
2208 assert!(r.needs_scan());
2209 let tukey = |a: SeparationAlgorithm, m: Manufacturer, t: f64| Case::new(|c| {
2211 separation(a)(c);
2212 c.facts.manufacturer = Some(m);
2213 c.cfg.tukey_window = t;
2214 }).ids().into_iter().filter(|id| id.contains(".tukey")).collect::<Vec<_>>();
2215 for a in [SeparationAlgorithm::ChiSepMedi, SeparationAlgorithm::ChiSepIlsqr] {
2216 assert_eq!(tukey(a, Siemens, 0.0), ["chi-sep.tukey-siemens-ge"], "{a:?}");
2217 assert_eq!(tukey(a, Ge, 0.0), ["chi-sep.tukey-siemens-ge"], "{a:?}");
2218 assert!(tukey(a, Ge, 0.4).is_empty(), "{a:?}");
2219 assert_eq!(tukey(a, Philips, 0.4), ["chi-sep.tukey-philips"], "{a:?}");
2220 assert!(tukey(a, Philips, 0.0).is_empty() && tukey(a, Other, 0.0).is_empty(), "{a:?}");
2221 }
2222 for a in [SeparationAlgorithm::Decompose, SeparationAlgorithm::R2starQsm, SeparationAlgorithm::SusepNet] {
2224 assert!(tukey(a, Siemens, 0.0).is_empty(), "{a:?}");
2225 }
2226 for id in ["chi-sep.tukey-siemens-ge", "chi-sep.tukey-philips", "chi-sepnet.tukey-philips"] {
2227 assert_eq!(RULES.iter().find(|r| r.id == id).unwrap().kind(), Kind::Reference, "{id}");
2228 }
2229 }
2230
2231 #[test]
2235 fn complex_fit_suggests_medi_region_growing_as_the_authors_setting() {
2236 let id = "complex-fit.unwrap-region-growing";
2237 let r = RULES.iter().find(|r| r.id == id).unwrap();
2238 assert_eq!((r.kind(), r.basis, r.default), (Kind::Reference, Basis::ReferenceChoice, Choice("medi-region-growing")));
2239 let with = |b0: B0EstimationMethod, u: UnwrappingAlgorithm| Case::new(|c| {
2240 c.cfg.field_mapping.b0_estimation = b0;
2241 c.cfg.field_mapping.unwrapping_algorithm = u;
2242 }).ids().contains(&id);
2243 let fit = || B0EstimationMethod::ComplexFit(Default::default());
2244 assert!(with(fit(), UnwrappingAlgorithm::Romeo) && with(fit(), UnwrappingAlgorithm::Laplacian));
2245 assert!(!with(fit(), UnwrappingAlgorithm::MediRegionGrowing));
2246 assert!(!with(B0EstimationMethod::default(), UnwrappingAlgorithm::Romeo));
2247 assert_eq!(FieldMappingConfig::default().unwrapping_algorithm, UnwrappingAlgorithm::Romeo);
2248 }
2249
2250 #[test]
2251 fn manufacturers_are_read_as_dcm2niix_writes_them() {
2252 use Manufacturer::*;
2253 for (name, m) in [
2254 ("Siemens", Siemens), ("SIEMENS", Siemens), ("Siemens Healthineers", Siemens),
2255 ("GE", Ge), ("GE MEDICAL SYSTEMS", Ge), ("General Electric", Ge), (" ge ", Ge),
2256 ("Philips", Philips), ("Philips Medical Systems", Philips), ("Philips Healthcare", Philips),
2257 ("Canon_MEC", Other), ("TOSHIBA", Other), ("UIH", Other), ("GEMINI", Other), ("", Other),
2258 ] {
2259 assert_eq!(Manufacturer::from_name(name), m, "{name:?}");
2260 }
2261 }
2262
2263 #[test]
2265 fn serialises_to_json() {
2266 let json = serde_json::to_value(RULES).unwrap();
2267 let star = json.as_array().unwrap().iter().find(|r| r["id"] == "star.padding").unwrap();
2268 assert_eq!(star["kind"], "reference");
2269 assert_eq!(star["strength"], "reference");
2270 assert_eq!(star["basis"], "reference_choice");
2271 assert_eq!(star["setting"], "inversion.star.pad");
2272 assert_eq!(star["unit"], "voxels");
2273 assert_eq!(star["when"][0]["on"], "setting");
2274 assert_eq!(star["when"][0]["is"]["test"], "one_of");
2275 assert_eq!(star["when"][0]["is"]["values"][0], "star");
2276 assert_eq!(star["expect"]["test"], "one_of");
2277 assert_eq!(star["default"], serde_json::json!([8.0, 8.0, 20.0]));
2278 let wasm = json.as_array().unwrap().iter().find(|r| r["id"] == "mindgrab.wasm-heap").unwrap();
2279 assert_eq!(wasm["when"][1]["on"], "fact");
2280 assert_eq!(wasm["when"][1]["fact"], "target");
2281
2282 let c = Case::new(|c| {
2283 c.cfg.inversion.algorithm = InversionAlgorithm::Star;
2284 c.cfg.inversion.star.pad = [12; 3];
2285 });
2286 let j = serde_json::to_value(evaluate(&c.input(), &c.facts)).unwrap();
2287 let pad = j.as_array().unwrap().iter().find(|f| f["id"] == "star.padding").unwrap();
2288 assert_eq!(pad["current"], serde_json::json!([[12.0, 12.0, 12.0]]));
2289 assert_eq!(pad["default"], serde_json::json!([8.0, 8.0, 20.0]));
2290 }
2291
2292 #[test]
2295 fn decompose_wants_a_qsm_per_echo() {
2296 let id = "decompose.qsm-per-echo";
2297 let r = RULES.iter().find(|r| r.id == id).unwrap();
2298 assert_eq!((r.strength, r.basis, r.default), (Strength::Recommended, Basis::Stated, Bool(true)));
2299 assert!(Case::new(separation(SeparationAlgorithm::Decompose)).ids().contains(&id));
2300 let on = Case::new(|c| { separation(SeparationAlgorithm::Decompose)(c); c.cfg.qsm_per_echo = true });
2301 assert!(!on.ids().contains(&id), "{:?}", on.ids());
2302 assert!(!Case::new(separation(SeparationAlgorithm::ChiSepIlsqr)).ids().contains(&id));
2303 }
2304
2305 #[test]
2308 fn decompose_unwrapping_at_7t_only() {
2309 let id = "decompose.unwrap-7t";
2310 let at = |b0: Option<f64>, unwrap: bool| Case::new(|c| {
2311 separation(SeparationAlgorithm::Decompose)(c);
2312 c.sep.as_mut().unwrap().decompose.unwrap_phase = unwrap;
2313 c.facts.field_strength = b0;
2314 }).ids().contains(&id);
2315 assert!(at(Some(7.0), false) && at(Some(6.98), false) && at(Some(9.4), false));
2316 assert!(!at(Some(3.0), false) && !at(Some(2.89), false) && !at(Some(1.5), false));
2317 assert!(!at(Some(7.0), true));
2318 assert!(!at(None, false));
2319 let r = RULES.iter().find(|r| r.id == id).unwrap();
2320 assert_eq!((r.strength, r.basis), (Strength::Recommended, Basis::Measured));
2321 assert!(r.needs_scan());
2322 assert!(!crate::separation::DecomposeParams::default().unwrap_phase);
2324 }
2325
2326 #[test]
2329 fn decompose_chi_bound_at_7t_only() {
2330 use crate::separation::DecomposeChiBound as B;
2331 let id = "decompose.chi-bound-7t";
2332 let at = |b0: Option<f64>, bound: B| Case::new(|c| {
2333 separation(SeparationAlgorithm::Decompose)(c);
2334 c.sep.as_mut().unwrap().decompose.chi_bound = bound;
2335 c.facts.field_strength = b0;
2336 }).ids().contains(&id);
2337 assert!(at(Some(7.0), B::Ppm(0.5)) && at(Some(6.98), B::Ppm(0.3)) && at(Some(9.4), B::Ppm(0.5)));
2338 assert!(!at(Some(3.0), B::Ppm(0.5)) && !at(Some(1.5), B::Ppm(0.5)));
2339 assert!(!at(Some(7.0), B::EchoSpacing));
2340 assert!(!at(None, B::Ppm(0.5)));
2341 let r = RULES.iter().find(|r| r.id == id).unwrap();
2342 assert_eq!((r.strength, r.basis), (Strength::Recommended, Basis::Measured));
2343 assert_eq!(r.default, Value::Choice("echo-spacing"));
2344 assert!(r.needs_scan());
2345 assert_eq!(crate::separation::DecomposeParams::default().chi_bound, B::Ppm(0.5));
2347 }
2348
2349 #[test]
2352 fn ismv_qsmjl_suggests_qsmjl_values() {
2353 use crate::bgremove::{IsmvParams, IsmvVariant};
2354 let ids = ["ismv-qsmjl.tolerance", "ismv-qsmjl.max-iterations"];
2355 let with = |p: IsmvParams| Case::new(|c| {
2356 c.cfg.bg_removal.algorithm = BgRemovalAlgorithm::Ismv;
2357 c.cfg.bg_removal.ismv = p;
2358 });
2359 let ismv_ids = |c: &Case| -> Vec<&'static str> { c.ids().into_iter().filter(|id| id.starts_with("ismv")).collect() };
2360 let variant_only = with(IsmvParams { variant: IsmvVariant::QsmJl, ..Default::default() });
2361 assert!(ids.iter().all(|id| variant_only.ids().contains(id)), "{:?}", variant_only.ids());
2362 let f = evaluate(&variant_only.input(), &variant_only.facts);
2363 let cap = f.iter().find(|f| f.rule.id == ids[1]).unwrap();
2364 assert_eq!(cap.current, vec![Current::Number(f64::INFINITY)]);
2365 assert!(serde_json::to_value(cap).unwrap()["current"][0].is_null());
2366 assert!(ismv_ids(&with(IsmvParams::qsmjl())).is_empty());
2367 assert!(ismv_ids(&with(IsmvParams::default())).is_empty());
2368 let p = IsmvParams::qsmjl();
2369 for r in RULES.iter().filter(|r| ids.contains(&r.id)) {
2370 assert_eq!(r.strength, Strength::Reference, "{}", r.id);
2371 let v = if r.setting == "bg_removal.ismv.tol" { p.tol } else { p.max_iter.unwrap() as f64 };
2372 assert_eq!(r.default, Number(v), "{}", r.id);
2373 }
2374 let off = Case::new(|c| c.cfg.bg_removal.ismv.variant = IsmvVariant::QsmJl);
2376 assert!(off.ids().is_empty(), "{:?}", off.ids());
2377 }
2378
2379 #[test]
2382 fn lbv_tolerance_is_suggested_not_rewritten() {
2383 let with = |tol: f64| Case::new(move |c| {
2384 c.cfg.bg_removal.algorithm = BgRemovalAlgorithm::Lbv;
2385 c.cfg.bg_removal.lbv.tol = tol;
2386 });
2387 assert!(!with(0.01).ids().contains(&"lbv.tolerance"));
2388 let old = with(1e-6);
2389 assert!(old.ids().contains(&"lbv.tolerance"), "{:?}", old.ids());
2390 let f = evaluate(&old.input(), &old.facts);
2391 let r = f.iter().find(|f| f.rule.id == "lbv.tolerance").unwrap();
2392 assert_eq!(r.current, vec![Current::Number(1e-6)]);
2393 assert_eq!(r.rule.strength, Strength::Reference);
2394 let off = Case::new(|c| c.cfg.bg_removal.lbv.tol = 1e-6);
2395 assert!(!off.ids().contains(&"lbv.tolerance"));
2396 }
2397
2398 #[test]
2400 fn per_echo_takes_echo_combination_out_of_play() {
2401 let combining = ["field_mapping.b0_estimation", "field_mapping.phase_offset_removal",
2402 "field_mapping.phase_offset_sigma_mm", "field_mapping.bipolar_correction",
2403 "field_mapping.b0_estimation.weighting"];
2404 let mut c = Case::new(|c| c.cfg.field_mapping.phase_offset_removal = true);
2405 for p in combining {
2406 assert!(!current(&c.input(), &c.facts, p).unwrap().is_empty(), "{p}");
2407 }
2408 c.cfg.qsm_per_echo = true;
2409 for p in combining {
2410 assert!(current(&c.input(), &c.facts, p).unwrap().is_empty(), "{p}");
2411 }
2412 assert!(!current(&c.input(), &c.facts, "field_mapping.unwrapping_algorithm").unwrap().is_empty());
2413 assert_eq!(current(&c.input(), &c.facts, "qsm_per_echo").unwrap(), vec![Current::Bool(true)]);
2414 let combined = Case::new(inversion(InversionAlgorithm::Star));
2416 assert!(combined.ids().contains(&"sti.echo-weights"), "{:?}", combined.ids());
2417 let per_echo = Case { cfg: QsmPipelineConfig { qsm_per_echo: true, ..combined.cfg.clone() }, ..combined.clone() };
2418 assert!(!per_echo.ids().contains(&"sti.echo-weights"), "{:?}", per_echo.ids());
2419 }
2420}