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,138 @@
1
+ """Saving matplotlib figures with per-context (poster/talk/paper) presets."""
2
+
3
+ from datetime import datetime
4
+ from pathlib import Path
5
+ from typing import Literal
6
+
7
+ import matplotlib.pyplot as plt
8
+ from matplotlib.figure import Figure
9
+
10
+ from ..utils import file_helpers
11
+
12
+ contexts = ["poster", "talk", "paper"]
13
+
14
+ DEFAULT_SAVE_PARAMS = {
15
+ "dpi": 150,
16
+ "format": "png",
17
+ "transparent": False,
18
+ "bbox_inches": "tight",
19
+ }
20
+
21
+ POSTER_SAVE_PARAMS = DEFAULT_SAVE_PARAMS.copy()
22
+ POSTER_SAVE_PARAMS.update(
23
+ {
24
+ "dpi": 300,
25
+ }
26
+ )
27
+
28
+ TALK_SAVE_PARAMS = DEFAULT_SAVE_PARAMS.copy()
29
+ TALK_SAVE_PARAMS.update(
30
+ {
31
+ "transparent": True,
32
+ }
33
+ )
34
+
35
+ PAPER_SAVE_PARAMS = DEFAULT_SAVE_PARAMS.copy()
36
+ PAPER_SAVE_PARAMS.update(
37
+ {
38
+ "dpi": 300,
39
+ "pad_inches": 0.1,
40
+ }
41
+ )
42
+
43
+
44
+ def save_figure(
45
+ fig: Figure,
46
+ folder: str | Path,
47
+ filename: str,
48
+ prepend_date: bool = True,
49
+ move_old: bool = True,
50
+ context: Literal["poster", "talk", "paper"] | None = None,
51
+ modify_folder_path: bool = True,
52
+ **kwargs,
53
+ ) -> None:
54
+ """Save a matplotlib figure to a file.
55
+
56
+ Parameters
57
+ ----------
58
+ fig : matplotlib.figure.Figure
59
+ The figure to save.
60
+ folder : str or Path
61
+ The folder to save the figure in.
62
+ filename : str
63
+ The name of the file to save the figure as (without extension).
64
+ prepend_date : bool, optional
65
+ Whether to prepend the current date and time to the filename.
66
+ Defaults to True.
67
+ move_old : bool, optional
68
+ Whether to move old files with the same name to an "old" folder
69
+ first. Defaults to True.
70
+ context : {"poster", "talk", "paper"}, optional
71
+ Preset save parameters (dpi, transparency, padding) to use.
72
+ modify_folder_path : bool, optional
73
+ If True and `context` is given, append `context` to `folder` when
74
+ it isn't already the folder's last component. Defaults to True.
75
+ **kwargs
76
+ Additional keyword arguments passed to `fig.savefig`.
77
+ """
78
+ folder = Path(folder)
79
+ # If folder does not contain "context" as last directory append it to the folder path
80
+ if modify_folder_path and context is not None and folder.name != context:
81
+ folder = folder / context
82
+
83
+ # Combine default save parameters with any provided kwargs
84
+ match context:
85
+ case "poster":
86
+ save_params = POSTER_SAVE_PARAMS.copy()
87
+ case "talk":
88
+ save_params = TALK_SAVE_PARAMS.copy()
89
+ case "paper":
90
+ save_params = PAPER_SAVE_PARAMS.copy()
91
+ case None:
92
+ save_params = DEFAULT_SAVE_PARAMS.copy()
93
+ save_params.update(kwargs)
94
+
95
+ # Create folder if it doesn't exist
96
+ folder.mkdir(parents=True, exist_ok=True)
97
+ if move_old:
98
+ file_helpers.move_files_with_glob(folder, f"*{filename}.*")
99
+
100
+ if prepend_date:
101
+ date_str = datetime.now().strftime("%Y%m%d_%H%M%S")
102
+ filename = f"{date_str}_{filename}"
103
+ file_extension = save_params.get("format", DEFAULT_SAVE_PARAMS["format"])
104
+ full_path = folder / f"{filename}.{file_extension}"
105
+
106
+ fig.savefig(full_path, **save_params)
107
+
108
+
109
+ def save_current_figure(
110
+ folder: str | Path, filename: str, prepend_date: bool = True, **kwargs
111
+ ) -> None:
112
+ """Save the current matplotlib figure to a file.
113
+
114
+ Parameters
115
+ ----------
116
+ folder : str or Path
117
+ The folder to save the figure in.
118
+ filename : str
119
+ The name of the file to save the figure as (without extension).
120
+ prepend_date : bool, optional
121
+ Whether to prepend the current date and time to the filename.
122
+ Defaults to True.
123
+ **kwargs
124
+ Additional keyword arguments forwarded to `save_figure`.
125
+ """
126
+ fig = plt.gcf()
127
+ save_figure(fig, folder, filename, prepend_date, **kwargs)
128
+
129
+
130
+ __all__ = [
131
+ "DEFAULT_SAVE_PARAMS",
132
+ "POSTER_SAVE_PARAMS",
133
+ "TALK_SAVE_PARAMS",
134
+ "PAPER_SAVE_PARAMS",
135
+ "contexts",
136
+ "save_figure",
137
+ "save_current_figure",
138
+ ]
@@ -0,0 +1,357 @@
1
+ from typing import Literal
2
+
3
+ import matplotlib.pyplot as plt
4
+ import matplotlib.ticker as ticker
5
+ import numpy as np
6
+ import numpy.typing as npt
7
+ import pandas as pd
8
+ import seaborn as sns
9
+ import xarray as xr
10
+ from matplotlib.axes import Axes
11
+ from xmris import DIMS
12
+
13
+ DEFAULT_SPECTRA_AX_PARAMS = {
14
+ "xlim": (8.1, -2.1),
15
+ "xlabel": "$^2$H chemical shift [ppm]",
16
+ "ylabel": "signal [a.u.]",
17
+ }
18
+
19
+
20
+ class DefaultTickerParams:
21
+ """Default tick locator parameters used by `set_default_ticks`."""
22
+
23
+ yticker_bins: int | Literal["auto"] = "auto"
24
+ xticker_bins: int | Literal["auto"] = "auto"
25
+ ticker_steps: list[int] = [1, 2, 5]
26
+
27
+
28
+ def set_default_ticks(ax: Axes) -> None:
29
+ """Set default tick parameters for a given Axes object."""
30
+ ax.yaxis.set_major_locator(
31
+ ticker.MaxNLocator(
32
+ nbins=DefaultTickerParams.yticker_bins,
33
+ steps=DefaultTickerParams.ticker_steps,
34
+ )
35
+ )
36
+ ax.xaxis.set_major_locator(
37
+ ticker.MaxNLocator(
38
+ nbins=DefaultTickerParams.xticker_bins,
39
+ steps=DefaultTickerParams.ticker_steps,
40
+ )
41
+ )
42
+ # By default override stylesheets behaviour of hiding ticks and set bottom and left to true
43
+ ax.tick_params(bottom=True, left=True)
44
+
45
+
46
+ def set_default_spectra_ax_params(ax: Axes, **kwargs) -> None:
47
+ """Set default spectra axis parameters for a given Axes object."""
48
+ spectra_params = DEFAULT_SPECTRA_AX_PARAMS.copy()
49
+ spectra_params.update(kwargs)
50
+ ax.set(**spectra_params)
51
+
52
+
53
+ def _resolve_spectra_input(
54
+ data: xr.DataArray | None,
55
+ ppm: npt.NDArray[np.floating] | None,
56
+ spectra: npt.NDArray[np.floating] | None,
57
+ labels: list[str] | None,
58
+ ) -> tuple[npt.NDArray[np.floating], npt.NDArray[np.floating], list[str] | None]:
59
+ """Resolve ppm/spectra arrays and labels from a DataArray or manual fallback args."""
60
+ if data is not None and DIMS.chemical_shift in data.dims:
61
+ ppm = data.coords[DIMS.chemical_shift].values
62
+ spectra = data.values
63
+ if labels is None:
64
+ other_dims = [d for d in data.dims if d != DIMS.chemical_shift]
65
+ if other_dims:
66
+ labels = [str(v) for v in data.coords[other_dims[0]].values]
67
+ return ppm, spectra, labels
68
+
69
+ if ppm is None or spectra is None:
70
+ raise ValueError(
71
+ "Provide either `data` (an xr.DataArray with a "
72
+ f"'{DIMS.chemical_shift}' dim) or both `ppm` and `spectra`."
73
+ )
74
+ return ppm, spectra, labels
75
+
76
+
77
+ def plot_spectra(
78
+ data: xr.DataArray | None = None,
79
+ ppm: npt.NDArray[np.floating] | None = None,
80
+ spectra: npt.NDArray[np.floating] | None = None,
81
+ ax: Axes | None = None,
82
+ labels: list[str] | None = None,
83
+ line_kwargs: dict | list[dict] | None = None,
84
+ **kwargs,
85
+ ) -> Axes:
86
+ """Plot one or more spectra against a chemical-shift (ppm) axis.
87
+
88
+ Pass already-phased data — either `data` (an `xr.DataArray` with a
89
+ `chemical_shift` dim/coord, e.g. from `MRSSeries.phase_avg_spec` or
90
+ `.xmr.phase()`/`.xmr.autophase()`) or, as a manual fallback, both `ppm`
91
+ and `spectra` as plain arrays. This function does no phasing itself.
92
+ """
93
+ resolved_ppm, resolved_spectra, resolved_labels = _resolve_spectra_input(
94
+ data, ppm, spectra, labels
95
+ )
96
+ if resolved_labels is None:
97
+ resolved_labels = []
98
+
99
+ if ax is None:
100
+ _, ax = plt.subplots()
101
+
102
+ spectra_arr = np.asarray(resolved_spectra)
103
+ if spectra_arr.ndim == 1:
104
+ spectra_arr = spectra_arr[np.newaxis, :] # Convert to 2D for consistency
105
+ if np.iscomplexobj(spectra_arr):
106
+ spectra_arr = spectra_arr.real
107
+ spectra_arr = spectra_arr.astype(np.float64)
108
+
109
+ if isinstance(line_kwargs, list):
110
+ if len(line_kwargs) != spectra_arr.shape[0]:
111
+ raise ValueError("Length of line_kwargs list must match number of spectra.")
112
+ elif isinstance(line_kwargs, dict):
113
+ line_kwargs = [line_kwargs] * spectra_arr.shape[0]
114
+
115
+ lines = []
116
+ for spec_idx in range(spectra_arr.shape[0]):
117
+ kwargs_to_use = {} if line_kwargs is None else line_kwargs[spec_idx]
118
+ label = (
119
+ resolved_labels[spec_idx]
120
+ if spec_idx < len(resolved_labels)
121
+ else f"Spectrum {spec_idx + 1}"
122
+ )
123
+ (line,) = ax.plot(
124
+ resolved_ppm,
125
+ spectra_arr[spec_idx],
126
+ label=label,
127
+ **kwargs_to_use,
128
+ )
129
+ lines.append(line)
130
+
131
+ set_default_ticks(ax)
132
+ set_default_spectra_ax_params(ax, **kwargs)
133
+
134
+ if spectra_arr.shape[0] > 1 or resolved_labels:
135
+ ax.legend()
136
+
137
+ return ax
138
+
139
+
140
+ def _resolve_fid_input(
141
+ data: xr.DataArray | None,
142
+ time: npt.NDArray[np.floating] | None,
143
+ fids: npt.NDArray[np.complexfloating | np.floating] | None,
144
+ ) -> tuple[npt.NDArray[np.floating], npt.NDArray[np.complexfloating | np.floating], str]:
145
+ """Resolve a time axis (in ms) and fid values from a DataArray or manual fallback args."""
146
+ if data is not None and DIMS.time in data.dims:
147
+ time_axis = data.coords[DIMS.time].values * 1e3 # s -> ms
148
+ return time_axis, data.values, "Time [ms]"
149
+
150
+ if time is None or fids is None:
151
+ raise ValueError(
152
+ f"Provide either `data` (an xr.DataArray with a '{DIMS.time}' dim) "
153
+ "or both `time` and `fids`."
154
+ )
155
+ return time, fids, "Time [ms]"
156
+
157
+
158
+ def plot_fid(
159
+ data: xr.DataArray | None = None,
160
+ time: npt.NDArray[np.floating] | None = None,
161
+ fids: npt.NDArray[np.complexfloating | np.floating] | None = None,
162
+ ax: Axes | None = None,
163
+ labels: list[str] | None = None,
164
+ show_real: bool = True,
165
+ show_imag: bool = True,
166
+ **kwargs,
167
+ ) -> Axes:
168
+ """Plot one or more FIDs against a time axis.
169
+
170
+ Pass either `data` (an `xr.DataArray` with a `time` dim/coord, e.g.
171
+ `RawMRISeries.fids`) or, as a manual fallback, both `time` (in ms) and
172
+ `fids` as plain arrays. This function does no processing itself — build
173
+ the time axis you want to see on the DataArray/array before calling.
174
+ """
175
+ if labels is None:
176
+ labels = []
177
+
178
+ xaxis, fid_values, xaxis_label = _resolve_fid_input(data, time, fids)
179
+ fid_values = np.asarray(np.copy(fid_values))
180
+ if fid_values.ndim == 1:
181
+ fid_values = fid_values[np.newaxis, :] # Convert to 2D for consistency
182
+
183
+ if ax is None:
184
+ _, ax = plt.subplots()
185
+
186
+ for i in range(fid_values.shape[0]):
187
+ label_real = f"{labels[i]} (Real)" if i < len(labels) else f"FID {i + 1} (Real)"
188
+ label_imag = f"{labels[i]} (Imag)" if i < len(labels) else f"FID {i + 1} (Imag)"
189
+ if show_real:
190
+ ax.plot(xaxis, np.real(fid_values[i, :]), label=label_real)
191
+ if show_imag and np.iscomplexobj(fid_values):
192
+ ax.plot(xaxis, np.imag(fid_values[i, :]), label=label_imag, linestyle="--")
193
+
194
+ ax.legend()
195
+ ax.set(**kwargs)
196
+
197
+ ax.set_xlabel(xaxis_label)
198
+ ax.set_ylabel("FID signal [a.u.]")
199
+
200
+ set_default_ticks(ax)
201
+
202
+ sns.set_style("ticks")
203
+ sns.set_palette("colorblind")
204
+ sns.despine()
205
+
206
+ return ax
207
+
208
+
209
+ def annotate_metabolites(ax: Axes | None = None, freqs: dict | None = None) -> Axes:
210
+ """Annotate metabolite labels on a plot at specific ppm positions.
211
+
212
+ Parameters
213
+ ----------
214
+ ax : matplotlib.axes.Axes, optional
215
+ The axes to annotate. Defaults to the current axes.
216
+ freqs : dict, optional
217
+ Mapping of metabolite name to ppm position. Defaults to a predefined
218
+ set of common metabolites (DHO, Glu, Glx, Lac).
219
+
220
+ Returns
221
+ -------
222
+ matplotlib.axes.Axes
223
+ The annotated axes.
224
+ """
225
+ if ax is None:
226
+ ax = plt.gca()
227
+
228
+ if freqs is None:
229
+ freqs = {
230
+ "DHO": 4.68,
231
+ "Glu": 3.7,
232
+ "Glx": 2.3,
233
+ "Lac": 1.35,
234
+ # "Lip": 0.9,
235
+ }
236
+
237
+ # Get lines and find max value at specific ppm positions
238
+ data = np.array([line.get_ydata() for line in ax.get_lines()])
239
+ ppm_axis = np.array([line.get_xdata() for line in ax.get_lines()])[0]
240
+
241
+ # Get y limits of plot
242
+ ylims = ax.get_ylim()
243
+ label_offset = (ylims[1] - ylims[0]) * 0.1
244
+
245
+ # Plot labels for all the freq offsets slightly above the max value at that position
246
+ for name, ppm in freqs.items():
247
+ # Get the y-value at the specific ppm position
248
+ ppm_index = np.argmin(np.abs(ppm_axis - ppm))
249
+ max_val = np.max(data[:, ppm_index])
250
+ # Only center 4.68 label, others aligned to left
251
+ if ppm == 4.68:
252
+ ha = "center"
253
+ else:
254
+ ha = "left"
255
+
256
+ ax.text(
257
+ ppm,
258
+ max_val + label_offset, # Slightly above the data line
259
+ name,
260
+ rotation=0,
261
+ verticalalignment="top",
262
+ horizontalalignment=ha,
263
+ color="black",
264
+ fontweight="bold",
265
+ )
266
+ return ax
267
+
268
+
269
+ def plot_spectra_over_time(
270
+ df: pd.DataFrame,
271
+ labels: list[str] | None = None,
272
+ title: str = "MRS Unlocalized Spectra Over Time",
273
+ **kwargs,
274
+ ) -> sns.FacetGrid:
275
+ """Plot MRS unlocalized spectra over time using a FacetGrid.
276
+
277
+ Parameters
278
+ ----------
279
+ df : pandas.DataFrame
280
+ DataFrame containing the spectra data with columns
281
+ `["ppm", "Spectra_ID", "Intensity"]`.
282
+ labels : list of str, optional
283
+ Labels for the spectra. Defaults to an empty list.
284
+ title : str, optional
285
+ Figure title. Defaults to "MRS Unlocalized Spectra Over Time".
286
+
287
+ Returns
288
+ -------
289
+ seaborn.FacetGrid
290
+ The resulting FacetGrid.
291
+ """
292
+ if labels is None:
293
+ labels = []
294
+
295
+ sns.set_theme(style="white", rc={"axes.facecolor": (0, 0, 0, 0)})
296
+
297
+ # 1. Initialize the FacetGrid
298
+ pal = sns.cubehelix_palette(df["Spectra_ID"].nunique(), rot=-0.25, light=0.7)
299
+ spectra_grid = sns.FacetGrid(
300
+ df,
301
+ row="Spectra_ID", # Maps each spectrum to a column (facet)
302
+ hue="Spectra_ID",
303
+ sharey=True, # Set to True if all spectra have a similar intensity range
304
+ height=1, # Height of each facet
305
+ aspect=5, # Aspect ratio of each facet
306
+ palette=pal,
307
+ )
308
+
309
+ # 2. Map the plotting function
310
+ spectra_grid.map(
311
+ sns.lineplot,
312
+ "ppm", # Your X-axis
313
+ "Intensity", # Your Y-axis
314
+ )
315
+
316
+ # Add labels to each subplot
317
+ if labels:
318
+ for ax, label in zip(spectra_grid.axes.flatten(), labels):
319
+ # Check how many lines the label will take
320
+ n_lines = label.count("\n") + 1
321
+ if n_lines > 1:
322
+ ypos = 0.05 + (n_lines - 1) * 0.15
323
+ else:
324
+ ypos = 0.05
325
+
326
+ ax.text(
327
+ 0.0,
328
+ ypos,
329
+ label,
330
+ fontweight="bold",
331
+ color=ax.lines[0].get_color(),
332
+ ha="left",
333
+ va="center",
334
+ transform=ax.transAxes,
335
+ )
336
+
337
+ # 3. Apply standard spectral convention (reverse x-axis)
338
+ spectra_grid.set(**DEFAULT_SPECTRA_AX_PARAMS) # Apply default spectra plotting params
339
+ # Set x limits from 10 to 0 ppm
340
+ spectra_grid.set(xlim=(10, -2))
341
+ spectra_grid.set(**kwargs)
342
+
343
+ spectra_grid.figure.subplots_adjust(hspace=-0.75) # Overlay plots vertically
344
+
345
+ # Remove axes details that don't play well with overlap
346
+ spectra_grid.set_titles("")
347
+ spectra_grid.set(yticks=[], ylabel="")
348
+ spectra_grid.despine(bottom=True, left=True)
349
+ spectra_grid.axes[-1][0].spines["bottom"].set_visible(True)
350
+
351
+ # 4. Final adjustments
352
+ spectra_grid.figure.suptitle(
353
+ f"{title}",
354
+ y=1.0,
355
+ fontsize=12,
356
+ )
357
+ return spectra_grid
mnutils/py.typed ADDED
File without changes
@@ -0,0 +1,3 @@
1
+ from .synthetic import build_fake_exam
2
+
3
+ __all__ = ["build_fake_exam"]