MNutils 1.2.0__py3-none-any.whl

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 (41) hide show
  1. mnutils/GEExam.py +495 -0
  2. mnutils/GESeries.py +1578 -0
  3. mnutils/__init__.py +54 -0
  4. mnutils/_scripts.py +177 -0
  5. mnutils/fitting/AMARES.py +831 -0
  6. mnutils/fitting/__init__.py +5 -0
  7. mnutils/matlab/__init__.py +13 -0
  8. mnutils/matlab/setup_matlab.py +116 -0
  9. mnutils/plotting/__init__.py +38 -0
  10. mnutils/plotting/_widgets/__init__.py +5 -0
  11. mnutils/plotting/_widgets/_html.py +34 -0
  12. mnutils/plotting/_widgets/_shared/__init__.py +50 -0
  13. mnutils/plotting/_widgets/_shared/dom.js +124 -0
  14. mnutils/plotting/_widgets/_shared/theme.css +140 -0
  15. mnutils/plotting/_widgets/image_grid.css +202 -0
  16. mnutils/plotting/_widgets/image_grid.js +212 -0
  17. mnutils/plotting/_widgets/image_grid.py +86 -0
  18. mnutils/plotting/_widgets/mrsi_inspector.css +376 -0
  19. mnutils/plotting/_widgets/mrsi_inspector.js +681 -0
  20. mnutils/plotting/_widgets/mrsi_inspector.py +70 -0
  21. mnutils/plotting/_widgets/slice_viewer.css +38 -0
  22. mnutils/plotting/_widgets/slice_viewer.js +41 -0
  23. mnutils/plotting/_widgets/slice_viewer.py +35 -0
  24. mnutils/plotting/images.py +1399 -0
  25. mnutils/plotting/saving.py +138 -0
  26. mnutils/plotting/spectra.py +357 -0
  27. mnutils/py.typed +0 -0
  28. mnutils/testing/__init__.py +3 -0
  29. mnutils/testing/_spectra.py +338 -0
  30. mnutils/testing/synthetic.py +373 -0
  31. mnutils/utils/__init__.py +25 -0
  32. mnutils/utils/data_loaders.py +218 -0
  33. mnutils/utils/file_helpers.py +708 -0
  34. mnutils/utils/images.py +202 -0
  35. mnutils/utils/nifti.py +379 -0
  36. mnutils/utils/spectra.py +149 -0
  37. mnutils-1.2.0.dist-info/METADATA +161 -0
  38. mnutils-1.2.0.dist-info/RECORD +41 -0
  39. mnutils-1.2.0.dist-info/WHEEL +4 -0
  40. mnutils-1.2.0.dist-info/entry_points.txt +7 -0
  41. mnutils-1.2.0.dist-info/licenses/LICENSE +28 -0
@@ -0,0 +1,373 @@
1
+ """Synthetic GE exam fixtures for docs pages and tests -- no real scanner data required.
2
+
3
+ See docs/diary/2026-08-19-synthetic-exam-fixtures.md for why this exists, what it does and doesn't
4
+ fake, and where the spectra/anatomy come from. `build_fake_exam(dataset)` writes the requested
5
+ dataset under a process-local temp directory (via `tempfile.mkdtemp`) the first time it's called,
6
+ caches each dataset's path for the rest of the process, and registers an `atexit` cleanup so
7
+ nothing survives past the process that created it.
8
+
9
+ `_TEMPLATE_ATTRIBUTION` in `_spectra.py` covers the one piece of real (downloaded, cached) data
10
+ used here -- a CC BY-NC 4.0 T1 template.
11
+ """
12
+
13
+ import atexit
14
+ import functools
15
+ import shutil
16
+ import tempfile
17
+ from collections.abc import Callable
18
+ from pathlib import Path
19
+
20
+ import h5py
21
+ import nibabel as nib
22
+ import numpy as np
23
+ import scipy.io
24
+ import xmris
25
+ from numpy.typing import ArrayLike
26
+
27
+ from . import _spectra
28
+
29
+ _RNG_SEED = 20260819
30
+
31
+
32
+ def _mat_header(
33
+ *,
34
+ nucleus: float,
35
+ protocol: str,
36
+ description: str,
37
+ exam_number: int,
38
+ bandwidth: float,
39
+ centre_freq_x1e7: float,
40
+ sctime: float = 60_000_000.0,
41
+ fov_mm: float,
42
+ ) -> dict:
43
+ """Build the nested dict `RawMRISeries._get_header_value` walks (`h.rdb_hdr.*` etc.)."""
44
+ return {
45
+ "rdb_hdr": {
46
+ "data_collect_type": 2.0,
47
+ "run_int": 1.0,
48
+ "nframes": 8.0,
49
+ "user0": bandwidth,
50
+ "ps_mps_freq": centre_freq_x1e7,
51
+ "te": 30000.0,
52
+ "fov": fov_mm,
53
+ "user25": 0.0,
54
+ "user14": 0.0,
55
+ "user3": 0.0,
56
+ "ps_mps_r1": 100.0,
57
+ "ps_mps_r2": 100.0,
58
+ "ps_mps_tg": 100.0,
59
+ "recon": 1.0,
60
+ "scan_date": "01/01/25",
61
+ "scan_time": "12:00",
62
+ },
63
+ "series": {"prtcl": protocol, "se_desc": description},
64
+ "exam": {"ex_no": exam_number},
65
+ "image": {
66
+ "specnuc": nucleus,
67
+ "mr_flip": 90.0,
68
+ "nex": 1.0,
69
+ "tr": 2_000_000.0,
70
+ "slthick": 5.0,
71
+ "sctime": sctime,
72
+ },
73
+ "mrconfig": {"fieldStrength": 30_000.0},
74
+ }
75
+
76
+
77
+ # Deuterium at ~3T. Kept in one place so the .mat header, the ppm axis GESeries derives from it,
78
+ # and the peaks xmris simulates all agree on what "4.7 ppm" means.
79
+ _NUCLEUS = 2.0
80
+ _BANDWIDTH_HZ = 5000.0
81
+ _CENTRE_FREQ_X1E7 = 1.9612e8 # -> 80.0 MHz via the `centre_frequency` property
82
+ _CENTRE_FREQ_MHZ = _CENTRE_FREQ_X1E7 / 1e7
83
+ _CARRIER_PPM = 4.68 # matches GESeries.RawMRISeries.carrier_ppm for nucleus in {1, 2}
84
+
85
+
86
+ def _rng(*salt: object) -> np.random.Generator:
87
+ return np.random.default_rng(abs(hash((_RNG_SEED, *salt))) % (2**32))
88
+
89
+
90
+ def _write_own_nii(path: Path, data: np.ndarray, affine: np.ndarray) -> None:
91
+ path.parent.mkdir(parents=True, exist_ok=True)
92
+ nib.save(nib.Nifti1Image(data.astype(np.float32), affine), path)
93
+
94
+
95
+ def _write_mat(path: Path, *, spec: np.ndarray, fov_mm: float, seed: int, **header_kwargs) -> None:
96
+ recon = {
97
+ "h": _mat_header(fov_mm=fov_mm, **header_kwargs),
98
+ "bb": _rng(seed, "bb").standard_normal((4, 4)),
99
+ "spec": spec,
100
+ }
101
+ path.parent.mkdir(parents=True, exist_ok=True)
102
+ scipy.io.savemat(path, recon)
103
+
104
+
105
+ def _write_mrs_mat(
106
+ path: Path,
107
+ *,
108
+ series_id: int,
109
+ averages: int,
110
+ npts: int,
111
+ seed: int,
112
+ intensities: ArrayLike | None = None,
113
+ fov_mm: float = 240.0,
114
+ **header_kwargs,
115
+ ) -> None:
116
+ """Write the `.mat` spectrum and, from the same simulated peaks, the raw FID `.h5` cache.
117
+
118
+ Every real MRS acquisition has both, so every synthetic one does too -- `simulate_fid`
119
+ returns the raw time-domain FID `xr.DataArray`, which we convert both ways from a single
120
+ simulation instead of writing an independently-random raw-FID array.
121
+ """
122
+ fid_da = _spectra.simulate_fid(
123
+ npts=npts,
124
+ n_specs=averages,
125
+ bandwidth=_BANDWIDTH_HZ,
126
+ seed=seed,
127
+ intensities=intensities,
128
+ )
129
+ fid = np.asarray(fid_da.values, dtype=complex)
130
+ spec = np.asarray(xmris.to_spectrum(fid_da).values, dtype=complex)
131
+ _write_mat(
132
+ path,
133
+ spec=spec,
134
+ fov_mm=fov_mm,
135
+ seed=seed,
136
+ nucleus=_NUCLEUS,
137
+ bandwidth=_BANDWIDTH_HZ,
138
+ centre_freq_x1e7=_CENTRE_FREQ_X1E7,
139
+ **header_kwargs,
140
+ )
141
+ # In mrs data the fids are chopped. IE every other fid is multiplied by -1
142
+ # Fake this too
143
+ fid[1::2, :] *= -1
144
+ # And the data is complex conjugated
145
+ fid = np.conjugate(fid)
146
+
147
+ _write_raw_fids_h5(path.parent, series_id, fid)
148
+
149
+
150
+ def _write_mrsi_mat(
151
+ path: Path,
152
+ *,
153
+ npts: int,
154
+ grid: tuple[int, int, int],
155
+ fov_mm: float,
156
+ seed: int,
157
+ intensity_map: np.ndarray | None = None,
158
+ **header_kwargs,
159
+ ) -> None:
160
+ if intensity_map is None:
161
+ intensity_map = np.full(grid, 0.5, dtype=np.float32)
162
+ spec = _spectra.simulate_grid(
163
+ npts=npts,
164
+ grid=grid,
165
+ bandwidth=_BANDWIDTH_HZ,
166
+ centre_freq_mhz=_CENTRE_FREQ_MHZ,
167
+ carrier_ppm=_CARRIER_PPM,
168
+ intensity_map=intensity_map,
169
+ seed=seed,
170
+ )
171
+ _write_mat(
172
+ path,
173
+ spec=spec,
174
+ fov_mm=fov_mm,
175
+ seed=seed,
176
+ nucleus=_NUCLEUS,
177
+ bandwidth=_BANDWIDTH_HZ,
178
+ centre_freq_x1e7=_CENTRE_FREQ_X1E7,
179
+ **header_kwargs,
180
+ )
181
+
182
+
183
+ def _write_raw_fids_h5(series_folder: Path, series_id: int, fids: np.ndarray) -> None:
184
+ """A dummy `ScanArchive*.h5` (only its existence matters) plus a pre-cached `fids` dataset.
185
+
186
+ `load_raw_fids` reads the cache directly when present and never opens the archive -- see the
187
+ diary entry.
188
+ """
189
+ series_folder.mkdir(parents=True, exist_ok=True)
190
+ (series_folder / f"ScanArchive_{series_id}.h5").touch()
191
+ with h5py.File(series_folder / f"Series{series_id}_raw_fids.h5", "w") as hf:
192
+ hf.create_dataset("fids", data=fids)
193
+
194
+
195
+ def _write_raw_fid_files(series_folder: Path, series_id: int, npts: int, ntime: int = 150) -> None:
196
+ """Random-noise raw FIDs for series with no `.mat`-backed spectrum to derive real ones from."""
197
+ fids = _rng(series_folder, "fids").standard_normal((npts, ntime)) + 1j * _rng(
198
+ series_folder, "fids-imag"
199
+ ).standard_normal((npts, ntime))
200
+ _write_raw_fids_h5(series_folder, series_id, fids)
201
+
202
+
203
+ def _build_brain_mrs_mrsi_exam(root: Path) -> None:
204
+ data = root / "data"
205
+ exam = data / "ExamHeVo18anon"
206
+ grid = (16, 16, 16)
207
+
208
+ # The real T1 template, cropped to the head -- see _spectra._TEMPLATE_ATTRIBUTION.
209
+ _spectra.write_template_t1(data / "002_3D_Ax_T1_BRAVO" / "2_3D_Ax_T1_BRAVO.nii.gz")
210
+
211
+ # Series 8's own NIfTI seeds create_MRSI_nii/create_MRSI_affine *and* doubles as the
212
+ # per-grid-cell intensity map below -- real brain signal in, brain-shaped spectra out.
213
+ own_affine, intensity_map = _spectra.template_grid_intensity(grid)
214
+ _write_own_nii(
215
+ data / "008_MRSI_pseudo_S64_X10_Y10_Z10_T1_C1" / "8_MRSI_pseudo.nii.gz",
216
+ intensity_map * 200,
217
+ own_affine,
218
+ )
219
+ # create_MRSI_affine() rescales x/y from the header's fov_mm/mtx[0] against own_affine's x
220
+ # voxel size -- derive fov_mm from that same affine so the two agree (a mismatched hardcoded
221
+ # value here inflates the MRSI grid relative to the real brain extent it's drawn over).
222
+ fov_mm = own_affine[0, 0] * grid[0]
223
+ (data / "014_localizer").mkdir(parents=True, exist_ok=True) # DICOM-only gap filler
224
+
225
+ _write_mrs_mat(
226
+ exam / "Series6" / "ScanArchive_Series6.mat",
227
+ series_id=6,
228
+ averages=64,
229
+ npts=2048,
230
+ seed=6,
231
+ protocol="MRS_unloc",
232
+ description="006_MRS_unloc",
233
+ exam_number=1801,
234
+ )
235
+
236
+ # Create increasing intensities for the washin series
237
+ # Defines start values [HDO, Glucose, Glx, Baseline] and their stop values
238
+ intensities = np.linspace(start=[1.0, 0.4, 0.0, 1.0], stop=[1.2, 3.0, 0.6, 1.0], num=250)
239
+
240
+ _write_mrs_mat(
241
+ exam / "Series7" / "ScanArchive_Series7.mat",
242
+ series_id=7,
243
+ averages=250,
244
+ intensities=intensities,
245
+ npts=2048,
246
+ seed=7,
247
+ protocol="MRS_washin",
248
+ description="007_MRS_washin",
249
+ exam_number=1801,
250
+ sctime=300_000_000.0,
251
+ )
252
+
253
+ _write_mrsi_mat(
254
+ exam / "Series8" / "ScanArchive_Series8.mat",
255
+ npts=700,
256
+ grid=grid,
257
+ fov_mm=fov_mm,
258
+ seed=8000,
259
+ intensity_map=intensity_map,
260
+ protocol="MRSI_pseudo",
261
+ description="008_MRSI_pseudo_S64_X10_Y10_Z10_T1_C1",
262
+ exam_number=1801,
263
+ )
264
+
265
+ for series_id, npts in ((9, 700), (11, 700), (12, 1678)):
266
+ _write_raw_fid_files(exam / f"Series{series_id}", series_id, npts=npts)
267
+
268
+ (exam / "Series13").mkdir(parents=True, exist_ok=True) # exam data, no DICOM
269
+
270
+
271
+ def _build_brain_extraction_exam(root: Path) -> None:
272
+ data = root / "data"
273
+ (data / "Series0010_BS_prescan").mkdir(parents=True, exist_ok=True) # new-style naming
274
+ _spectra.write_template_t1(data / "002_3D_Ax_T1_BRAVO" / "2_3D_Ax_T1_BRAVO_BODY.nii.gz")
275
+
276
+
277
+ def _build_mrsi_missing_series_exam(root: Path) -> None:
278
+ exam = root / "Exam4873anon"
279
+ _write_mrsi_mat(
280
+ exam / "Series7" / "ScanArchive_Series7.mat",
281
+ npts=700,
282
+ grid=(16, 16, 16),
283
+ fov_mm=300.0,
284
+ seed=7700,
285
+ protocol="MRSI_pseudo",
286
+ description="007_MRSI_pseudo",
287
+ exam_number=4873,
288
+ )
289
+ (exam / "Series5").mkdir(parents=True, exist_ok=True) # no .mat -- FileNotFoundError case
290
+ (root / "010_axial_localizer").mkdir(parents=True, exist_ok=True) # old-style naming
291
+
292
+
293
+ def _build_nist_phantom_exam(root: Path) -> None:
294
+ data = root / "data"
295
+ exam = data / "ExamNISTanon"
296
+ grid = (16, 16, 16)
297
+ fov_mm = 300.0 # -> 14 mm in-plane voxels, comfortably resolves the 8-sphere ring
298
+ z_extent_mm = 300.0
299
+ rings = _spectra.DEFAULT_SPHERE_RINGS
300
+
301
+ _spectra.sphere_phantom_image(
302
+ data / "002_3D_Ax_T1_BRAVO" / "2_3D_Ax_T1_BRAVO.nii.gz",
303
+ shape=(128, 128, 100),
304
+ voxel_mm=1,
305
+ rings=rings,
306
+ seed=2,
307
+ )
308
+
309
+ intensity_map, voxel_xy = _spectra.sphere_grid_intensity(
310
+ grid, fov_mm=fov_mm, z_extent_mm=z_extent_mm, rings=rings
311
+ )
312
+ own_affine = _spectra.grid_own_affine(grid, (voxel_xy, voxel_xy, z_extent_mm / grid[2]))
313
+ _write_own_nii(
314
+ data / "005_MRSI_pseudo_S64_X12_Y12_Z3_T1_C1" / "5_MRSI_pseudo.nii.gz",
315
+ intensity_map * 200,
316
+ own_affine,
317
+ )
318
+
319
+ _write_mrsi_mat(
320
+ exam / "Series5" / "ScanArchive_Series5.mat",
321
+ npts=700,
322
+ grid=grid,
323
+ fov_mm=fov_mm,
324
+ seed=5500,
325
+ intensity_map=intensity_map * 500,
326
+ protocol="MRSI_pseudo",
327
+ description="005_MRSI_pseudo_S64_X12_Y12_Z3_T1_C1",
328
+ exam_number=408,
329
+ )
330
+
331
+
332
+ def _write_legacy_mat(path: Path) -> None:
333
+ """A genuine MATLAB v7.3 (HDF5) file -- `mat73`'s fallback path, not `scipy.io.loadmat`."""
334
+ path.parent.mkdir(parents=True, exist_ok=True)
335
+ with h5py.File(path, "w") as hf:
336
+ dataset = hf.create_dataset("pyr", data=_rng(path, "pyr").standard_normal(64))
337
+ dataset.attrs["MATLAB_class"] = np.bytes_(b"double")
338
+
339
+
340
+ _DATASETS: dict[str, tuple[Callable[[Path], None], str]] = {
341
+ "brain_mrs_mrsi_exam": (_build_brain_mrs_mrsi_exam, "brain_mrs_mrsi_exam"),
342
+ "brain_extraction_exam": (_build_brain_extraction_exam, "brain_extraction_exam"),
343
+ "mrsi_missing_series_exam": (_build_mrsi_missing_series_exam, "mrsi_missing_series_exam"),
344
+ "nist_phantom_exam": (_build_nist_phantom_exam, "nist_phantom_exam"),
345
+ "legacy_matlab73_example": (_write_legacy_mat, "legacy_matlab73_example.mat"),
346
+ }
347
+
348
+
349
+ @functools.cache
350
+ def _fake_exam_root() -> Path:
351
+ root = Path(tempfile.mkdtemp(prefix="mnutils_fake_exam_"))
352
+ atexit.register(shutil.rmtree, root, ignore_errors=True)
353
+ return root
354
+
355
+
356
+ @functools.cache
357
+ def build_fake_exam(dataset: str) -> Path:
358
+ """Build (on first call) and return the path to one synthetic dataset.
359
+
360
+ `dataset` is one of `_DATASETS`' keys -- the same names and shapes docs pages and tests
361
+ previously read from `tests/datasets/`, entirely fabricated (see the diary entry for what's
362
+ simulated vs. downloaded). Every dataset lives under one shared `tempfile.mkdtemp` directory
363
+ (created on first call to any dataset) removed by an `atexit` hook when the process exits, so
364
+ nothing accumulates across runs. Each dataset is built at most once per process.
365
+ """
366
+ if dataset not in _DATASETS:
367
+ raise ValueError(
368
+ f"Unknown fake-exam dataset {dataset!r}; choose one of {sorted(_DATASETS)}"
369
+ )
370
+ builder, filename = _DATASETS[dataset]
371
+ path = _fake_exam_root() / filename
372
+ builder(path)
373
+ return path
@@ -0,0 +1,25 @@
1
+ """Loading, file discovery, NIfTI and spectral helpers."""
2
+
3
+ from . import data_loaders, file_helpers, images, nifti, spectra
4
+ from .data_loaders import load_mat_file
5
+ from .file_helpers import (
6
+ get_exam_folder,
7
+ get_h5_data_from_series,
8
+ get_mat_data_from_series,
9
+ )
10
+ from .spectra import calculate_ppm_axis
11
+
12
+ __all__ = [
13
+ # modules
14
+ "data_loaders",
15
+ "file_helpers",
16
+ "images",
17
+ "nifti",
18
+ "spectra",
19
+ # frequently used helpers
20
+ "calculate_ppm_axis",
21
+ "get_exam_folder",
22
+ "get_h5_data_from_series",
23
+ "get_mat_data_from_series",
24
+ "load_mat_file",
25
+ ]
@@ -0,0 +1,218 @@
1
+ import getpass
2
+ from datetime import datetime
3
+ from pathlib import Path
4
+
5
+ import h5py
6
+ import numpy as np
7
+ from loguru import logger
8
+
9
+ from . import file_helpers
10
+
11
+
12
+ def load_mat_file(file_path: Path | str) -> dict:
13
+ """Load a .mat file and return its contents as a dictionary.
14
+
15
+ Transparently falls back from `scipy.io.loadmat` (plain v5/v7 files) to
16
+ `mat73` (v7.3, HDF5-based files) when the former can't read the file.
17
+
18
+ Parameters
19
+ ----------
20
+ file_path : Path or str
21
+ Path to the .mat file.
22
+
23
+ Returns
24
+ -------
25
+ dict
26
+ Contents of the .mat file.
27
+
28
+ Raises
29
+ ------
30
+ FileNotFoundError
31
+ If `file_path` does not exist or is not a file.
32
+ ValueError
33
+ If `file_path` does not have a `.mat` extension.
34
+ RuntimeError
35
+ If the file can't be loaded by either scipy or mat73.
36
+ """
37
+ file_path = Path(file_path)
38
+ if not file_path.is_file():
39
+ raise FileNotFoundError(
40
+ f"The specified path does not exist or is not a file: '{file_path}'"
41
+ )
42
+ if file_path.suffix.lower() != ".mat":
43
+ raise ValueError(f"The file does not have a .mat extension: '{file_path}'")
44
+
45
+ try:
46
+ # Try loading with scipy.io.loadmat first
47
+ return _loadmat(file_path)
48
+ except (NotImplementedError, ValueError):
49
+ # If it fails, try loading with mat73 (for v7.3 .mat files)
50
+ try:
51
+ import mat73
52
+
53
+ return mat73.loadmat(file_path)
54
+ except ImportError as e:
55
+ raise RuntimeError(
56
+ "mat73 is required to read MATLAB v7.3 files but could not be imported."
57
+ ) from e
58
+ except OSError as e:
59
+ logger.error(f"Failed to load .mat file with both scipy and mat73: {e}")
60
+ raise OSError(f"Failed to open .mat file with mat73: {e}")
61
+ except (TypeError, ValueError) as e:
62
+ raise RuntimeError(f"Unexpected error loading .mat file with mat73: {e}")
63
+ except (OSError, TypeError) as e:
64
+ logger.error(f"Failed to load .mat file with scipy: {e}.")
65
+ raise RuntimeError(f"Unexpected error loading .mat file with scipy: {e}")
66
+
67
+
68
+ def load_raw_fids(
69
+ DATA_FOLDER: Path | str,
70
+ series_id: int,
71
+ load_most_recent: bool = True,
72
+ force_override: bool = False,
73
+ ) -> tuple[np.ndarray, Path]:
74
+ """Load raw FIDs for a series, reading from a cached HDF5 file when available.
75
+
76
+ Parameters
77
+ ----------
78
+ DATA_FOLDER : Path or str
79
+ Path to the exam's data folder.
80
+ series_id : int
81
+ Series number to load raw FIDs for.
82
+ load_most_recent : bool
83
+ If True, return the most recently created cached `*_raw_fids.h5` file
84
+ instead of re-reading the scan archive.
85
+ force_override : bool
86
+ If True, delete and regenerate an existing output HDF5 file rather than
87
+ returning its cached contents.
88
+
89
+ Returns
90
+ -------
91
+ tuple[np.ndarray, Path]
92
+ The complex raw FIDs array and the path to the HDF5 file it was
93
+ loaded from (or written to).
94
+ """
95
+ scan_archive_file = file_helpers.get_h5_data_from_series(DATA_FOLDER, series_id)
96
+ series_folder = file_helpers.get_exam_folder(DATA_FOLDER) / f"Series{series_id}"
97
+
98
+ # Find if there is already an h5 file with raw fids
99
+ if load_most_recent:
100
+ existing_files = list(series_folder.glob(f"Series{series_id}_raw_fids.h5"))
101
+ if existing_files:
102
+ # Get the most recent file based on creation time
103
+ output_h5_file = max(existing_files, key=lambda f: f.stat().st_ctime)
104
+ logger.debug(f"Found existing raw FIDs file: {output_h5_file}. Loading data from it.")
105
+ with h5py.File(output_h5_file, "r") as hf:
106
+ data = np.array(hf["fids"])
107
+ return data, output_h5_file
108
+ else:
109
+ logger.debug(
110
+ f"No existing raw FIDs file found in {series_folder}. Will create a new one."
111
+ )
112
+
113
+ output_h5_file = series_folder / f"Series{series_id}_raw_fids.h5"
114
+
115
+ logger.debug(f"Loading raw FIDs from {scan_archive_file} into {output_h5_file}")
116
+
117
+ if output_h5_file.exists():
118
+ if not force_override:
119
+ logger.warning(f"Output file {output_h5_file} already exists. Returning data from it.")
120
+ with h5py.File(output_h5_file, "r") as hf:
121
+ data = np.array(hf["fids"])
122
+ return data, output_h5_file
123
+ else:
124
+ logger.warning(
125
+ f"Output file {output_h5_file} already exists. "
126
+ "Overriding as force_override is True."
127
+ )
128
+ output_h5_file.unlink()
129
+
130
+ from .. import matlab
131
+
132
+ matengine = matlab.connect_to_matlab()
133
+ matlab.setup_util_path(matengine)
134
+ matlab.add_matlablatest_path(matengine)
135
+ matengine.manage_paths("ORCHESTRASDK", True, nargout=0)
136
+ data = matengine.read_archive(str(scan_archive_file), 0, nargout=1)
137
+ matengine.quit()
138
+ logger.debug(
139
+ f"Raw data loaded from MATLAB engine, type: {type(data)}. Matlabengine session closed."
140
+ )
141
+ data = np.asarray(data)
142
+ # Validate data shape and type to be complex numpy array
143
+ if not isinstance(data, np.ndarray) or not np.iscomplexobj(data):
144
+ raise ValueError(f"Loaded data is not a complex numpy array. Got type: {type(data)}")
145
+
146
+ with h5py.File(output_h5_file, "w") as hf:
147
+ hf.create_dataset("fids", data=data)
148
+ hf.attrs["file_created_date"] = datetime.now().isoformat().replace("+00:00", "Z")
149
+ try:
150
+ hf.attrs["file_created_by"] = getpass.getuser()
151
+ except (OSError, KeyError):
152
+ hf.attrs["file_created_by"] = "unknown_user"
153
+
154
+ # Check if file exists
155
+ if not output_h5_file.exists():
156
+ raise FileNotFoundError(f"Output file {output_h5_file} was not created successfully.")
157
+
158
+ return data, output_h5_file
159
+
160
+
161
+ def _loadmat(filename: str | Path) -> dict:
162
+ """
163
+ Load a MATLAB .mat file and convert its contents into Python data structures.
164
+
165
+ This function wraps around `scipy.io.loadmat` to address issues with
166
+ recovering Python dictionaries from MATLAB .mat files. It ensures that
167
+ all entries, including those stored as MATLAB objects, are properly
168
+ converted to Python-native types by invoking the `_check_keys` function.
169
+
170
+ Args:
171
+ filename (str): The path to the .mat file to be loaded.
172
+
173
+ Returns
174
+ -------
175
+ dict: A dictionary containing the contents of the .mat file, with
176
+ MATLAB objects converted to Python-native types.
177
+ """
178
+ import scipy.io
179
+
180
+ data = scipy.io.loadmat(filename, struct_as_record=False, squeeze_me=True)
181
+ return _check_keys(data)
182
+
183
+
184
+ def _check_keys(data: dict) -> dict:
185
+ """Recursively convert MATLAB structs within a dictionary to Python dictionaries.
186
+
187
+ Iterates through the keys of the given dictionary and checks if any of the values
188
+ are MATLAB structs (from `scipy.io.matlab.mat_struct`). If a MATLAB struct is
189
+ found, it is converted to a Python dictionary using the `_todict` function.
190
+
191
+ Parameters
192
+ ----------
193
+ data : dict
194
+ The input dictionary to process.
195
+
196
+ Returns
197
+ -------
198
+ dict
199
+ The processed dictionary with MATLAB structs converted to Python dictionaries.
200
+ """
201
+ from scipy.io.matlab import mat_struct
202
+
203
+ for key in data:
204
+ if isinstance(data[key], mat_struct):
205
+ data[key] = _todict(data[key], mat_struct)
206
+ return data
207
+
208
+
209
+ def _todict(matobj, mat_struct_type: type) -> dict:
210
+ """Recursively construct nested dictionaries from a MATLAB struct object."""
211
+ out: dict = {}
212
+ for field_name in matobj._fieldnames:
213
+ elem = matobj.__dict__[field_name]
214
+ if isinstance(elem, mat_struct_type):
215
+ out[field_name] = _todict(elem, mat_struct_type)
216
+ else:
217
+ out[field_name] = elem
218
+ return out