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
mnutils/GESeries.py ADDED
@@ -0,0 +1,1578 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import date, datetime, time, timedelta
4
+ from functools import cached_property
5
+ from pathlib import Path
6
+
7
+ import h5py
8
+ import nibabel as nib
9
+ import numpy as np
10
+ import numpy.typing as npt
11
+ import pandas as pd
12
+ import pyAMARES
13
+ import xarray as xr
14
+ import xmris # noqa: F401, necessary for xarray accessor to work
15
+ from loguru import logger
16
+ from nibabel import nifti1, spatialimages
17
+
18
+ from . import fitting, plotting
19
+ from .utils import data_loaders, file_helpers, nifti, spectra
20
+
21
+
22
+ class NiiBase:
23
+ """Wraps a NIfTI image with orientation-aware loading, display, and I/O helpers."""
24
+
25
+ def __init__(
26
+ self,
27
+ nii: spatialimages.SpatialImage | Path,
28
+ orientation: tuple[str, str, str] = ("L", "P", "S"),
29
+ ) -> None:
30
+ if isinstance(nii, Path):
31
+ self.nii_path = nii
32
+ nii = nifti1.load(nii)
33
+ else:
34
+ self.nii_path = None
35
+ self.nii: spatialimages.SpatialImage = nii
36
+ self.orientation = orientation
37
+
38
+ @property
39
+ def affine(self) -> np.ndarray:
40
+ """The affine matrix of the NIfTI image."""
41
+ if self.nii.affine is not None:
42
+ return self.nii.affine
43
+ else:
44
+ raise ValueError("Affine matrix of the NIfTI image is None.")
45
+
46
+ def resample_self_to(
47
+ self, target_nii: spatialimages.SpatialImage | NiiBase, **kwargs
48
+ ) -> NiiBase:
49
+ """Return a new NiiBase with this image resampled onto target_nii's grid."""
50
+ # Create new object of with resampled data
51
+ if isinstance(target_nii, NiiBase):
52
+ target_nii = target_nii.nii
53
+ resampled_nii = nifti.resample_nifti(self.nii, target_nii, **kwargs)
54
+ return NiiBase(resampled_nii, orientation=self.orientation)
55
+
56
+ def images(
57
+ self,
58
+ orientation: tuple[str, str, str] | None = None,
59
+ display_plane: nifti.DISPLAY_PLANES | None = None,
60
+ caching: bool = True,
61
+ **kwargs,
62
+ ) -> npt.NDArray[np.float64]:
63
+ """Return the image data array, oriented per orientation or display_plane."""
64
+ if orientation is None and display_plane is None:
65
+ orientation = self.orientation
66
+ return nifti.orient_nifti(
67
+ self.nii,
68
+ orientation=orientation,
69
+ display_plane=display_plane,
70
+ caching=caching,
71
+ **kwargs,
72
+ )
73
+
74
+ def with_new_data(
75
+ self,
76
+ new_data: npt.NDArray | xr.DataArray,
77
+ new_affine: npt.NDArray | None = None,
78
+ ) -> NiiBase:
79
+ """Return a new NiiBase wrapping new_data, keeping this image's header/affine."""
80
+ if new_affine is None and new_data.shape != self.nii.shape:
81
+ # A change in shape is okay if the voxel sizes are different. Trust the
82
+ # user that a correct affine is provided
83
+ raise ValueError(
84
+ f"New data shape {new_data.shape} does not match original "
85
+ f"NIfTI shape {self.nii.shape}."
86
+ )
87
+
88
+ if new_affine is not None:
89
+ affine = new_affine
90
+ else:
91
+ if self.nii.affine is None:
92
+ raise ValueError(
93
+ "Original NIfTI affine is None, cannot create new NIfTI with new data."
94
+ "\nPass new_affine argument to specify affine for new NIfTI."
95
+ )
96
+ else:
97
+ affine = self.nii.affine
98
+
99
+ # Image data is to be oriented the following
100
+ orientated_data = nib.orientations.apply_orientation(
101
+ new_data, nib.orientations.axcodes2ornt(("P", "L", "S"))
102
+ )
103
+
104
+ new_nii = self.nii.__class__(
105
+ orientated_data, # pyright: ignore[reportArgumentType]
106
+ affine=affine.copy(),
107
+ header=self.nii.header,
108
+ extra=self.nii.extra,
109
+ file_map=self.nii.file_map,
110
+ )
111
+
112
+ new_nii.set_data_dtype(new_data.dtype)
113
+
114
+ return NiiBase(new_nii, orientation=self.orientation)
115
+
116
+ def apply_mask(self, mask: npt.NDArray | NiiBase) -> npt.NDArray:
117
+ """Return the image data with voxels outside mask set to NaN."""
118
+ images = self.images()
119
+ if isinstance(mask, NiiBase):
120
+ mask = mask.images()
121
+ if mask.shape != images.shape:
122
+ raise ValueError(f"Mask shape {mask.shape} does not match image shape {images.shape}.")
123
+ masked_images = np.where(mask, images, np.nan)
124
+ return masked_images
125
+
126
+ def display(
127
+ self,
128
+ orientation: tuple[str, str, str] | None = None,
129
+ display_plane: nifti.DISPLAY_PLANES | None = None,
130
+ mask: npt.NDArray | NiiBase | None = None,
131
+ **kwargs,
132
+ ) -> None:
133
+ """Display the image, optionally masked, via plotting.images.display_images.
134
+
135
+ Displays an interactive image-grid widget and returns nothing.
136
+ """
137
+ # Check if orientation or display_plane is provided in kwargs and orient images
138
+ logger.debug(
139
+ f"Displaying NIfTI with orientation {orientation} and display plane {display_plane}."
140
+ )
141
+ images = self.images(orientation=orientation, display_plane=display_plane)
142
+ # If mask is provided, check if it has same shape as images and apply mask to images
143
+ if mask is not None:
144
+ try:
145
+ images = self.apply_mask(mask)
146
+ except ValueError as e:
147
+ logger.error(f"Error applying mask: {e}. Displaying unmasked images.")
148
+
149
+ plotting.images.display_images(images, **kwargs)
150
+
151
+ def overlay_on_T1(
152
+ self,
153
+ t1: NiiBase | spatialimages.SpatialImage | npt.NDArray,
154
+ resample_kwargs: dict | None = None,
155
+ orientation: tuple[str, str, str] | None = None,
156
+ display_plane: nifti.DISPLAY_PLANES | None = None,
157
+ mask: npt.NDArray | NiiBase | None = None,
158
+ **kwargs,
159
+ ) -> None:
160
+ """Overlay this image on a T1 image, resampling and masking as needed."""
161
+ if resample_kwargs is None:
162
+ resample_kwargs = {}
163
+ if orientation is None and display_plane is None:
164
+ orientation = self.orientation
165
+ if isinstance(t1, np.ndarray):
166
+ if mask is not None and isinstance(mask, NiiBase):
167
+ mask = mask.images(orientation=orientation, display_plane=display_plane).astype(
168
+ bool
169
+ )
170
+ plotting.images.overlay_image_data_on_T1(
171
+ t1,
172
+ self.images(orientation=orientation, display_plane=display_plane),
173
+ mask=mask,
174
+ **kwargs,
175
+ )
176
+ return
177
+ if isinstance(t1, NiiBase):
178
+ t1 = t1.nii
179
+ if isinstance(t1, (NiiBase, spatialimages.SpatialImage)):
180
+ plotting.images.overlay_nifti_data_on_T1(
181
+ t1,
182
+ self.nii,
183
+ orientation=orientation,
184
+ display_plane=display_plane,
185
+ resample_kwargs=resample_kwargs,
186
+ mask=mask,
187
+ **kwargs,
188
+ )
189
+ else:
190
+ raise TypeError("t1 must be either a NIfTI image or a numpy array.")
191
+
192
+ def get_brain_mask(
193
+ self,
194
+ create: bool = False,
195
+ mask_keyword: str = "_bet.nii",
196
+ **extract_kwargs,
197
+ ) -> NiiBase:
198
+ """Find (or optionally create) the brain mask NIfTI next to this image.
199
+
200
+ Looks for a brain mask NIfTI (matching ``mask_keyword``) in the same
201
+ folder as ``self.nii_path``. If none is found and ``create`` is True, a
202
+ mask is generated with HD-BET via :func:`utils.images.extract_brain` and
203
+ loaded. The mask is returned as its own :class:`NiiBase`.
204
+
205
+ Parameters
206
+ ----------
207
+ create : bool, optional
208
+ If True, run HD-BET brain extraction when no mask is found.
209
+ mask_keyword : str, optional
210
+ Substring identifying the brain mask NIfTI file.
211
+ **extract_kwargs
212
+ Forwarded to ``utils.images.extract_brain``.
213
+
214
+ Returns
215
+ -------
216
+ NiiBase
217
+ The brain mask wrapped as a NiiBase.
218
+
219
+ Raises
220
+ ------
221
+ ValueError
222
+ If this NIfTI was not loaded from a path (no folder to search).
223
+ FileNotFoundError
224
+ If no mask is found and ``create`` is False.
225
+ """
226
+ if self.nii_path is None:
227
+ raise ValueError("Cannot locate a brain mask: this NIfTI was not loaded from a path.")
228
+ folder = self.nii_path.parent
229
+ mask_path = file_helpers.get_nifti_file(
230
+ folder,
231
+ filter_out_keywords=[],
232
+ filter_in_keywords=[mask_keyword],
233
+ )
234
+ if mask_path is None:
235
+ if not create:
236
+ raise FileNotFoundError(
237
+ f"No brain mask (matching '{mask_keyword}') found in {folder}. "
238
+ "Pass create=True to generate one with HD-BET."
239
+ )
240
+ # Local import avoids the images <-> GESeries circular import and
241
+ # defers the heavy torch/HD-BET import until a mask must be created.
242
+ from .utils import images
243
+
244
+ logger.info(f"No brain mask found in {folder}. Creating one with HD-BET.")
245
+ extract_kwargs.setdefault("save_mask", True)
246
+ extract_kwargs.setdefault("extract_brain", False)
247
+ mask_path = images.extract_brain(self.nii_path, **extract_kwargs)
248
+
249
+ return NiiBase(mask_path, orientation=self.orientation)
250
+
251
+ @cached_property
252
+ def brain_mask(self) -> NiiBase:
253
+ """The brain mask as a NiiBase, loaded if present else created on access."""
254
+ return self.get_brain_mask(create=True)
255
+
256
+ def save(self, output_path: Path) -> None:
257
+ """Save the NIfTI image to output_path as a Nifti1Image."""
258
+ if self.nii_path is not None:
259
+ logger.warning(
260
+ f"NIfTI was originally loaded from {self.nii_path}. "
261
+ f"Saving to a new location {output_path}."
262
+ )
263
+ # Create directory if it does not exist
264
+ output_path.parent.mkdir(parents=True, exist_ok=True)
265
+ file_helpers.move_files_with_glob(output_path.parent, f"*{output_path.stem}*")
266
+
267
+ # To correctly store them in often required Nifti1 format, we need to
268
+ # convert them to Nifti1Image before saving
269
+ nifti1.Nifti1Image(
270
+ self.nii.dataobj,
271
+ self.nii.affine,
272
+ self.nii.header,
273
+ self.nii.extra,
274
+ self.nii.file_map,
275
+ ).to_filename(output_path)
276
+ logger.info(f"Saved NIfTI to {output_path}.")
277
+
278
+
279
+ class MRISeries(NiiBase):
280
+ """A reconstructed MRI series, loaded as a NIfTI from a scan's DATA_FOLDER."""
281
+
282
+ def __init__(
283
+ self,
284
+ DATA_FOLDER: Path,
285
+ SERIES_ID: int,
286
+ orientation: tuple[str, str, str] = ("L", "P", "S"),
287
+ ) -> None:
288
+ self.DATA_FOLDER = DATA_FOLDER
289
+ self.OUTPUT_FOLDER = (DATA_FOLDER / ".." / "output").resolve()
290
+ self.SERIES_ID = SERIES_ID
291
+ try:
292
+ nii_path: Path = file_helpers.get_niftis_from_series(
293
+ self.DATA_FOLDER, self.SERIES_ID, convert_dicoms=True
294
+ )
295
+ super().__init__(nii_path, orientation)
296
+ except (ValueError, FileNotFoundError) as e:
297
+ logger.warning(
298
+ f"Could not create or find NIfTIs for series {self.SERIES_ID}. "
299
+ f"Proceeding without initializing NiiBase.\nError: {e}"
300
+ )
301
+
302
+
303
+ class RawMRISeries(MRISeries):
304
+ """An MRI series with the associated raw reconstructed .mat data loaded."""
305
+
306
+ def __init__(self, DATA_FOLDER: Path, SERIES_ID: int) -> None:
307
+ super().__init__(DATA_FOLDER, SERIES_ID)
308
+ self.mat_path: Path = file_helpers.get_mat_data_from_series(
309
+ self.DATA_FOLDER, self.SERIES_ID
310
+ )
311
+ self.recon: dict = data_loaders.load_mat_file(self.mat_path)
312
+ self.header: dict = self.recon.get("h", {})
313
+ self.bb = self.recon.get("bb", np.array([]))
314
+ self.bbabs = self.recon.get("bbabs", np.abs(self.bb))
315
+
316
+ @cached_property
317
+ def fids(self) -> xr.DataArray:
318
+ """The raw FIDs, unchopped and conjugated, as a DataArray over id and time."""
319
+ fids, _ = data_loaders.load_raw_fids(self.DATA_FOLDER, self.SERIES_ID)
320
+ fids = fids.copy()
321
+ if self.fids_are_chopped:
322
+ fids[1::2, :] *= -1
323
+ fids = np.conj(fids)
324
+ return xr.DataArray(
325
+ fids,
326
+ dims=["id", "time"],
327
+ coords={"time": np.arange(fids.shape[1]) * self.dwelltime},
328
+ )
329
+
330
+ @property
331
+ def fids_are_chopped(self) -> bool:
332
+ """Whether the FIDs are chopped or not."""
333
+ value = self._get_header_value(["rdb_hdr", "data_collect_type"])
334
+ if isinstance(value, float):
335
+ is_chopped = int(value) % 2 == 0
336
+ return is_chopped
337
+ else:
338
+ logger.error(
339
+ "Value for data_collect_type in header does not have correct type. Returning False."
340
+ )
341
+ return False
342
+
343
+ @property
344
+ def pfile_number(self) -> int:
345
+ """The P-file number of the scan."""
346
+ value = self._get_header_value(["rdb_hdr", "run_int"])
347
+ if isinstance(value, float):
348
+ return int(value)
349
+ else:
350
+ return -1
351
+
352
+ @property
353
+ def protocol_name(self) -> str:
354
+ """The protocol name of the scan."""
355
+ value = self._get_header_value(["series", "prtcl"])
356
+ if isinstance(value, str):
357
+ return str(value)
358
+ else:
359
+ return ""
360
+
361
+ @property
362
+ def series_name(self) -> str:
363
+ """The series name of the scan."""
364
+ value = self._get_header_value(["series", "se_desc"])
365
+ if isinstance(value, str):
366
+ return str(value)
367
+ else:
368
+ return ""
369
+
370
+ @property
371
+ def exam_number(self) -> int:
372
+ """The exam number of the scan."""
373
+ value = self._get_header_value(["exam", "ex_no"])
374
+ if isinstance(value, float):
375
+ return int(value)
376
+ else:
377
+ return -1
378
+
379
+ @property
380
+ def scan_datetime(self) -> datetime:
381
+ """The scan datetime."""
382
+ date = self._get_header_value(["rdb_hdr", "scan_date"])
383
+ time = self._get_header_value(["rdb_hdr", "scan_time"])
384
+ if isinstance(date, str) and isinstance(time, str):
385
+ datetime_str = f"{date} {time}"
386
+ return datetime.strptime(datetime_str, "%m/%d/1%y %H:%M")
387
+ else:
388
+ return datetime.max
389
+
390
+ @property
391
+ def scan_date(self) -> date:
392
+ """The scan date."""
393
+ return self.scan_datetime.date()
394
+
395
+ @property
396
+ def scan_time(self) -> time:
397
+ """The scan time."""
398
+ return self.scan_datetime.time()
399
+
400
+ @property
401
+ def transients(self) -> float:
402
+ """The number of transients."""
403
+ value = self._get_header_value(["rdb_hdr", "nframes"])
404
+ if isinstance(value, (int, float)):
405
+ return float(value)
406
+ else:
407
+ return np.nan
408
+
409
+ @property
410
+ def bandwidth(self) -> float:
411
+ """The bandwidth of the scan (Hz)."""
412
+ value = self._get_header_value(["rdb_hdr", "user0"])
413
+ if isinstance(value, (int, float)):
414
+ return float(value)
415
+ else:
416
+ return np.nan
417
+
418
+ @property
419
+ def dwelltime(self) -> float:
420
+ """The dwell time of the scan (s, 1/Hz)."""
421
+ return 1 / self.bandwidth
422
+
423
+ @property
424
+ def deadtime(self) -> float:
425
+ """The dead time is assumed to be a single echo time in seconds."""
426
+ return self.echo_time / 1e6 # Convert from microseconds to seconds
427
+
428
+ @property
429
+ def centre_frequency(self) -> float:
430
+ """The centre frequency of the scan (MHz)."""
431
+ value = self._get_header_value(["rdb_hdr", "ps_mps_freq"])
432
+ if isinstance(value, (int, float)):
433
+ return float(value) / 1e7 # For some reason stored in Hz x10
434
+ else:
435
+ return np.nan
436
+
437
+ @property
438
+ def nucleus(self) -> int:
439
+ """The nucleus of the scan as integer (e.g. 1 for 1H, 13 for 13C)."""
440
+ value = self._get_header_value(["image", "specnuc"])
441
+ # Try casting to int if not possible return 0
442
+ try:
443
+ return int(value) # pyright: ignore[reportArgumentType]
444
+ except (ValueError, TypeError):
445
+ logger.error(f"Failed to convert nucleus value '{value}' to integer.")
446
+ return 0
447
+
448
+ @property
449
+ def carrier_ppm(self) -> float:
450
+ """The carrier ppm of the scan centre frequency (ppm). Based on nucleus."""
451
+ if self.nucleus in [1, 2]:
452
+ return 4.68
453
+ else:
454
+ return 0
455
+
456
+ @property
457
+ def echo_time(self) -> float:
458
+ """The echo time (TE) of the scan (us)."""
459
+ value = self._get_header_value(["rdb_hdr", "te"])
460
+ if isinstance(value, (int, float)):
461
+ return value
462
+ else:
463
+ return np.nan
464
+
465
+ @property
466
+ def field_of_view(self) -> float:
467
+ """The field of view (FOV) of the scan (mm)."""
468
+ value = self._get_header_value(["rdb_hdr", "fov"])
469
+ if isinstance(value, (int, float)):
470
+ return float(value)
471
+ else:
472
+ return np.nan
473
+
474
+ @property
475
+ def flip_angle(self) -> float:
476
+ """The flip angle of the scan (in degrees)."""
477
+ value = self._get_header_value(["image", "mr_flip"])
478
+ if isinstance(value, (int, float)):
479
+ return float(value)
480
+ else:
481
+ return np.nan
482
+
483
+ @property
484
+ def inversion_time(self) -> float:
485
+ """The inversion time (TI) of the scan (us)."""
486
+ value = self._get_header_value(["rdb_hdr", "user25"])
487
+ if isinstance(value, float):
488
+ return float(value)
489
+ else:
490
+ return np.nan
491
+
492
+ @property
493
+ def number_of_excitations(self) -> int:
494
+ """The number of excitations (NEX) of the scan."""
495
+ value = self._get_header_value(["image", "nex"])
496
+ if isinstance(value, float):
497
+ return int(value)
498
+ else:
499
+ return -1
500
+
501
+ @property
502
+ def repetition_time(self) -> float:
503
+ """The repetition time (TR) of the scan (ms)."""
504
+ value = self._get_header_value(["image", "tr"])
505
+ if isinstance(value, float):
506
+ return float(value) / 1e3
507
+ else:
508
+ return np.nan
509
+
510
+ @property
511
+ def rf_pulse_type(self) -> int:
512
+ """The RF pulse type used in the scan."""
513
+ value = self._get_header_value(["rdb_hdr", "user14"])
514
+ if isinstance(value, float):
515
+ return int(value)
516
+ else:
517
+ return -1
518
+
519
+ @property
520
+ def slice_thickness(self) -> float:
521
+ """The slice thickness of the scan (mm)."""
522
+ value = self._get_header_value(["image", "slthick"])
523
+ if isinstance(value, float):
524
+ return float(value)
525
+ else:
526
+ return np.nan
527
+
528
+ @property
529
+ def scan_duration(self) -> timedelta:
530
+ """The scan duration."""
531
+ value = self._get_header_value(["image", "sctime"])
532
+ if isinstance(value, float):
533
+ return timedelta(microseconds=value)
534
+ else:
535
+ raise ValueError("Scan duration not found in header.")
536
+
537
+ @property
538
+ def reconstruction_used(self) -> int:
539
+ """The reconstruction method used."""
540
+ value = self._get_header_value(["rdb_hdr", "recon"])
541
+ if isinstance(value, float):
542
+ return int(value)
543
+ else:
544
+ return -1
545
+
546
+ @property
547
+ def waveform_type_scan_mode(self) -> float:
548
+ """The waveform type or scan mode."""
549
+ value = self._get_header_value(["rdb_hdr", "user3"])
550
+ if isinstance(value, float):
551
+ return float(value)
552
+ else:
553
+ return np.nan
554
+
555
+ @property
556
+ def receive_gain_1(self) -> float:
557
+ """The first receive gain."""
558
+ value = self._get_header_value(["rdb_hdr", "ps_mps_r1"])
559
+ if isinstance(value, float):
560
+ return float(value)
561
+ else:
562
+ return np.nan
563
+
564
+ @property
565
+ def receive_gain_2(self) -> float:
566
+ """The second receive gain."""
567
+ value = self._get_header_value(["rdb_hdr", "ps_mps_r2"])
568
+ if isinstance(value, float):
569
+ return float(value)
570
+ else:
571
+ return np.nan
572
+
573
+ @property
574
+ def transmit_gain(self) -> int:
575
+ """The transmit gain (TG)."""
576
+ value = self._get_header_value(["rdb_hdr", "ps_mps_tg"])
577
+ if isinstance(value, float):
578
+ return int(value)
579
+ else:
580
+ return -1
581
+
582
+ @property
583
+ def field_strength(self) -> float:
584
+ """The field strength of the scan (T)."""
585
+ value = self._get_header_value(["mrconfig", "fieldStrength"])
586
+ if isinstance(value, float):
587
+ return float(value) / 10000
588
+ else:
589
+ return np.nan
590
+
591
+ def _get_header_value(self, key_path: list[str]) -> None | str | np.double | npt.NDArray:
592
+ """Traverse the header dictionary using a key path.
593
+
594
+ Raises AttributeError if the path is invalid.
595
+ """
596
+ value = self.header
597
+ try:
598
+ for key in key_path:
599
+ value = value[key]
600
+ # Depending on the data type, return the appropriate value
601
+ # If string
602
+ if value is None:
603
+ raise KeyError
604
+ elif isinstance(value, str):
605
+ return value
606
+ elif isinstance(value, (np.ndarray, np.number, int, float)):
607
+ value = np.asarray(value)
608
+ if value.size == 1:
609
+ return np.double(value.item())
610
+ else:
611
+ return value.flatten()
612
+ # Any other type is treated as a missing/unsupported value.
613
+ return None
614
+
615
+ except (TypeError, KeyError):
616
+ raise AttributeError(
617
+ f"Mapped attribute not found. Expected path: {' -> '.join(key_path)}"
618
+ )
619
+
620
+
621
+ class MRSISeries(RawMRISeries):
622
+ """A raw MRSI series: spectroscopic imaging spectra, fitting, and voxel maps."""
623
+
624
+ def __init__(
625
+ self, DATA_FOLDER: Path, SERIES_ID: int, load_processed_data: bool = False
626
+ ) -> None:
627
+ super().__init__(DATA_FOLDER, SERIES_ID)
628
+ # Expect spec and header to be present
629
+ spec_data = self.recon.get("spec", np.ndarray([]))
630
+ if not isinstance(spec_data, np.ndarray) or spec_data.size == 0:
631
+ logger.error(
632
+ "Expected 'spec' to be a non-empty numpy array. Not initializing object properly"
633
+ )
634
+ else:
635
+ self.spec: xr.DataArray = xr.DataArray(
636
+ spec_data,
637
+ dims=["chemical_shift", "i", "j", "k"],
638
+ coords={
639
+ "chemical_shift": spectra.calculate_ppm_axis(
640
+ spectral_width=self.bandwidth,
641
+ frequency=self.centre_frequency,
642
+ carrier_ppm=self.carrier_ppm,
643
+ npts=spec_data.shape[0],
644
+ ),
645
+ "i": np.arange(spec_data.shape[1]),
646
+ "j": np.arange(spec_data.shape[2]),
647
+ "k": np.arange(spec_data.shape[3]),
648
+ },
649
+ attrs={
650
+ "field_strength": self.field_strength,
651
+ "reference_frequency": self.centre_frequency,
652
+ "carrier_ppm": self.carrier_ppm,
653
+ "spectral_width": self.bandwidth,
654
+ "deadtime": self.deadtime,
655
+ },
656
+ )
657
+
658
+ self.dims: tuple = self.spec.shape[1:4]
659
+ self.npts: int = self.spec.shape[0]
660
+ self.ppm: xr.DataArray = self.spec.coords["chemical_shift"]
661
+ self.RAW_exp: NiiBase = self.create_MRSI_nii()
662
+ # Update attributes of spec
663
+ self.spec.attrs.update(
664
+ {
665
+ "affine": self.RAW_exp.affine.tolist(),
666
+ "orientation": self.RAW_exp.orientation,
667
+ }
668
+ )
669
+
670
+ self.fitted_metabolite_maps: dict = {}
671
+ self.goodness_of_fit_maps: dict = {}
672
+ if load_processed_data:
673
+ self.load_processed_data()
674
+
675
+ @cached_property
676
+ def spec_flat(self) -> xr.DataArray:
677
+ """The spectra with the voxel dimensions stacked into a single voxel dim."""
678
+ return self.spec.stack(voxel=self.spec.dims[1:4])
679
+
680
+ @cached_property
681
+ def fids_flat(self) -> xr.DataArray:
682
+ """The FIDs with the voxel dimensions stacked into a single voxel dim."""
683
+ return self.fids.stack(voxel=self.fids.dims[1:4])
684
+
685
+ @cached_property
686
+ def fids(self) -> xr.DataArray:
687
+ """The FIDs, derived from the spectra via FFT."""
688
+ return self.spec.xmr.to_hz().xmr.to_fid()
689
+
690
+ @cached_property
691
+ def avg_fid(self) -> xr.DataArray:
692
+ """Average fid across voxels with SNR above threshold."""
693
+ return self.fids.where(self.get_SNR_mask()).mean(["i", "j", "k"], skipna=True)
694
+
695
+ @cached_property
696
+ def SNR_map(self) -> xr.DataArray:
697
+ """The per-voxel SNR, computed from the FIDs via pyAMARES.fidSNR."""
698
+ return xr.apply_ufunc(
699
+ pyAMARES.fidSNR, self.fids, input_core_dims=[["time"]], vectorize=True
700
+ )
701
+
702
+ @property
703
+ def voxel_size(self) -> float:
704
+ """The in-plane voxel size (mm), derived from the field of view and matrix."""
705
+ mtx = self.dims[0:2]
706
+ # Assert that mtx is the same in x and y
707
+ if mtx[0] != mtx[1]:
708
+ logger.warning(
709
+ f"Matrix size in x and y are not the same: {mtx[0]} vs {mtx[1]}. "
710
+ "Using x size for voxel size calculation."
711
+ )
712
+ return self.field_of_view / mtx[0]
713
+
714
+ @cached_property
715
+ def default_SNR_mask(self) -> xr.DataArray:
716
+ """The default SNR mask, computed with get_SNR_mask's default threshold."""
717
+ return self.get_SNR_mask()
718
+
719
+ def get_SNR_mask(self, threshold=1.5) -> xr.DataArray:
720
+ """Return a boolean mask of voxels with SNR at or above threshold."""
721
+ return self.SNR_map >= threshold
722
+
723
+ def create_MRSI_affine(self) -> npt.NDArray:
724
+ """Return an affine matrix rescaled to the MRSI voxel size and offset."""
725
+ # Create new affine matrix based on original nifti affine and mat voxel size
726
+ # This assumes the same orientation and slice thickness and spacing
727
+
728
+ if self.nii.affine is None:
729
+ raise ValueError("The affine matrix of the NIfTI image is None.")
730
+
731
+ # Check if the original affine has uniform scaling in x and y
732
+ if not np.isclose(self.nii.affine[0, 0], self.nii.affine[1, 1]):
733
+ logger.warning(
734
+ "Original affine matrix has non-uniform scaling in x and y: "
735
+ f"{self.nii.affine[0, 0]} vs {self.nii.affine[1, 1]}. "
736
+ "Using x scaling for new affine."
737
+ )
738
+
739
+ interpolated_voxel_sizes = self.nii.affine[0, 0]
740
+ scaling_factor = self.voxel_size / interpolated_voxel_sizes
741
+
742
+ new_affine = np.copy(self.nii.affine)
743
+ new_affine[0, 0] *= scaling_factor
744
+ new_affine[1, 1] *= scaling_factor
745
+ # Round voxel sizes to 3 decimal places to avoid floating point issues
746
+ new_affine[0:3, 0:3] = np.round(new_affine[0:3, 0:3], 3)
747
+
748
+ # Offset x and y by centre offset
749
+ # The shift is important as we want to make sure the overlay is in the correct position
750
+ # The correct shift is the following
751
+ shift = (np.diag(new_affine[:3]) - np.diag(self.nii.affine[:3])).round(3) / 2
752
+ # However this creates the correct FOV but the overlay is not in the correct position
753
+ # For a correct shift we need to add an additonal half voxel
754
+ shift += np.array([new_affine[0, 0], new_affine[1, 1], 0]) / 2
755
+ logger.trace(
756
+ f"Calculated shift for new affine: {shift}. "
757
+ f"Original affine translation: {self.nii.affine[:3, 3]}"
758
+ )
759
+ new_affine[:3, 3] += shift
760
+
761
+ return new_affine
762
+
763
+ def create_MRSI_nii(self) -> NiiBase:
764
+ """Return the RAW magnitude image as a NiiBase, with the MRSI affine."""
765
+ magnitude_data = np.sum(np.abs(self.spec), axis=0).astype(np.float32)
766
+ return self.with_new_data(new_data=magnitude_data, new_affine=self.create_MRSI_affine())
767
+
768
+ def create_map_nii(self, map_data: npt.NDArray) -> NiiBase:
769
+ """Wrap a per-voxel map array as a NiiBase, with the MRSI affine."""
770
+ # Make sure map_data has correct shape
771
+ if map_data.shape != self.dims:
772
+ raise ValueError(
773
+ f"map_data shape {map_data.shape} does not match expected dimensions {self.dims}."
774
+ )
775
+ return self.with_new_data(new_data=map_data, new_affine=self.create_MRSI_affine())
776
+
777
+ def get_voxel_spectrum(self, x: int, y: int, slice: int) -> xr.DataArray:
778
+ """Return the spectrum at voxel (x, y, slice)."""
779
+ logger.debug(
780
+ f"Assuming voxel coordinates (x, y, slice) = ({x}, {y}, {slice}) "
781
+ "are in correct order and within bounds."
782
+ )
783
+ return self.spec.sel(i=x, j=y, k=slice)
784
+
785
+ def fit_single_voxel(
786
+ self,
787
+ x: int,
788
+ y: int,
789
+ slice: int,
790
+ init_params: dict,
791
+ fit_params: dict | None = None,
792
+ ):
793
+ """Fit the FID at voxel (x, y, slice) with AMARES."""
794
+ if fit_params is None:
795
+ fit_params = {}
796
+ fid = self.fids.sel(i=x, j=y, k=slice)
797
+
798
+ # Include series information in init_params
799
+ passed_init_params = {
800
+ "MHz": self.centre_frequency,
801
+ "sw": self.bandwidth,
802
+ "deadtime": self.deadtime,
803
+ }
804
+ passed_init_params.update(init_params)
805
+
806
+ fit_result = fitting.AMARES.fit_single_fid(
807
+ fid,
808
+ passed_init_params,
809
+ fit_params,
810
+ )
811
+ return fit_result
812
+
813
+ def fit_all_voxels(
814
+ self,
815
+ init_params: dict,
816
+ batch_fitting_params: dict | None = None,
817
+ bad_fit_chisqr_threshold: float = -1.0,
818
+ testing_mode: bool = False,
819
+ ) -> npt.NDArray:
820
+ """Fit all voxels above the SNR threshold with AMARES and store the maps.
821
+
822
+ Parameters
823
+ ----------
824
+ init_params : dict
825
+ Initial fit parameters, merged with series-derived MHz/sw/deadtime.
826
+ batch_fitting_params : dict, optional
827
+ Forwarded to ``fitting.AMARES.fit_multiple_fids``.
828
+ bad_fit_chisqr_threshold : float, optional
829
+ Chi-squared threshold above which a fit is flagged as bad. Defaults to -1.0
830
+ (disabled).
831
+ testing_mode : bool, optional
832
+ If True, limit fitting to the first 100 voxels above the SNR threshold.
833
+
834
+ Returns
835
+ -------
836
+ numpy.ndarray
837
+ Flattened voxel indices of fits flagged as bad.
838
+ """
839
+ if batch_fitting_params is None:
840
+ batch_fitting_params = {}
841
+ SNR_mask = self.get_SNR_mask()
842
+ # If in testing mode, limit to first 100 voxels with SNR above threshold
843
+ if testing_mode:
844
+ flat_SNR_mask = SNR_mask.stack(voxel=SNR_mask.dims)
845
+ indices = np.where(flat_SNR_mask)[0][:100]
846
+ SNR_mask = xr.full_like(flat_SNR_mask, False)
847
+ SNR_mask[indices] = True
848
+ SNR_mask = SNR_mask.unstack("voxel")
849
+
850
+ # Include series information in init_params
851
+ passed_init_params = {
852
+ "MHz": self.centre_frequency,
853
+ "sw": self.bandwidth,
854
+ "deadtime": self.deadtime,
855
+ }
856
+ passed_init_params.update(init_params)
857
+
858
+ results = fitting.AMARES.fit_multiple_fids(
859
+ self.fids.where(SNR_mask).stack(voxel=SNR_mask.dims).dropna("voxel"),
860
+ batch_fitting_params=batch_fitting_params,
861
+ init_params=passed_init_params,
862
+ )
863
+
864
+ # Extract results back into original shape
865
+ extracted_results = fitting.AMARES.extract_from_fit_results(
866
+ results, bad_fit_chisqr_threshold
867
+ )
868
+
869
+ # Save extracted results into processing data folder with additional attributes
870
+ additional_attributes: dict[str, str | int] = {
871
+ "SERIES_ID": self.SERIES_ID,
872
+ "exam_number": self.exam_number,
873
+ "ACQUISITION_TIME": self.scan_datetime.isoformat(),
874
+ "SAVED_ON": datetime.now().isoformat(),
875
+ }
876
+ additional_datasets = {
877
+ "SNR_mask": SNR_mask.values,
878
+ }
879
+
880
+ fitting.AMARES.save_extracted_results(
881
+ self.OUTPUT_FOLDER
882
+ / "processing_data"
883
+ / "dev"
884
+ / f"Series{self.SERIES_ID:02d}_extracted_results.h5",
885
+ extracted_results,
886
+ additional_attributes,
887
+ additional_datasets=additional_datasets,
888
+ prepend_datetime=True,
889
+ )
890
+
891
+ combined_fit_results = extracted_results.combined_fit_results
892
+ fitted_metabolites_temp = np.full((*self.dims, combined_fit_results.columns.size), np.nan)
893
+ fitted_metabolites_temp[SNR_mask] = combined_fit_results.loc[
894
+ (slice(None), "amplitude", "value"), :
895
+ ].to_numpy()
896
+ for i, metabolite in enumerate(combined_fit_results.columns):
897
+ self.fitted_metabolite_maps[metabolite] = fitted_metabolites_temp[:, :, :, i]
898
+
899
+ goodness_of_fit = extracted_results.goodness_of_fit
900
+ gof_metrics_temp = np.full((*self.dims, goodness_of_fit.columns.size), np.nan)
901
+ gof_metrics_temp[SNR_mask] = goodness_of_fit.to_numpy()
902
+ for i, metric in enumerate(goodness_of_fit.columns):
903
+ self.goodness_of_fit_maps[metric] = gof_metrics_temp[:, :, :, i]
904
+
905
+ # The badfit_ids correspond to the flattened indices of the fitted voxels
906
+ # Create mapping from badfit_ids to original voxel flattened indices
907
+ if extracted_results.badfit_ids.size == 0:
908
+ original_badfit_ids = np.array([], dtype=int)
909
+ else:
910
+ original_badfit_ids = np.where(SNR_mask.flatten())[0][extracted_results.badfit_ids]
911
+
912
+ return original_badfit_ids
913
+
914
+ def save_processed_data(self, output_folder: Path | None = None) -> Path:
915
+ """Save fitted maps, FIDs, and identifying attributes to an HDF5 file.
916
+
917
+ Parameters
918
+ ----------
919
+ output_folder : Path, optional
920
+ Directory to save into. Defaults to ``OUTPUT_FOLDER / "processing_data"``.
921
+
922
+ Returns
923
+ -------
924
+ Path
925
+ Path to the saved (or, if a file already exists, the existing) HDF5 file.
926
+
927
+ Raises
928
+ ------
929
+ OSError
930
+ If the saved file fails the post-save integrity check.
931
+ """
932
+ attrs_to_save = [
933
+ "SERIES_ID", # Necessary to identify series later, do not remove
934
+ "exam_number", # Necessary to identify series later, do not remove
935
+ ]
936
+
937
+ data_to_save = [
938
+ "fids",
939
+ "avg_fid",
940
+ "SNR_map",
941
+ "default_SNR_mask",
942
+ ]
943
+
944
+ # Save results to output folder / processing_data
945
+ if output_folder is None:
946
+ output_folder = self.OUTPUT_FOLDER / "processing_data"
947
+ output_folder.mkdir(exist_ok=True, parents=True)
948
+
949
+ date_str = datetime.now().strftime("%Y%m%d_%H%M%S")
950
+ filename = f"Series{self.SERIES_ID:02d}_processing_data.h5"
951
+
952
+ # Move previous runs to old folder. Making it easier to determine which is the latest
953
+ file_helpers.move_files_with_glob(output_folder, f"*{filename}*")
954
+
955
+ filename = f"{date_str}_{filename}"
956
+ full_path = output_folder / filename
957
+
958
+ # If file exists, log warning and return
959
+ if full_path.exists():
960
+ logger.warning(
961
+ f"File {full_path} already exists. Not overwriting. Returning existing file path."
962
+ )
963
+ return full_path
964
+
965
+ with h5py.File(full_path, "a") as f:
966
+ # Save fitted metabolite maps
967
+ fitted_data_group = f.create_group("fitted_data")
968
+ for metabolite, metabolite_map in self.fitted_metabolite_maps.items():
969
+ logger.debug(f"Saving metabolite map for {metabolite}")
970
+ fitted_data_group.create_dataset(
971
+ metabolite, data=metabolite_map, dtype=metabolite_map.dtype
972
+ )
973
+
974
+ # Save goodness of fit metrics
975
+ gof_group = fitted_data_group.create_group("goodness_of_fit")
976
+ for goodness_metric, metric_map in self.goodness_of_fit_maps.items():
977
+ logger.debug(f"Saving goodness of fit metric map for {goodness_metric}")
978
+ gof_group.create_dataset(goodness_metric, data=metric_map, dtype=metric_map.dtype)
979
+
980
+ # Save acquisition time of series in HDF5 in readable format
981
+ f.attrs["ACQUISITION_TIME"] = self.scan_datetime.strftime("%Y-%m-%d_%H:%M:%S")
982
+ f.attrs["saved_on"] = datetime.now().isoformat()
983
+ for attr in attrs_to_save:
984
+ value = getattr(self, attr)
985
+ logger.debug(f"Saving attribute {attr} with value {value}")
986
+ f.attrs[attr] = value
987
+
988
+ # Save additional attributes
989
+ for attr in data_to_save:
990
+ data = getattr(self, attr)
991
+ logger.debug(f"Saving attribute {attr}")
992
+ if hasattr(data, "dtype"):
993
+ f.create_dataset(attr, data=data, dtype=data.dtype)
994
+ else:
995
+ f.create_dataset(attr, data=data)
996
+
997
+ logger.info(f"Saved processed data to {'/'.join(full_path.parts[-4:])}")
998
+ logger.debug("Checking saved data.")
999
+ is_okay = self._check_saved_data(full_path)
1000
+ if not is_okay:
1001
+ logger.error("Saved data did not pass the integrity check! Renaming file!!")
1002
+ full_path.rename(full_path.with_suffix(".corrupt"))
1003
+ raise OSError(
1004
+ "Saved data did not pass the integrity check! "
1005
+ "Debug this to make sure data is saved correctly."
1006
+ )
1007
+ else:
1008
+ logger.debug("Saved data passed the integrity check.")
1009
+
1010
+ return full_path
1011
+
1012
+ def load_processed_data(
1013
+ self, output_folder: Path | None = None, force_load: bool = False
1014
+ ) -> bool:
1015
+ """Load the most recent processed-data HDF5 file for this series.
1016
+
1017
+ Parameters
1018
+ ----------
1019
+ output_folder : Path, optional
1020
+ Directory to search in. Defaults to ``OUTPUT_FOLDER / "processing_data"``.
1021
+ force_load : bool, optional
1022
+ If True, load the data even if SERIES_ID, exam_number, or acquisition
1023
+ time do not match this series.
1024
+
1025
+ Returns
1026
+ -------
1027
+ bool
1028
+ True if data was loaded successfully, False otherwise.
1029
+ """
1030
+ if output_folder is None:
1031
+ output_folder = self.OUTPUT_FOLDER / "processing_data"
1032
+
1033
+ try:
1034
+ old_processed_data = file_helpers.get_latest_processing_file(
1035
+ output_folder, self.SERIES_ID
1036
+ )
1037
+ except FileNotFoundError:
1038
+ logger.warning(
1039
+ f"No processed data file found in directory {output_folder}. "
1040
+ "Returning without loading."
1041
+ )
1042
+ return False
1043
+ with h5py.File(old_processed_data, "r") as f:
1044
+ # Do some sanity checks, check Series id, exam_number and acquisition time
1045
+ try:
1046
+ same_series_id = f.attrs["SERIES_ID"] == self.SERIES_ID # type: ignore
1047
+ same_exam_number = f.attrs["exam_number"] == self.exam_number # type: ignore
1048
+ same_acq_time = (
1049
+ datetime.strptime(
1050
+ f.attrs["ACQUISITION_TIME"], # type: ignore
1051
+ "%Y-%m-%d_%H:%M:%S",
1052
+ )
1053
+ == self.scan_datetime
1054
+ )
1055
+ logger.debug(
1056
+ f"Series ID match: {same_series_id}, "
1057
+ f"Exam number match: {same_exam_number}, "
1058
+ f"Acquisition time match: {same_acq_time}"
1059
+ )
1060
+ if not all([same_series_id, same_exam_number, same_acq_time]):
1061
+ logger.warning("Mismatch found in saved attributes! Not loading data")
1062
+ if force_load:
1063
+ logger.warning("Force loading data despite mismatches.")
1064
+ else:
1065
+ raise ValueError("Cannot load data due to mismatches in attributes.")
1066
+ except KeyError as e:
1067
+ logger.error(
1068
+ f"KeyError: {e}. The following keys must be present in the "
1069
+ "file: 'SERIES_ID', 'exam_number', 'acquisition_time'."
1070
+ )
1071
+ logger.error(f"Tree of the HDF5 file:\n{file_helpers.print_hdf5_tree(f)}")
1072
+ return False
1073
+
1074
+ # Load fitted metabolite maps
1075
+ self.fitted_metabolite_maps = {}
1076
+ self.goodness_of_fit_maps = {}
1077
+ try:
1078
+ fitted_data_group = f["fitted_data"]
1079
+ if isinstance(fitted_data_group, h5py.Group):
1080
+ for key in fitted_data_group.keys():
1081
+ data = fitted_data_group[key]
1082
+ if isinstance(data, h5py.Dataset):
1083
+ metabolite_map = data[:]
1084
+ self.fitted_metabolite_maps[key] = metabolite_map
1085
+ logger.debug(f"Loaded metabolite map for {key}")
1086
+ logger.debug(
1087
+ f" dtype: {metabolite_map.dtype}, shape: {metabolite_map.shape}"
1088
+ )
1089
+ logger.debug(f" sum: {np.nansum(metabolite_map)}")
1090
+ if isinstance(data, h5py.Group):
1091
+ logger.debug(f"Loaded group for {key}")
1092
+ for subkey in data.keys():
1093
+ subdata = data[subkey]
1094
+ if isinstance(subdata, h5py.Dataset):
1095
+ subdata = subdata[:]
1096
+ self.goodness_of_fit_maps[subkey] = subdata
1097
+ logger.debug(f" Loaded dataset for {subkey}")
1098
+ logger.debug(
1099
+ f" dtype: {subdata.dtype}, shape: {subdata.shape}"
1100
+ )
1101
+ logger.debug(f" sum: {np.nansum(subdata)}")
1102
+ except KeyError as e:
1103
+ logger.error(f"KeyError: {e}. The expected keys may not be present in the file.")
1104
+ logger.error(f"Tree of the HDF5 file:\n{file_helpers.print_hdf5_tree(f)}")
1105
+
1106
+ # Check if fitted_metabolite_maps and goodness_of_fit_maps are loaded
1107
+ if not self.fitted_metabolite_maps:
1108
+ logger.warning(
1109
+ "No fitted metabolite maps were loaded. Something went wrong! "
1110
+ "Datasets probably not stored correctly in hdf5 file."
1111
+ )
1112
+ if not self.goodness_of_fit_maps:
1113
+ logger.warning(
1114
+ "No goodness of fit maps were loaded. Something went wrong! "
1115
+ "Datasets probably not stored correctly in hdf5 file."
1116
+ )
1117
+ return True
1118
+
1119
+ def _check_saved_data(self, filename: Path) -> bool:
1120
+ """Check if the saved data file is valid.
1121
+
1122
+ Validates that the file is an HDF5 file, contains maps in the 'fitted_data'
1123
+ group, and ensures the maps are nonzero in size and have 3D dimensions.
1124
+
1125
+ Parameters
1126
+ ----------
1127
+ filename : Path
1128
+ The path to the file to check.
1129
+
1130
+ Returns
1131
+ -------
1132
+ bool
1133
+ True if the file passes all checks, False otherwise.
1134
+ """
1135
+ if not filename.exists():
1136
+ logger.error(f"File {filename} does not exist.")
1137
+ return False
1138
+
1139
+ if not h5py.is_hdf5(filename):
1140
+ logger.error(f"File {filename} is not a valid HDF5 file.")
1141
+ return False
1142
+
1143
+ try:
1144
+ with h5py.File(filename, "r") as f:
1145
+ if "fitted_data" not in f:
1146
+ logger.error(f"'fitted_data' group not found in {filename}.")
1147
+ return False
1148
+
1149
+ fitted_data_group = f["fitted_data"]
1150
+ if not isinstance(fitted_data_group, h5py.Group):
1151
+ logger.error(f"'fitted_data' is not a group in {filename}.")
1152
+ return False
1153
+
1154
+ # Ensure 'fitted_data' group has at least one key
1155
+ if not any(
1156
+ isinstance(fitted_data_group[key], h5py.Dataset)
1157
+ for key in fitted_data_group.keys()
1158
+ ):
1159
+ logger.error(f"'fitted_data' group in {filename} has no datasets.")
1160
+ return False
1161
+
1162
+ # Ensure 'goodness_of_fit' group has at least one key
1163
+ if "goodness_of_fit" in fitted_data_group:
1164
+ gof_group = fitted_data_group["goodness_of_fit"]
1165
+ if isinstance(gof_group, h5py.Group) and len(gof_group.keys()) == 0:
1166
+ logger.error(f"'goodness_of_fit' group in {filename} has no keys.")
1167
+ return False
1168
+
1169
+ for key in fitted_data_group.keys():
1170
+ dataset = fitted_data_group[key]
1171
+ if isinstance(dataset, h5py.Dataset):
1172
+ data = dataset[:]
1173
+ if data.size == 0:
1174
+ logger.error(
1175
+ f"Dataset '{key}' in 'fitted_data' group is empty in {filename}."
1176
+ )
1177
+ return False
1178
+ if data.ndim != 3:
1179
+ logger.error(
1180
+ f"Dataset '{key}' in 'fitted_data' group is not 3D in {filename}."
1181
+ )
1182
+ return False
1183
+ elif isinstance(dataset, h5py.Group):
1184
+ for subkey in dataset.keys():
1185
+ subdataset = dataset[subkey]
1186
+ if isinstance(subdataset, h5py.Dataset):
1187
+ subdata = subdataset[:]
1188
+ if subdata.size == 0:
1189
+ logger.error(
1190
+ f"Sub-dataset '{subkey}' in '{key}' group "
1191
+ f"is empty in {filename}."
1192
+ )
1193
+ return False
1194
+ if subdata.ndim != 3:
1195
+ logger.error(
1196
+ f"Sub-dataset '{subkey}' in '{key}' group "
1197
+ f"is not 3D in {filename}."
1198
+ )
1199
+ return False
1200
+ else:
1201
+ logger.error(
1202
+ f"Unexpected data type in 'fitted_data' group for "
1203
+ f"key '{key}' in {filename}."
1204
+ )
1205
+ return False
1206
+
1207
+ # Additional check: Ensure acquisition time is present and valid
1208
+ if "ACQUISITION_TIME" not in f.attrs:
1209
+ logger.error(f"'ACQUISITION_TIME' attribute not found in {filename}.")
1210
+ return False
1211
+ try:
1212
+ acq_time_str = f.attrs["ACQUISITION_TIME"]
1213
+ datetime.strptime(str(acq_time_str), "%Y-%m-%d_%H:%M:%S")
1214
+ logger.debug(f"'ACQUISITION_TIME' in {filename} is valid: {acq_time_str}")
1215
+ except (TypeError, ValueError) as e:
1216
+ logger.error(f"Invalid 'ACQUISITION_TIME' format in {filename}: {e}")
1217
+ return False
1218
+
1219
+ except (OSError, KeyError, TypeError, ValueError) as e:
1220
+ logger.error(f"Error while checking file {filename}: {e}")
1221
+ return False
1222
+
1223
+ logger.debug(f"File {'/'.join(filename.parts[-4:])} passed all checks.")
1224
+ return True
1225
+
1226
+ def visualize_fitted_metabolite_map(
1227
+ self,
1228
+ metabolite_name: str,
1229
+ **kwargs,
1230
+ ):
1231
+ """Display the fitted metabolite map for metabolite_name."""
1232
+ plotting.images.display_images(
1233
+ self.fitted_metabolite_maps[metabolite_name],
1234
+ title=f"Fitted Metabolite Map: {metabolite_name}",
1235
+ colorbar=True,
1236
+ **kwargs,
1237
+ )
1238
+
1239
+ def visualize_goodness_of_fit_map(
1240
+ self,
1241
+ metric_name: str,
1242
+ **kwargs,
1243
+ ):
1244
+ """Display the goodness-of-fit map for metric_name."""
1245
+ plotting.images.display_images(
1246
+ self.goodness_of_fit_maps[metric_name],
1247
+ title=f"Goodness of Fit Map: {metric_name}",
1248
+ colorbar=True,
1249
+ **kwargs,
1250
+ )
1251
+
1252
+ def visualize_fitted_values(self):
1253
+ """Not yet implemented."""
1254
+ ...
1255
+
1256
+
1257
+ class MRSSeries(RawMRISeries):
1258
+ """A single-voxel MRS series: FIDs, spectra, averaging, and fitting."""
1259
+
1260
+ def __init__(self, DATA_FOLDER: Path, SERIES_ID: int) -> None:
1261
+ super().__init__(DATA_FOLDER, SERIES_ID)
1262
+ spec_data = self.recon.get("spec", np.ndarray([]))
1263
+ if not isinstance(spec_data, np.ndarray) or spec_data.size == 0:
1264
+ logger.error(
1265
+ "Expected 'spec' to be a non-empty numpy array. Not initializing object properly"
1266
+ )
1267
+ else:
1268
+ # This assumes no zero filling was applied yet
1269
+ self.npts: int = spec_data.shape[1]
1270
+ self.spec: xr.DataArray = xr.DataArray(
1271
+ spec_data,
1272
+ dims=["averages", "chemical_shift"],
1273
+ coords={
1274
+ "averages": np.arange(spec_data.shape[0]),
1275
+ "chemical_shift": spectra.calculate_ppm_axis(
1276
+ spectral_width=self.bandwidth,
1277
+ frequency=self.centre_frequency,
1278
+ carrier_ppm=self.carrier_ppm,
1279
+ npts=spec_data.shape[1],
1280
+ ),
1281
+ },
1282
+ attrs={
1283
+ "field_strength": self.field_strength,
1284
+ "reference_frequency": self.centre_frequency,
1285
+ "carrier_ppm": self.carrier_ppm,
1286
+ "units": "a.u.",
1287
+ "spectral_width": self.bandwidth,
1288
+ "deadtime": self.deadtime,
1289
+ },
1290
+ )
1291
+ self.averages: int = self.spec.sizes["averages"]
1292
+ self.ppm: xr.DataArray = self.spec.coords["chemical_shift"]
1293
+
1294
+ @cached_property
1295
+ def fids(self) -> xr.DataArray:
1296
+ """The FIDs from RawMRISeries, with the id coordinate renamed to averages."""
1297
+ # Return the fids from RawMRISeries but rename coordinate id to averages
1298
+ super().fids # Ensure fids is cached in RawMRISeries before renaming
1299
+ fids = super().fids.rename({"id": "averages"})
1300
+ return fids.assign_attrs(**self.spec.attrs)
1301
+
1302
+ @cached_property
1303
+ def avg_fid(self) -> xr.DataArray:
1304
+ """The FID averaged across all averages."""
1305
+ return self.fids.mean("averages").assign_attrs(**self.spec.attrs)
1306
+
1307
+ @cached_property
1308
+ def avg_spec(self) -> xr.DataArray:
1309
+ """The spectrum averaged across all averages."""
1310
+ # WARNING: fid to spectrum has different scaling than avg spec!! this is
1311
+ # probably due to scaling in the mat data
1312
+ logger.trace(
1313
+ "Calculating average spectrum from averaged FIDs. Note that this may "
1314
+ "have different scaling than the average of the spectra due to "
1315
+ "scaling in the mat data."
1316
+ )
1317
+ return self.spec.mean("averages").assign_attrs(**self.spec.attrs)
1318
+
1319
+ def group_size_by_duration(self, group_duration: float) -> int:
1320
+ """Calculate number of consecutive FIDs to average for a target duration.
1321
+
1322
+ Parameters
1323
+ ----------
1324
+ group_duration : float
1325
+ Target duration in seconds for each group.
1326
+
1327
+ Returns
1328
+ -------
1329
+ int
1330
+ Number of consecutive FIDs to average together.
1331
+ """
1332
+ scan_duration_sec = self.scan_duration.total_seconds()
1333
+ time_per_fid = scan_duration_sec / self.averages
1334
+
1335
+ if scan_duration_sec % group_duration != 0:
1336
+ actual_group_duration = (group_duration * self.averages) / scan_duration_sec
1337
+ logger.warning(
1338
+ f"Scan duration {scan_duration_sec:.2f}s is not an exact multiple "
1339
+ f"of group duration {group_duration:.2f}s.\n Actual group "
1340
+ f"duration will be {actual_group_duration:.2f}s."
1341
+ )
1342
+
1343
+ group_size = int(group_duration / time_per_fid)
1344
+
1345
+ return group_size
1346
+
1347
+ def average_fids(self, group_size: int | None = 8) -> xr.DataArray:
1348
+ """Average FIDs by grouping consecutive acquisitions.
1349
+
1350
+ Parameters
1351
+ ----------
1352
+ group_size : int, optional
1353
+ Number of consecutive FIDs to average together. Defaults to 8.
1354
+
1355
+ Returns
1356
+ -------
1357
+ xarray.DataArray
1358
+ Averaged FIDs, shape `(n_groups, n_timepoints)`.
1359
+ """
1360
+ if group_size is None:
1361
+ group_size = 8
1362
+ # Create bin edges: [0, group_size, 2*group_size, ..., self.averages]
1363
+ bins = np.arange(0, self.averages + group_size, group_size)
1364
+
1365
+ # Group by bins and average across averages dimension
1366
+ return (
1367
+ self.fids.groupby_bins("averages", bins=bins)
1368
+ .mean("averages")
1369
+ .assign_attrs(**self.spec.attrs)
1370
+ )
1371
+
1372
+ def average_spectra(self, group_size: int | None = 8) -> xr.DataArray:
1373
+ """Average spectra by grouping consecutive acquisitions.
1374
+
1375
+ Parameters
1376
+ ----------
1377
+ group_size : int, optional
1378
+ Number of consecutive FIDs to average together. Defaults to 8.
1379
+
1380
+ Returns
1381
+ -------
1382
+ xarray.DataArray
1383
+ Averaged spectra, shape `(n_groups, n_points)`.
1384
+ """
1385
+ # Average grouped FIDs
1386
+ averaged_fids = self.average_fids(group_size=group_size)
1387
+
1388
+ # Apply FFT along time dimension
1389
+ return averaged_fids.xmr.to_spectrum()
1390
+
1391
+ def average_spectra_by_duration(self, group_duration: int | None = 10) -> xr.DataArray:
1392
+ """Average spectra by grouping acquisitions within a time duration.
1393
+
1394
+ Parameters
1395
+ ----------
1396
+ group_duration : int, optional
1397
+ Duration in seconds for each group. Defaults to 10.
1398
+
1399
+ Returns
1400
+ -------
1401
+ xarray.DataArray
1402
+ Averaged spectra grouped by duration.
1403
+ """
1404
+ if group_duration is None:
1405
+ group_duration = 10
1406
+ group_size = self.group_size_by_duration(group_duration)
1407
+ return self.average_spectra(group_size=group_size)
1408
+
1409
+ def average_fids_by_duration(self, group_duration: int | None = 10) -> xr.DataArray:
1410
+ """Average FIDs by grouping acquisitions within a time duration.
1411
+
1412
+ Parameters
1413
+ ----------
1414
+ group_duration : int, optional
1415
+ Duration in seconds for each group. Defaults to 10.
1416
+
1417
+ Returns
1418
+ -------
1419
+ xarray.DataArray
1420
+ Averaged FIDs grouped by duration.
1421
+ """
1422
+ if group_duration is None:
1423
+ group_duration = 10
1424
+ group_size = self.group_size_by_duration(group_duration)
1425
+ return self.average_fids(group_size=group_size)
1426
+
1427
+ def phase_avg_spec(
1428
+ self,
1429
+ zero_order: float = 0.0,
1430
+ first_order: float = 0.0,
1431
+ autophase: bool = False,
1432
+ ppm_min: float = -np.inf,
1433
+ ppm_max: float = np.inf,
1434
+ ) -> xr.DataArray:
1435
+ """Phase (or autophase) the average spectrum, limited to a ppm range."""
1436
+ if autophase:
1437
+ phased_spec = self.spec.xmr.to_hz().xmr.autophase()
1438
+ else:
1439
+ phased_spec = self.spec.xmr.to_hz().xmr.phase(p0=zero_order, p1=first_order)
1440
+
1441
+ return (
1442
+ phased_spec.mean("averages")
1443
+ .assign_attrs(**self.spec.attrs)
1444
+ .xmr.to_ppm()
1445
+ .sel(chemical_shift=slice(ppm_min, ppm_max))
1446
+ )
1447
+
1448
+ def limit_spec_to_ppm_range(self, ppm_min: float = -2, ppm_max: float = 6) -> xr.DataArray:
1449
+ """Return the spectra limited to the chemical_shift range [ppm_min, ppm_max]."""
1450
+ return self.spec.sel(chemical_shift=slice(ppm_max, ppm_min))
1451
+
1452
+ def fit_average_fid(
1453
+ self,
1454
+ init_params: dict,
1455
+ fit_params: dict | None = None,
1456
+ normalisation_factor: np.complexfloating | None = None,
1457
+ filter_by_ppm: tuple[float, float] | None = None,
1458
+ ):
1459
+ """Fit the averaged FID (avg_fid) with AMARES, normalizing it first."""
1460
+ if fit_params is None:
1461
+ fit_params = {}
1462
+ # Normalize avg_fid
1463
+ if normalisation_factor is None:
1464
+ scaling_factor = np.max(self.avg_fid) * self.number_of_excitations
1465
+ logger.warning(
1466
+ "Using averaged fid as normalisation factor. It is recommended to "
1467
+ "provide a normalisation_factor for consistent scaling across "
1468
+ "series. See debug for more details."
1469
+ )
1470
+ else:
1471
+ scaling_factor = normalisation_factor * self.number_of_excitations
1472
+ logger.debug(
1473
+ f"Scaling the FID by factor {scaling_factor:.4g} for fitting. "
1474
+ f"Number of excitations: {self.number_of_excitations}"
1475
+ )
1476
+
1477
+ avg_fid = self.avg_fid / scaling_factor
1478
+
1479
+ fit_result = fitting.AMARES.fit_single_fid(
1480
+ avg_fid,
1481
+ init_params,
1482
+ fit_params,
1483
+ raw_header=self.header,
1484
+ filter_by_ppm=filter_by_ppm,
1485
+ )
1486
+ return fit_result
1487
+
1488
+
1489
+ class MRSWashinSeries(MRSSeries):
1490
+ """An MRS washin series: repeated spectra fitted and grouped over time."""
1491
+
1492
+ def __init__(self, DATA_FOLDER: Path, SERIES_ID: int) -> None:
1493
+ super().__init__(DATA_FOLDER, SERIES_ID)
1494
+ self.fit_results: list = []
1495
+
1496
+ @property
1497
+ def extracted_results(self):
1498
+ """The fit results extracted into a FitResults, or an empty one if unfit."""
1499
+ if not self.fit_results:
1500
+ logger.info(
1501
+ "No fit results present. Please run fit_grouped_by_duration() "
1502
+ "first. Returning empty results."
1503
+ )
1504
+ return fitting.AMARES.FitResults(np.asarray([]), pd.DataFrame(), pd.DataFrame())
1505
+ else:
1506
+ return self.extract_from_fit_results()
1507
+
1508
+ def plot_washin(self, **kwargs):
1509
+ """Plot the duration-averaged spectra, one line per minute."""
1510
+ averaged_spec = self.average_spectra_by_duration(kwargs.pop("group_duration", None))
1511
+ minute_labels = [f"Min. {i + 1:.0f}" for i in range(averaged_spec.shape[0])]
1512
+
1513
+ # averaged_spectra_per_minute
1514
+ return plotting.spectra.plot_spectra(
1515
+ data=averaged_spec.xmr.to_ppm(), labels=minute_labels, **kwargs
1516
+ )
1517
+
1518
+ def fit_grouped_by_duration(
1519
+ self,
1520
+ init_params: dict,
1521
+ fit_params: dict | None = None,
1522
+ normalisation_factor: np.complexfloating | None = None,
1523
+ group_duration: int = 60,
1524
+ ) -> list:
1525
+ """Average FIDs by duration and fit each group's FID with AMARES."""
1526
+ if fit_params is None:
1527
+ fit_params = {}
1528
+ if normalisation_factor is None:
1529
+ logger.warning(
1530
+ "No normalisation_factor provided. This can lead to different "
1531
+ "scaling between different series."
1532
+ )
1533
+
1534
+ averaged_fids = self.average_fids_by_duration(group_duration=group_duration)
1535
+ if normalisation_factor is None:
1536
+ scaling_factor = np.max(averaged_fids[0, :]) * self.number_of_excitations
1537
+ logger.warning(
1538
+ "Using first averaged fid as normalisation factor. It is "
1539
+ "recommended to provide a normalisation_factor for consistent "
1540
+ "scaling across series. See debug for more details."
1541
+ )
1542
+ else:
1543
+ scaling_factor = normalisation_factor * self.number_of_excitations
1544
+ logger.debug(
1545
+ f"Scaling the FIDs by factor {scaling_factor:.4g} for fitting. "
1546
+ f"Number of excitations: {self.number_of_excitations}"
1547
+ )
1548
+
1549
+ fit_results = []
1550
+ for idx in range(averaged_fids.shape[0]):
1551
+ fid = averaged_fids[idx, :]
1552
+ # Normalize fid
1553
+ fid = fid / scaling_factor
1554
+
1555
+ fit_result = fitting.AMARES.fit_single_fid(
1556
+ fid,
1557
+ init_params,
1558
+ fit_params,
1559
+ raw_header=self.header,
1560
+ )
1561
+ fit_results.append(fit_result)
1562
+
1563
+ self.fit_results = fit_results
1564
+ return fit_results
1565
+
1566
+ def extract_from_fit_results(
1567
+ self, index_key: str = "group_id", index_values: list | None = None
1568
+ ) -> fitting.AMARES.FitResults:
1569
+ """Restructure self.fit_results into a combined FitResults."""
1570
+ restructured_results = []
1571
+ for res in self.fit_results:
1572
+ data = (res.result_multiplets, res.out_obj)
1573
+ restructured_results.append(data)
1574
+
1575
+ extracted_results = fitting.AMARES.extract_from_fit_results(
1576
+ restructured_results, index_key=index_key, index_values=index_values
1577
+ )
1578
+ return extracted_results