MNutils 1.2.3__tar.gz → 1.2.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. {mnutils-1.2.3 → mnutils-1.2.4}/PKG-INFO +4 -10
  2. {mnutils-1.2.3 → mnutils-1.2.4}/README.md +3 -6
  3. {mnutils-1.2.3 → mnutils-1.2.4}/pyproject.toml +4 -5
  4. {mnutils-1.2.3 → mnutils-1.2.4}/pyproject.toml.orig +5 -5
  5. {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/GEExam.py +50 -77
  6. {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/GESeries.py +81 -22
  7. {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/__init__.py +13 -0
  8. {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/_scripts.py +34 -16
  9. mnutils-1.2.4/src/mnutils/fitting/__init__.py +16 -0
  10. {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/plotting/__init__.py +1 -13
  11. {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/plotting/_widgets/image_grid.css +21 -5
  12. {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/plotting/_widgets/image_grid.js +7 -0
  13. {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/plotting/_widgets/image_grid.py +8 -0
  14. {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/plotting/images.py +228 -96
  15. {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/plotting/saving.py +23 -45
  16. {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/plotting/spectra.py +18 -17
  17. mnutils-1.2.4/src/mnutils/rcparams.py +449 -0
  18. {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/testing/synthetic.py +176 -34
  19. {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/utils/data_loaders.py +163 -5
  20. {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/utils/file_helpers.py +305 -175
  21. {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/utils/images.py +9 -9
  22. {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/utils/nifti.py +277 -287
  23. {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/utils/partial_volume.py +105 -20
  24. mnutils-1.2.3/src/mnutils/fitting/__init__.py +0 -5
  25. {mnutils-1.2.3 → mnutils-1.2.4}/LICENSE +0 -0
  26. {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/fitting/AMARES.py +0 -0
  27. {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/matlab/__init__.py +0 -0
  28. {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/matlab/setup_matlab.py +0 -0
  29. {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/plotting/_widgets/__init__.py +0 -0
  30. {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/plotting/_widgets/_html.py +0 -0
  31. {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/plotting/_widgets/_shared/__init__.py +0 -0
  32. {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/plotting/_widgets/_shared/dom.js +0 -0
  33. {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/plotting/_widgets/_shared/theme.css +0 -0
  34. {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/plotting/_widgets/mrsi_inspector.css +0 -0
  35. {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/plotting/_widgets/mrsi_inspector.js +0 -0
  36. {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/plotting/_widgets/mrsi_inspector.py +0 -0
  37. {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/plotting/_widgets/slice_viewer.css +0 -0
  38. {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/plotting/_widgets/slice_viewer.js +0 -0
  39. {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/plotting/_widgets/slice_viewer.py +0 -0
  40. {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/py.typed +0 -0
  41. {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/testing/__init__.py +0 -0
  42. {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/testing/_spectra.py +0 -0
  43. {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/utils/__init__.py +0 -0
  44. {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/utils/spectra.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: MNutils
3
- Version: 1.2.3
3
+ Version: 1.2.4
4
4
  Summary: Utility functions for multinuclear MR data processing
5
5
  Keywords: mri,mrs,xarray,spectroscopy,nmr
6
6
  Author: Sebastian Bauer
@@ -14,7 +14,6 @@ Classifier: Intended Audience :: Science/Research
14
14
  Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
15
15
  Classifier: Operating System :: OS Independent
16
16
  Classifier: Framework :: Jupyter
17
- Requires-Dist: dcm2niiw>=0.1.5
18
17
  Requires-Dist: ipython>=8.0
19
18
  Requires-Dist: loguru>=0.7.3
20
19
  Requires-Dist: mat73
@@ -26,7 +25,6 @@ Requires-Dist: scipy>=1.15.3
26
25
  Requires-Dist: seaborn>=0.13.2
27
26
  Requires-Dist: xarray>=2025.6.1
28
27
  Requires-Dist: xmris[fitting]
29
- Requires-Dist: matlabengine==25.1.* ; extra == '2025a'
30
28
  Requires-Dist: hd-bet>=2.0.1 ; extra == 'bet'
31
29
  Requires-Dist: torch>=2.10.0 ; extra == 'bet'
32
30
  Requires-Python: >=3.12
@@ -34,7 +32,6 @@ Project-URL: Homepage, https://github.com/bastigw/mnutils
34
32
  Project-URL: Documentation, https://bastigw.github.io/mnutils/
35
33
  Project-URL: Repository, https://github.com/bastigw/mnutils
36
34
  Project-URL: Issues, https://github.com/bastigw/mnutils/issues
37
- Provides-Extra: 2025a
38
35
  Provides-Extra: bet
39
36
  Description-Content-Type: text/markdown
40
37
 
@@ -58,7 +55,7 @@ scanners.
58
55
  ## Features
59
56
 
60
57
  - **Data loading** — read GE raw/reconstructed data via `GEExam`/`GESeries`.
61
- - **NIfTI/DICOM conversion** — convert scanner output to NIfTI.
58
+ - **NIfTI handling** — read dcm2niix-converted scanner output (NIfTI + JSON sidecar); DICOMs are not read.
62
59
  - **Plotting** — spectral and slice plotting utilities.
63
60
  - **Spectral fitting** — AMARES fitting delegated to [`xmris`](https://github.com/andrewendlinger/xmris)
64
61
  (`xmris[fitting]`, which wraps pyAMARES).
@@ -78,7 +75,6 @@ Optional extras:
78
75
 
79
76
  ```bash
80
77
  uv add "mnutils[bet]" # brain extraction (hd-bet, torch)
81
- uv add "mnutils[2025a]" # MATLAB Engine pinned to MATLAB 2025a
82
78
  ```
83
79
 
84
80
  To track the development version instead:
@@ -87,10 +83,8 @@ To track the development version instead:
87
83
  uv add git+https://github.com/bastigw/mnutils.git
88
84
  ```
89
85
 
90
- `2025a` pins `matlabengine==25.1.*`. If your local MATLAB install is a different version, install
91
- the matching `matlabengine` release yourself instead (see [MATLAB Engine
92
- Dependency](#matlab-engine-dependency) below) — there's no way to pin one version project-wide
93
- across contributors' machines.
86
+ The MATLAB Engine is not an extra: its version has to match your local MATLAB install, so you
87
+ install it yourself (see [MATLAB Engine Dependency](#matlab-engine-dependency) below).
94
88
 
95
89
  ## Usage
96
90
 
@@ -18,7 +18,7 @@ scanners.
18
18
  ## Features
19
19
 
20
20
  - **Data loading** — read GE raw/reconstructed data via `GEExam`/`GESeries`.
21
- - **NIfTI/DICOM conversion** — convert scanner output to NIfTI.
21
+ - **NIfTI handling** — read dcm2niix-converted scanner output (NIfTI + JSON sidecar); DICOMs are not read.
22
22
  - **Plotting** — spectral and slice plotting utilities.
23
23
  - **Spectral fitting** — AMARES fitting delegated to [`xmris`](https://github.com/andrewendlinger/xmris)
24
24
  (`xmris[fitting]`, which wraps pyAMARES).
@@ -38,7 +38,6 @@ Optional extras:
38
38
 
39
39
  ```bash
40
40
  uv add "mnutils[bet]" # brain extraction (hd-bet, torch)
41
- uv add "mnutils[2025a]" # MATLAB Engine pinned to MATLAB 2025a
42
41
  ```
43
42
 
44
43
  To track the development version instead:
@@ -47,10 +46,8 @@ To track the development version instead:
47
46
  uv add git+https://github.com/bastigw/mnutils.git
48
47
  ```
49
48
 
50
- `2025a` pins `matlabengine==25.1.*`. If your local MATLAB install is a different version, install
51
- the matching `matlabengine` release yourself instead (see [MATLAB Engine
52
- Dependency](#matlab-engine-dependency) below) — there's no way to pin one version project-wide
53
- across contributors' machines.
49
+ The MATLAB Engine is not an extra: its version has to match your local MATLAB install, so you
50
+ install it yourself (see [MATLAB Engine Dependency](#matlab-engine-dependency) below).
54
51
 
55
52
  ## Usage
56
53
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "MNutils"
3
- version = "1.2.3"
3
+ version = "1.2.4"
4
4
  description = "Utility functions for multinuclear MR data processing"
5
5
  readme = "README.md"
6
6
  license = "BSD-3-Clause"
@@ -23,7 +23,6 @@ classifiers = [
23
23
  ]
24
24
  requires-python = ">=3.12"
25
25
  dependencies = [
26
- "dcm2niiw>=0.1.5",
27
26
  "ipython>=8.0",
28
27
  "loguru>=0.7.3",
29
28
  "mat73",
@@ -48,7 +47,6 @@ Repository = "https://github.com/bastigw/mnutils"
48
47
  Issues = "https://github.com/bastigw/mnutils/issues"
49
48
 
50
49
  [project.optional-dependencies]
51
- 2025a = ["matlabengine==25.1.*"]
52
50
  bet = [
53
51
  "hd-bet>=2.0.1",
54
52
  "torch>=2.10.0",
@@ -62,7 +60,7 @@ test-mnutils-gen = "mnutils._scripts:generate_test_notebooks"
62
60
  test-mnutils = "mnutils._scripts:run_tests"
63
61
 
64
62
  [build-system]
65
- requires = ["uv_build>=0.8.13,<0.13.0"]
63
+ requires = ["uv_build>=0.8.13,<0.14.0"]
66
64
  build-backend = "uv_build"
67
65
 
68
66
  [dependency-groups]
@@ -78,10 +76,11 @@ dev = [
78
76
  "griffe<0.40.0",
79
77
  "jupyter-server>=2.17.0",
80
78
  "jupytext>=1.19.1",
79
+ "pytest-xdist>=3.8.0",
81
80
  ]
82
81
 
83
82
  [tool.pytest.ini_options]
84
- addopts = "--capture=no --nbmake"
83
+ addopts = "--nbmake -n auto -v --durations=0"
85
84
  testpaths = ["tests"]
86
85
 
87
86
  [tool.uv.sources.mat73]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "MNutils"
3
- version = "1.2.3"
3
+ version = "1.2.4"
4
4
 
5
5
  authors = [
6
6
  {name = "Sebastian Bauer", email = "28663646+bastigw@users.noreply.github.com"}
@@ -25,7 +25,6 @@ classifiers = [
25
25
  requires-python = ">=3.12"
26
26
 
27
27
  dependencies = [
28
- "dcm2niiw>=0.1.5",
29
28
  "ipython>=8.0",
30
29
  "loguru>=0.7.3",
31
30
  "mat73",
@@ -46,7 +45,6 @@ Repository = "https://github.com/bastigw/mnutils"
46
45
  Issues = "https://github.com/bastigw/mnutils/issues"
47
46
 
48
47
  [project.optional-dependencies]
49
- 2025a = ["matlabengine==25.1.*"]
50
48
  bet = ["hd-bet>=2.0.1", "torch>=2.10.0"]
51
49
 
52
50
  [project.scripts]
@@ -57,7 +55,7 @@ test-mnutils-gen = "mnutils._scripts:generate_test_notebooks" # docs/*.md -> te
57
55
  test-mnutils = "mnutils._scripts:run_tests" # test-gen, then run the full pytest suite
58
56
 
59
57
  [build-system]
60
- requires = ["uv_build>=0.8.13,<0.13.0"]
58
+ requires = ["uv_build>=0.8.13,<0.14.0"]
61
59
  build-backend = "uv_build"
62
60
 
63
61
  [dependency-groups]
@@ -77,10 +75,12 @@ dev = [
77
75
  "griffe<0.40.0", # Explicit pin to fix quartodoc parsing bug
78
76
  "jupyter-server>=2.17.0", # Required for rendering docs
79
77
  "jupytext>=1.19.1",
78
+ "pytest-xdist>=3.8.0",
80
79
  ]
81
80
 
82
81
  [tool.pytest.ini_options]
83
- addopts = "--capture=no --nbmake"
82
+ # See conftest.py for further details
83
+ addopts = "--nbmake -n auto -v --durations=0"
84
84
  testpaths = ["tests"]
85
85
 
86
86
  # [tool.uv]
@@ -16,6 +16,11 @@ from .utils import file_helpers
16
16
  ANATOMICAL_SERIES_TO_SKIP = ["FGRE", "localizer", "GE_HOS", "localiser"]
17
17
 
18
18
 
19
+ def _normalise_description(description: object) -> str:
20
+ """Lower-case, spaces as underscores: ``MRS washin`` -> ``mrs_washin``."""
21
+ return description.strip().lower().replace(" ", "_") if isinstance(description, str) else ""
22
+
23
+
19
24
  class ExamBase:
20
25
  """Base class for a GE exam, loading series metadata and providing series access."""
21
26
 
@@ -69,60 +74,45 @@ class ExamBase:
69
74
  """Iterate over loaded series as (series ID, series object) pairs."""
70
75
  yield from self.all.items()
71
76
 
77
+ def _ids_with_description(self, fragment: str) -> npt.NDArray[np.int_]:
78
+ """Series IDs whose normalised description contains `fragment` (also normalised)."""
79
+ normalised = self.exam_overview["Description"].map(_normalise_description)
80
+ mask = normalised.str.contains(_normalise_description(fragment), regex=False)
81
+ return self.exam_overview[mask].index.to_numpy().astype(int)
82
+
72
83
  def _create_series_dict(self) -> dict[int, type[GESeries.MRISeries]]:
84
+ """Pick a series class per series from its description, never from a folder name."""
73
85
  series_class_dict = {}
74
86
  for series_num in self.series:
75
- folder_name = self.exam_overview.at[series_num, "Folder Name"]
87
+ raw_description = self.exam_overview.at[series_num, "Description"]
88
+ description = _normalise_description(raw_description)
76
89
  has_exam = self.exam_overview.at[series_num, "Has Exam"]
77
- has_dicom = self.exam_overview.at[series_num, "Has DICOM"]
78
-
79
- if has_exam and not has_dicom:
80
- logger.debug(
81
- f"Series {series_num} does not have DICOM. Assuming it is an MRS series."
82
- )
83
- self.exam_overview.at[series_num, "Folder Name"] = "ASSUMING MRS"
84
- series_class_dict[series_num] = GESeries.MRSSeries
85
- continue
86
-
87
- # Make sure folder name is a string
88
- if isinstance(folder_name, str) and has_dicom:
89
- folder_name = folder_name.strip().lower()
90
- else:
91
- logger.warning(
92
- f"Folder name for series {series_num} is not a string: {folder_name}. Skipping."
93
- )
94
- continue
95
-
96
- if not has_exam and not has_dicom:
97
- logger.debug(f"Series {series_num} does not have exam or DICOM data. Skipping")
98
- continue
99
90
 
100
- if has_dicom and not has_exam:
101
- logger.trace(f"Series {series_num} does not have raw data.")
102
- # Based on the folder name determine if it is anatomical data we want to load
103
- if any(skip_name.lower() in folder_name for skip_name in ANATOMICAL_SERIES_TO_SKIP):
91
+ if not has_exam:
92
+ if any(skip_name.lower() in description for skip_name in ANATOMICAL_SERIES_TO_SKIP):
104
93
  logger.debug(f"Series {series_num} is anatomical data to skip.")
105
94
  continue
106
95
  series_class_dict[series_num] = GESeries.MRISeries
107
96
  continue
108
97
 
109
- if has_exam and has_dicom:
110
- logger.trace(
111
- f"Series {series_num} has both exam and DICOM data. Checking folder name."
98
+ if not description:
99
+ logger.debug(
100
+ f"Series {series_num} has no series description. Assuming it is an MRS series."
101
+ )
102
+ series_class_dict[series_num] = GESeries.MRSSeries
103
+ elif "mrs_washin" in description:
104
+ series_class_dict[series_num] = GESeries.MRSWashinSeries
105
+ elif "mrsi" in description:
106
+ series_class_dict[series_num] = GESeries.MRSISeries
107
+ elif "mrs_" in description:
108
+ series_class_dict[series_num] = GESeries.MRSSeries
109
+ elif "bs_prescan" in description:
110
+ continue # Skip the bs_prescan series
111
+ else:
112
+ logger.warning(
113
+ f"Series {series_num} with description '{raw_description}' does not "
114
+ f"match any known series types. Skipping."
112
115
  )
113
- if "mrs_washin" in folder_name:
114
- series_class_dict[series_num] = GESeries.MRSWashinSeries
115
- elif "mrsi_pseudo" in folder_name:
116
- series_class_dict[series_num] = GESeries.MRSISeries
117
- elif "mrs_" in folder_name:
118
- series_class_dict[series_num] = GESeries.MRSSeries
119
- elif "bs_prescan" in folder_name:
120
- continue # Skip the bs_prescan series
121
- else:
122
- logger.warning(
123
- f"Series {series_num} with folder name '{folder_name}' does not "
124
- f"match any known series types. Skipping."
125
- )
126
116
 
127
117
  return series_class_dict
128
118
 
@@ -190,33 +180,22 @@ class DMIExam(ExamBase):
190
180
  identifier = ANATOMICAL_series.strip().lower()
191
181
  else:
192
182
  identifier = "t1_bravo"
193
- self.ANATOMICAL_series = (
194
- self.exam_overview[
195
- self.exam_overview["Folder Name"].str.contains(identifier, na=False, case=False)
196
- ]
197
- .index.to_numpy()
198
- .astype(int)[0]
199
- )
183
+ self.ANATOMICAL_series = self._ids_with_description(identifier)[0]
200
184
 
201
- self.MRS_series = (
202
- self.exam_overview[
203
- self.exam_overview["Folder Name"].str.contains("MRS_unloc", na=False)
204
- ]
205
- .index.to_numpy()
206
- .astype(int)
207
- )
208
- # By default append all series where Has Dicom in the exam overview table is False
209
- no_DICOM_series = (
210
- self.exam_overview[~self.exam_overview["Has DICOM"]].index.to_numpy().astype(int)
185
+ self.MRS_series = self._ids_with_description("MRS_unloc")
186
+ # By default append all series without a NIfTI (raw data only)
187
+ no_nifti_series = (
188
+ self.exam_overview[~self.exam_overview["Has NIfTI"]].index.to_numpy().astype(int)
211
189
  )
212
- self.MRS_series = np.union1d(self.MRS_series, no_DICOM_series)
213
-
214
- self.MRSI_series = (
215
- self.exam_overview[
216
- self.exam_overview["Folder Name"].str.contains("MRSI_pseudo", na=False)
217
- ]
218
- .index.to_numpy()
219
- .astype(int)
190
+ self.MRS_series = np.union1d(self.MRS_series, no_nifti_series)
191
+
192
+ self.MRSI_series = np.array(
193
+ [
194
+ series_id
195
+ for series_id, series_class in self.series_dict.items()
196
+ if issubclass(series_class, GESeries.MRSISeries)
197
+ ],
198
+ dtype=int,
220
199
  )
221
200
 
222
201
  self.anatomical: GESeries.MRISeries
@@ -253,9 +232,9 @@ class DMIExam(ExamBase):
253
232
  logger.warning(
254
233
  f"Multiple anatomical datasets detected. "
255
234
  f"Previous: Series ID {self.anatomical.SERIES_ID}, "
256
- f"Name '{self.exam_overview.at[self.anatomical.SERIES_ID, 'Folder Name']}'."
235
+ f"Name '{self.exam_overview.at[self.anatomical.SERIES_ID, 'Description']}'."
257
236
  f" New: Series ID {series_obj.SERIES_ID}, "
258
- f"Name '{self.exam_overview.at[series_obj.SERIES_ID, 'Folder Name']}'."
237
+ f"Name '{self.exam_overview.at[series_obj.SERIES_ID, 'Description']}'."
259
238
  )
260
239
  self.anatomical = series_obj
261
240
 
@@ -333,13 +312,7 @@ class DMIinjExam(DMIExam):
333
312
  ) -> None:
334
313
  super().__init__(BASE_FOLDER, DATA_FOLDER, OUTPUT_FOLDER, ANATOMICAL_series)
335
314
 
336
- self.WASHIN_series = (
337
- self.exam_overview[
338
- self.exam_overview["Folder Name"].str.contains("MRS_washin", na=False)
339
- ]
340
- .index.to_numpy()
341
- .astype(int)[0]
342
- )
315
+ self.WASHIN_series = self._ids_with_description("MRS_washin")[0]
343
316
 
344
317
  # Define pre and post series based on wash-in series
345
318
  self.pre_MRS_series, self.post_MRS_series = self._split_series(
@@ -8,7 +8,6 @@ import h5py
8
8
  import numpy as np
9
9
  import numpy.typing as npt
10
10
  import pandas as pd
11
- import pyAMARES
12
11
  import xarray as xr
13
12
  import xmris # noqa: F401, necessary for xarray accessor to work
14
13
  from loguru import logger
@@ -283,7 +282,7 @@ class NiiBase:
283
282
 
284
283
 
285
284
  class MRISeries(NiiBase):
286
- """A reconstructed MRI series, loaded as a NIfTI from a scan's DATA_FOLDER."""
285
+ """A reconstructed MRI series, loaded as a NIfTI (found by its sidecar's SeriesNumber)."""
287
286
 
288
287
  def __init__(
289
288
  self,
@@ -295,9 +294,7 @@ class MRISeries(NiiBase):
295
294
  self.OUTPUT_FOLDER = (DATA_FOLDER / ".." / "output").resolve()
296
295
  self.SERIES_ID = SERIES_ID
297
296
  try:
298
- nii_path: Path = file_helpers.get_niftis_from_series(
299
- self.DATA_FOLDER, self.SERIES_ID, convert_dicoms=True
300
- )
297
+ nii_path: Path = file_helpers.get_niftis_from_series(self.DATA_FOLDER, self.SERIES_ID)
301
298
  super().__init__(nii_path, orientation)
302
299
  except (ValueError, FileNotFoundError) as e:
303
300
  logger.warning(
@@ -307,17 +304,71 @@ class MRISeries(NiiBase):
307
304
 
308
305
 
309
306
  class RawMRISeries(MRISeries):
310
- """An MRI series with the associated raw reconstructed .mat data loaded."""
307
+ """An MRI series with the associated raw reconstructed .mat data loaded.
311
308
 
312
- def __init__(self, DATA_FOLDER: Path, SERIES_ID: int) -> None:
309
+ The ``.mat`` is optional at construction: if none is found, a warning is logged and
310
+ `mat_path` is ``None``. Using the data (`recon`, `bb`, header properties) then raises.
311
+ Check `has_mat` first when a series may lack one.
312
+ """
313
+
314
+ def __init__(
315
+ self, DATA_FOLDER: Path, SERIES_ID: int, mat_path: str | Path | None = None
316
+ ) -> None:
313
317
  super().__init__(DATA_FOLDER, SERIES_ID)
314
- self.mat_path: Path = file_helpers.get_mat_data_from_series(
315
- self.DATA_FOLDER, self.SERIES_ID
318
+ self.mat_path: Path | None
319
+ self.header: dict
320
+ if mat_path is not None:
321
+ self.mat_path = Path(mat_path)
322
+ else:
323
+ try:
324
+ self.mat_path = file_helpers.get_mat_data_from_series(
325
+ self.DATA_FOLDER, self.SERIES_ID
326
+ )
327
+ except FileNotFoundError as e:
328
+ logger.warning(
329
+ f"Could not find a .mat file for series {self.SERIES_ID}. "
330
+ f"Proceeding without raw data.\nError: {e}"
331
+ )
332
+ self.mat_path = None
333
+ # Only the header fields MNUtils reads: a v7.3 header is ~1600 HDF5 datasets (~0.45 s).
334
+ self.header = (
335
+ data_loaders.read_mat_header(self.mat_path) if self.mat_path is not None else {}
316
336
  )
317
- self.recon: dict = data_loaders.load_mat_file(self.mat_path)
318
- self.header: dict = self.recon.get("h", {})
319
- self.bb = self.recon.get("bb", np.array([]))
320
- self.bbabs = self.recon.get("bbabs", np.abs(self.bb))
337
+
338
+ @property
339
+ def has_mat(self) -> bool:
340
+ """Whether this series has a ``.mat`` file to read raw data and header from."""
341
+ return self.mat_path is not None
342
+
343
+ def _require_mat(self) -> Path:
344
+ if self.mat_path is None:
345
+ raise FileNotFoundError(
346
+ f"Series {self.SERIES_ID} has no .mat file (looked in {self.DATA_FOLDER})."
347
+ )
348
+ return self.mat_path
349
+
350
+ @cached_property
351
+ def recon(self) -> dict:
352
+ """Every top-level variable of the ``.mat`` file, with ``h`` pruned to `self.header`.
353
+
354
+ Loaded on first access, not at construction, so reading only the header stays cheap.
355
+ The full ``h`` (~1600 datasets on v7.3) is skipped; use ``load_mat_file`` for it.
356
+ """
357
+ mat_path = self._require_mat()
358
+ names = [n for n in data_loaders.list_mat_variables(mat_path) if n != "h"]
359
+ recon = data_loaders.load_mat_file(mat_path, variable_names=names)
360
+ recon["h"] = self.header
361
+ return recon
362
+
363
+ @cached_property
364
+ def bb(self) -> np.ndarray:
365
+ """The reconstructed image, or an empty array if the file has none."""
366
+ return self.recon.get("bb", np.array([]))
367
+
368
+ @cached_property
369
+ def bbabs(self) -> np.ndarray:
370
+ """The magnitude image: ``bbabs`` from the file, else ``abs(bb)``."""
371
+ return self.recon.get("bbabs", np.abs(self.bb))
321
372
 
322
373
  @cached_property
323
374
  def fids(self) -> xr.DataArray:
@@ -597,8 +648,10 @@ class RawMRISeries(MRISeries):
597
648
  def _get_header_value(self, key_path: list[str]) -> None | str | np.double | npt.NDArray:
598
649
  """Traverse the header dictionary using a key path.
599
650
 
600
- Raises AttributeError if the path is invalid.
651
+ Raises AttributeError if the path is invalid, FileNotFoundError if the series has no
652
+ ``.mat`` (and so no header).
601
653
  """
654
+ self._require_mat()
602
655
  value = self.header
603
656
  try:
604
657
  for key in key_path:
@@ -632,11 +685,13 @@ class MRSISeries(RawMRISeries):
632
685
  ) -> None:
633
686
  super().__init__(DATA_FOLDER, SERIES_ID)
634
687
  # Expect spec and header to be present
635
- spec_data = self.recon.get("spec", np.ndarray([]))
688
+ spec_data = self.recon.get("spec", np.ndarray([])) if self.has_mat else np.empty(0)
636
689
  if not isinstance(spec_data, np.ndarray) or spec_data.size == 0:
637
- logger.error(
638
- "Expected 'spec' to be a non-empty numpy array. Not initializing object properly"
639
- )
690
+ if self.has_mat: # without a .mat the missing data was already warned about
691
+ logger.error(
692
+ "Expected 'spec' to be a non-empty numpy array. "
693
+ "Not initializing object properly"
694
+ )
640
695
  else:
641
696
  self.spec: xr.DataArray = xr.DataArray(
642
697
  spec_data,
@@ -701,6 +756,8 @@ class MRSISeries(RawMRISeries):
701
756
  @cached_property
702
757
  def SNR_map(self) -> xr.DataArray:
703
758
  """The per-voxel SNR, computed from the FIDs via pyAMARES.fidSNR."""
759
+ import pyAMARES
760
+
704
761
  return xr.apply_ufunc(
705
762
  pyAMARES.fidSNR, self.fids, input_core_dims=[["time"]], vectorize=True
706
763
  )
@@ -1264,11 +1321,13 @@ class MRSSeries(RawMRISeries):
1264
1321
 
1265
1322
  def __init__(self, DATA_FOLDER: Path, SERIES_ID: int) -> None:
1266
1323
  super().__init__(DATA_FOLDER, SERIES_ID)
1267
- spec_data = self.recon.get("spec", np.ndarray([]))
1324
+ spec_data = self.recon.get("spec", np.ndarray([])) if self.has_mat else np.empty(0)
1268
1325
  if not isinstance(spec_data, np.ndarray) or spec_data.size == 0:
1269
- logger.error(
1270
- "Expected 'spec' to be a non-empty numpy array. Not initializing object properly"
1271
- )
1326
+ if self.has_mat: # without a .mat the missing data was already warned about
1327
+ logger.error(
1328
+ "Expected 'spec' to be a non-empty numpy array. "
1329
+ "Not initializing object properly"
1330
+ )
1272
1331
  else:
1273
1332
  # This assumes no zero filling was applied yet
1274
1333
  self.npts: int = spec_data.shape[1]
@@ -8,6 +8,11 @@ reachable without knowing the internal module layout::
8
8
  exam = mnutils.DMIExam("/data/exam")
9
9
  mnutils.plotting.images.display_images(exam.anatomical[0].data)
10
10
 
11
+ Package-wide defaults live in `mnutils.rcParams`, which works like
12
+ matplotlib's::
13
+
14
+ mnutils.rcParams["image.cmap"] = "viridis"
15
+
11
16
  `mnutils.matlab` is deliberately *not* imported here: it needs the optional
12
17
  `matlabengine` dependency, so import it explicitly when you need it.
13
18
  """
@@ -24,6 +29,7 @@ from .GESeries import (
24
29
  NiiBase,
25
30
  RawMRISeries,
26
31
  )
32
+ from .rcparams import RcParams, rc_context, rc_presets, rcdefaults, rcParams, rcParamsDefault
27
33
 
28
34
  try:
29
35
  __version__ = version("mnutils")
@@ -50,5 +56,12 @@ __all__ = [
50
56
  "MRSISeries",
51
57
  "MRSSeries",
52
58
  "MRSWashinSeries",
59
+ # configuration
60
+ "RcParams",
61
+ "rcParams",
62
+ "rcParamsDefault",
63
+ "rc_context",
64
+ "rc_presets",
65
+ "rcdefaults",
53
66
  "__version__",
54
67
  ]
@@ -11,6 +11,7 @@ import re
11
11
  import shutil
12
12
  import subprocess
13
13
  import sys
14
+ from concurrent.futures import ThreadPoolExecutor
14
15
  from pathlib import Path
15
16
 
16
17
 
@@ -124,7 +125,24 @@ def docs_all() -> None:
124
125
  # chapter, mirrored into tests/autogen_notebooks/<chapter>/. Keep in sync with
125
126
  # GENRES in .claude/skills/docs-page/check_docs.py -- that file decides which
126
127
  # house rules a chapter's pages are held to, this one decides which get run.
127
- TEST_CHAPTERS = ("basics", "data-model", "plotting", "fitting", "nifti", "matlab")
128
+ # `matlab` is deliberately excluded: its pages need a live MATLAB install and
129
+ # a matching `matlabengine` version (see CLAUDE.md's Gotchas), which no CI or
130
+ # contributor machine can be assumed to have -- it isn't run by default.
131
+ TEST_CHAPTERS = ("basics", "data-model", "plotting", "fitting", "nifti")
132
+
133
+
134
+ def _convert_to_notebook(md_file: Path, out_dir: Path) -> None:
135
+ subprocess.run(
136
+ [
137
+ "jupytext",
138
+ "--to",
139
+ "notebook",
140
+ "--output",
141
+ str(out_dir / f"{md_file.stem}.ipynb"),
142
+ str(md_file),
143
+ ],
144
+ check=True,
145
+ )
128
146
 
129
147
 
130
148
  def generate_test_notebooks() -> None:
@@ -135,6 +153,11 @@ def generate_test_notebooks() -> None:
135
153
  chapter `index.md`, are skipped rather than failing with no kernel to
136
154
  start). Output goes under tests/autogen_notebooks/, which is gitignored --
137
155
  regenerate it before running pytest, never commit it.
156
+
157
+ Each page is its own `jupytext` subprocess (~0.3-0.5s of interpreter
158
+ startup apiece) with nothing shared between them, so they run in a thread
159
+ pool -- the GIL doesn't matter here since every thread spends its time
160
+ blocked in `subprocess.run`, not executing Python.
138
161
  """
139
162
  project_root = _find_project_root()
140
163
  docs_dir = project_root / "docs"
@@ -143,26 +166,21 @@ def generate_test_notebooks() -> None:
143
166
  if out_root.exists():
144
167
  shutil.rmtree(out_root)
145
168
 
169
+ jobs: list[tuple[Path, Path]] = []
146
170
  for chapter in TEST_CHAPTERS:
147
171
  chapter_dir = docs_dir / chapter
148
172
  if not chapter_dir.is_dir():
149
173
  continue
174
+ out_dir = out_root / chapter
150
175
  for md_file in sorted(chapter_dir.glob("*.md")):
151
- if not _is_executable_page(md_file):
152
- continue
153
- out_dir = out_root / chapter
154
- out_dir.mkdir(parents=True, exist_ok=True)
155
- subprocess.run(
156
- [
157
- "jupytext",
158
- "--to",
159
- "notebook",
160
- "--output",
161
- str(out_dir / f"{md_file.stem}.ipynb"),
162
- str(md_file),
163
- ],
164
- check=True,
165
- )
176
+ if _is_executable_page(md_file):
177
+ jobs.append((md_file, out_dir))
178
+
179
+ for out_dir in {out_dir for _, out_dir in jobs}:
180
+ out_dir.mkdir(parents=True, exist_ok=True)
181
+
182
+ with ThreadPoolExecutor() as pool:
183
+ list(pool.map(lambda job: _convert_to_notebook(*job), jobs))
166
184
 
167
185
 
168
186
  def _is_executable_page(md_file: Path) -> bool:
@@ -0,0 +1,16 @@
1
+ """Spectral fitting, delegated to pyAMARES through a thin wrapper."""
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ if TYPE_CHECKING:
6
+ from . import AMARES
7
+
8
+ __all__ = ["AMARES"]
9
+
10
+
11
+ def __getattr__(name: str):
12
+ if name == "AMARES":
13
+ from . import AMARES
14
+
15
+ return AMARES
16
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")