CI can’t see a scanner, so the test exam has to be born on disk
Last edited: 2026-08-25
CI (#3) ran green locally and failed for real: tests/datasets/ is .gitignored, so HeVo-18,
HeVo-23, LG_D19, and 20250408-NIST-Mag2 (now brain_mrs_mrsi_exam, brain_extraction_exam,
mrsi_missing_series_exam, and nist_phantom_exam) didn’t exist on the runner, and 8
tests/notebooks that hard-coded paths into them died on ValueError: The base folder does not exist. Uploading
them wasn’t a small fix either — they’re real scanner acquisitions with unclear redistribution
provenance, several MB each, and brittle to extend (issue #10). But the first pass at faking them
filled every .mat/.nii with plain random noise, which only gets a docs page to run — a
spectrum plot of white noise or a brain-shaped blob of static doesn’t teach a reader anything about
what mrs.spec or t1.nii actually look like, and every “which voxel is brighter” example had
nothing real to point at.
What has to be faked, and what doesn’t¶
The loaders need far less real-looking structure than the dataset names suggest — but the content is worth getting right, since these fixtures are what every docs-page reader actually sees:
| Real thing | What the code actually reads | Fake used |
|---|---|---|
| DICOM series | get_dicom_folder only checks the folder name (002_...); MRISeries loads any .nii.gz already sitting in that folder before ever trying DICOM conversion | a real anatomical NIfTI (see below), zero real DICOM bytes |
ScanArchive*.mat | RawMRISeries parses h.rdb_hdr/h.series/h.exam/h.image/h.mrconfig plus spec/bb out of a nested MATLAB struct | a plain nested dict through scipy.io.savemat — round-trips into the mat_struct shape load_mat_file/_todict expect, spec filled by xmris.simulate_fid |
ScanArchive*.h5 (raw P-file) | load_raw_fids only needs the file to exist — a Series{id}_raw_fids.h5 cache sitting next to it is read directly and the archive’s contents are never touched | an empty stub .h5 + a pre-built cache .h5 (still random noise — nothing reads these FIDs for their physics, only their shape) |
Legacy v7.3 .mat (mat_files.md’s mat73 fallback case) | mat73.loadmat reads any HDF5 file whose datasets carry a MATLAB_class attribute | a bare h5py.File with one dataset and that one attribute set |
# the call site every doc page and test ends up with
from mnutils.testing import build_fake_exam
DATA_FOLDER = (
build_fake_exam("brain_mrs_mrsi_exam") / "data"
) # built once per process, cleaned up at exit
t1 = MRISeries(DATA_FOLDER, 2) # real, cropped T1 template
mrsi = MRSISeries(DATA_FOLDER, 8) # HDO/Glucose/Glx, sharper inside the brainSpectra: xmris.simulate_fid, not noise¶
mnutils.testing._spectra.simulate_spectrum places four peaks from one DMI_TRUTH dict — HDO
(4.70 ppm), Glucose (3.80 ppm), Glx (2.40 ppm), and a broad Baseline component (1.9 ppm, 70 Hz
linewidth) standing in for the macromolecular background every real spectrum sits on top of — each
with its own illustrative relative amplitude and linewidth (HDO dominant, as in real 2H-MRSI), via
xmris.simulate_fid + xmris.to_spectrum. Every voxel/average takes two knobs: intensity (peak
amplitude and target SNR) and clarity (a linewidth multiplier on top of each peak’s own
linewidth — higher clarity means a narrower, cleaner peak). Both feed a spatial map instead of
being constant:
brain_mrs_mrsi_exam/brain_extraction_exam’s brain series: the MRSI series’ own NIfTI is the real T1 template downsampled onto the spec grid — that same downsampled array is the per-voxel intensity/clarity map, so “inside the brain” literally means “where the real template has signal.”nist_phantom_exam: aSphereRing(12 spheres, evenly spaced around a 60 mm-radius circle close to the central S/I slice) assigns each sphere a distinct intensity, linearly increasing around the ring; grid cells inside a sphere take that sphere’s intensity, everything else is near-silent.ANATOMY_ONLY_SPHERE_RINGSadds a second, smaller ring that only feeds the anatomical phantom image, neversphere_grid_intensity-- those spheres are visible on the T1 but carry no MRSI signal at all.
Every repetition/voxel also gets a small, independently-seeded B0 (b0_ppm) and zero-order phase
(phase_deg) jitter — real MRS drifts shot-to-shot, real MRSI drifts voxel-to-voxel, and a stack of
spectra that all sit at exactly 0 ppm/0° would look more consistent than any real acquisition. SNR
realism is split by how many transients actually back a spectrum: simulate_averages (the
MRS_unloc/MRS_washin series) scales its target SNR by sqrt(averages / _REFERENCE_TRANSIENTS),
so MRS_unloc’s real 64-transient acquisition count lands well above a single-shot voxel;
simulate_grid (MRSI) has no such boost — each voxel is effectively single-transient, so it draws
from a separately calibrated, ~10x lower _BASE_SNR_MRSI noise floor instead of the unloc-side
_BASE_SNR_UNLOC.
# adding a second range of spheres with a different spectral profile -- SphereRing's job
from mnutils.testing._spectra import PEAKS_PPM, SphereRing
second_ring = SphereRing(
name="lactate_ring",
ring_radius_mm=30.0,
z_offset_mm=-15.0,
peaks_ppm={"Lactate": 1.3, **PEAKS_PPM},
)Both the anatomical image and the MRSI grid place spheres from the same world_xyz -> intensity
function (sphere_intensity), evaluated at each one’s own resolution — so a sphere lands in
roughly the same place in the T1-like image and in the spectral grid without needing the two to
share a coordinate system exactly. The alignment is good to about half a voxel (create_MRSI_affine
adds a half-voxel shift in x/y that the shared placement function doesn’t replicate) — negligible
next to a 10 mm sphere radius.
Why not upload the NIST phantom and fake the rest?
Half-real, half-fake fixtures mean two code paths stay alive in every doc page that touches both, and the repo still carries a binary blob whose provenance someone has to keep vouching for. One generator, zero checked-in data, is simpler to reason about and is what issue #10 asks for anyway.
Anatomy: a real, downloaded template¶
brain_mrs_mrsi_exam’s and brain_extraction_exam’s anatomical/MRSI-seed NIfTIs are cropped from Chris Rorden’s
chris_t1, part of the niivue-images sample set,
licensed CC BY-NC 4.0 — non-commercial use
only. _fetch_t1_template_path() downloads it once per machine into the system temp directory
(mnutils_template_cache/) and reuses that cache on every later call; nothing is checked into the
repo, and every CI run either downloads or reuses its own runner-local copy.
brain_mrs_mrsi_exam also carries a second, sibling volume from the same subject/repo/license:
chris_PD, series 4 (004_2D_Ax_PD), fetched and cropped the same way
(_fetch_pd_template_path()/write_template_pd()) but — unlike the T1 above — kept at its native
0.86x0.86x2.4mm voxel size rather than resampled to isotropic. It exists specifically because the T1
can’t demonstrate anything about voxel shape: every axis of a cube looks right regardless of which
one ends up in-plane. chris_PD’s real in-plane/slice-thickness anisotropy is what
the GESeries class hierarchy page uses to show
display()'s zooms correction (issue #35) actually doing something across display_planes.
Why not skip the download and use a procedural phantom instead?
A layered-ellipsoid/Shepp-Logan-style skull-brain model would need no network access and no attribution, but it isn’t real anatomy — the whole point here was to stop looking at noise. The templates are small (~4-5 MB combined), cached after the first fetch, and CC BY-NC 4.0 is easy to honor for a non-commercial open-source test fixture (attribution above, and in the GESeries class hierarchy page where they’re first loaded).
Cleanup: atexit, not a fixture¶
build_fake_exam(dataset) is called with a dataset name from both pytest and plain notebook cells
(MyST’s --execute runs docs pages outside pytest entirely), so there’s no shared fixture scope to
hang teardown on. All datasets share one tempfile.mkdtemp() directory, created the first time any
dataset is requested per process; each dataset itself is only built the first time it’s requested
(functools.cache, keyed on the dataset name), so every call within one test run or one notebook
reuses the same tree. An atexit.register(shutil.rmtree, ...) is registered once for that shared
directory — so the tree is removed when the pytest process or the notebook kernel exits, whichever
called it. The downloaded
template lives separately, in a machine-wide cache directory rather than the per-process tree, so
it survives across runs instead of being re-fetched every time.
test_brain_extract.py::test_hevo23_bet also gained a pytest.importorskip("torch") guard and an
explicit device="cpu" on extract_brain() — the bet extra (torch + HD-BET) isn’t installed by
the CI test job’s plain uv sync, and extract_brain’s device default is "cuda", which
would fail on a GPU-less runner even with torch present. Both were latent gaps the missing dataset
had been masking — the test never got far enough to hit either.