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,831 @@
1
+ from dataclasses import dataclass
2
+ from datetime import datetime
3
+ from pathlib import Path
4
+
5
+ import h5py
6
+ import matplotlib.pyplot as plt
7
+ import numpy as np
8
+ import numpy.typing as npt
9
+ import pandas as pd
10
+ import pyAMARES
11
+ import xarray as xr
12
+ import xmris # noqa: F401
13
+ from loguru import logger
14
+ from matplotlib.axes import Axes
15
+ from matplotlib.figure import Figure
16
+
17
+ from ..utils import file_helpers
18
+
19
+ DEFAULT_INIT_PARAMS = {
20
+ "preview": False,
21
+ "xlim": (5, -5),
22
+ "g_global": 0.8,
23
+ "ppm_offset": -4.68, # For now default to water offset
24
+ "normalize_fid": False,
25
+ }
26
+
27
+ DEFAULT_FIT_PARAMS = {
28
+ "method": "leastsq",
29
+ "ifplot": False,
30
+ "inplace": False,
31
+ }
32
+
33
+ DEFAULT_BATCH_FIT_PARAMS = {
34
+ "num_workers": 10,
35
+ "initialize_with_lm": True,
36
+ "method": "leastsq",
37
+ }
38
+
39
+ DEFAULT_PLOT_PARAMS = {"ifphase": True}
40
+
41
+
42
+ def fit_single_fid(
43
+ fid: npt.NDArray | xr.DataArray,
44
+ init_params: dict,
45
+ fit_params: dict | None = None,
46
+ raw_header: dict | None = None,
47
+ filter_by_ppm: tuple[float, float] | None = None,
48
+ ):
49
+ """Fit a single FID using the pyAMARES fitting library.
50
+
51
+ Parameters
52
+ ----------
53
+ fid : ndarray or xarray.DataArray
54
+ The input FID (time-domain signal) to be fitted.
55
+ init_params : dict
56
+ Initial parameters for fitting. Must include `priorknowledgefile`,
57
+ `MHz`, `sw`, and `deadtime` (directly or via `raw_header`).
58
+ fit_params : dict, optional
59
+ Fitting parameters, merged over `DEFAULT_FIT_PARAMS`.
60
+ raw_header : dict, optional
61
+ Raw scanner header, used to derive `MHz`/`sw`/`deadtime` defaults.
62
+ filter_by_ppm : tuple[float, float], optional
63
+ If given, restrict fitting to this ppm range.
64
+
65
+ Returns
66
+ -------
67
+ pyAMARES fit result
68
+ The fitted FID result object.
69
+
70
+ Raises
71
+ ------
72
+ ValueError
73
+ If a required field (`priorknowledgefile`, `MHz`, `sw`, `deadtime`)
74
+ is missing from the merged init parameters.
75
+ """
76
+ if fit_params is None:
77
+ fit_params = {}
78
+
79
+ # Merge default parameters with user-provided parameters
80
+ init_kwargs = DEFAULT_INIT_PARAMS.copy()
81
+ if raw_header is not None:
82
+ init_kwargs.update(vars(pyAMARES.fileio.readfidall.header2par_v73(raw_header)))
83
+ # Then override with any provided init_params
84
+ init_kwargs.update(init_params)
85
+
86
+ # Init kwargs must have the fields priorknowledgefile, MHz, sw, and deadtime
87
+ # Check for those fields and raise error if missing
88
+ required_fields = ["priorknowledgefile", "MHz", "sw", "deadtime"]
89
+ for field in required_fields:
90
+ if field not in init_kwargs:
91
+ raise ValueError(f"Missing required field '{field}' in init_params.")
92
+
93
+ logger.debug(f"Initializing FID with parameters: {init_kwargs}")
94
+
95
+ if isinstance(fid, xr.DataArray):
96
+ fid = fid.values.squeeze()
97
+
98
+ FIDobj = pyAMARES.initialize_FID(
99
+ fid,
100
+ **init_kwargs,
101
+ )
102
+
103
+ fit_kwargs = DEFAULT_FIT_PARAMS.copy()
104
+ # Default to plot for single voxel
105
+ fit_kwargs["ifplot"] = True
106
+ fit_kwargs.update(fit_params)
107
+ # By default turn of linebroadening during plot
108
+ FIDobj.plotParameters.lb = 0
109
+ fit_kwargs["fid_parameters"] = FIDobj
110
+ fit_kwargs["fitting_parameters"] = FIDobj.initialParams
111
+
112
+ if filter_by_ppm is not None:
113
+ logger.info(f"Filtering FID data by ppm range: {filter_by_ppm}")
114
+ FIDobj = pyAMARES.filter_fid_by_ppm(FIDobj, fit_ppm=filter_by_ppm, ifplot=True)
115
+ filtered_params = pyAMARES.filter_param_by_ppm(
116
+ allpara=FIDobj.initialParams, fit_ppm=filter_by_ppm, MHz=FIDobj.MHz
117
+ )
118
+ fit_kwargs["fid_parameters"] = FIDobj
119
+ fit_kwargs["fitting_parameters"] = filtered_params
120
+
121
+ logger.debug(f"Fitting FID with parameters: {fit_kwargs}")
122
+
123
+ return pyAMARES.fitAMARES(**fit_kwargs)
124
+
125
+
126
+ def fit_multiple_fids(
127
+ fids: npt.NDArray | xr.DataArray,
128
+ batch_fitting_params: dict | None = None,
129
+ init_params: dict | None = None,
130
+ batch_fitting_obj=None,
131
+ raw_header: dict | None = None,
132
+ ) -> list:
133
+ """Batch-fit multiple FIDs in parallel using the pyAMARES library.
134
+
135
+ Initializes a batch fitting object from the average FID of the highest
136
+ SNR voxels, then applies parallel fitting to every input FID.
137
+
138
+ Parameters
139
+ ----------
140
+ fids : ndarray or xarray.DataArray
141
+ The input FIDs (time-domain signals) to be fitted. Expected shape is
142
+ `(n_signals, n_points)`.
143
+ batch_fitting_params : dict, optional
144
+ Parameters for batch fitting, merged over `DEFAULT_BATCH_FIT_PARAMS`.
145
+ init_params : dict, optional
146
+ Initial parameters for fitting, merged over `DEFAULT_INIT_PARAMS`.
147
+ batch_fitting_obj : optional
148
+ A pre-initialized batch fitting object to reuse instead of creating
149
+ a new one.
150
+ raw_header : dict, optional
151
+ Raw header information passed to the initial single-FID fit.
152
+
153
+ Returns
154
+ -------
155
+ list[tuple]
156
+ The fitting results for each FID, each including the fitted
157
+ parameters and other relevant information.
158
+
159
+ Notes
160
+ -----
161
+ The batch fitting object is initialized using the average FID of the 10
162
+ highest-SNR voxels (or fewer if there are fewer than 10 FIDs).
163
+ `pyAMARES.run_parallel_fitting_with_progress` performs the parallel
164
+ fitting with a progress indicator.
165
+ """
166
+ if batch_fitting_params is None:
167
+ batch_fitting_params = {}
168
+ if init_params is None:
169
+ init_params = {}
170
+
171
+ batch_fit_kwargs = DEFAULT_BATCH_FIT_PARAMS.copy()
172
+ batch_fit_kwargs.update(batch_fitting_params)
173
+
174
+ if isinstance(fids, xr.DataArray):
175
+ fids = fids.transpose("voxel", "time").values
176
+
177
+ if batch_fitting_obj is None:
178
+ init_kwargs = DEFAULT_INIT_PARAMS.copy()
179
+ init_kwargs.update(init_params)
180
+
181
+ logger.debug(f"Initializing batch fitting with parameters: {init_kwargs}")
182
+
183
+ # The input is multiple FIDs. To initialize the batch object take the average fid
184
+ # of the 10 highest SNR voxels
185
+ n_voxels = 10 if fids.shape[0] >= 10 else fids.shape[0]
186
+ SNRs = np.apply_along_axis(pyAMARES.fidSNR, 1, fids)
187
+ avg_fid = np.mean(fids[np.argsort(SNRs)[-n_voxels:]], axis=0) # type: ignore
188
+
189
+ fit_kwargs = {
190
+ "ifplot": False,
191
+ }
192
+ fit_kwargs["method"] = batch_fit_kwargs.get("method", DEFAULT_BATCH_FIT_PARAMS["method"])
193
+
194
+ batch_fitting_obj = fit_single_fid(
195
+ avg_fid,
196
+ init_params=init_kwargs,
197
+ fit_params=fit_kwargs,
198
+ raw_header=raw_header,
199
+ )
200
+
201
+ results = pyAMARES.run_parallel_fitting_with_progress(
202
+ fids,
203
+ FIDobj_shared=batch_fitting_obj,
204
+ initial_params=batch_fitting_obj.fittedParams, # pyright: ignore[reportAttributeAccessIssue]
205
+ **batch_fit_kwargs,
206
+ )
207
+
208
+ logger.debug(
209
+ f"Completed batch fitting of {len(results)} FIDs. Results type: {type(results)}, "
210
+ f"first element type: {type(results[0]) if results else 'N/A'}"
211
+ )
212
+
213
+ # Print debug messages about the first result. It should be a tuple of
214
+ # (DataFrame, MinimizerResult)
215
+ if results:
216
+ logger.debug(f"First result content: {results[0]}")
217
+
218
+ return results
219
+
220
+
221
+ @dataclass
222
+ class FitResults:
223
+ """Container for extracted pyAMARES batch fitting results."""
224
+
225
+ badfit_ids: npt.NDArray
226
+ goodness_of_fit: pd.DataFrame
227
+ combined_fit_results: pd.DataFrame
228
+
229
+
230
+ def extract_from_fit_results(
231
+ fit_results: list[tuple],
232
+ bad_fit_chisqr_threshold: float = -1.0,
233
+ ppm_offset: float = DEFAULT_INIT_PARAMS["ppm_offset"],
234
+ index_key: str = "voxel_id",
235
+ index_values: list | pd.Index | None = None,
236
+ ) -> FitResults:
237
+ """Extract goodness-of-fit metrics and combined fit parameters from batch fit results.
238
+
239
+ Parameters
240
+ ----------
241
+ fit_results : list of tuple
242
+ Per-voxel results as returned by `fit_multiple_fids`, each a
243
+ `(DataFrame, MinimizerResult)` tuple.
244
+ bad_fit_chisqr_threshold : float, optional
245
+ Chi-squared threshold above which a fit is flagged as bad. If
246
+ negative (default), it is set to 3x the median chi-squared.
247
+ ppm_offset : float, optional
248
+ Offset subtracted from the fitted chemical shift values. Defaults
249
+ to `DEFAULT_INIT_PARAMS["ppm_offset"]`.
250
+ index_key : str, optional
251
+ Name used for the voxel index in the returned DataFrames. Defaults
252
+ to "voxel_id".
253
+ index_values : list or pandas.Index, optional
254
+ Values to use for the voxel index. Must match the length of
255
+ `fit_results` if provided. Defaults to a range index.
256
+
257
+ Returns
258
+ -------
259
+ FitResults
260
+ The bad fit ids, goodness-of-fit metrics, and combined fit results.
261
+ """
262
+ # Extract goodness of fit metrics
263
+ GoF_params_to_extract = ["chisqr", "redchi"]
264
+ goodness_of_fit_metrics = np.full(
265
+ (len(fit_results), len(GoF_params_to_extract)), np.nan, dtype=np.float64
266
+ )
267
+
268
+ # Assert that if index_values is provided, its length matches fit_results
269
+ if index_values is not None and len(index_values) != len(fit_results):
270
+ raise ValueError("Length of index_values must match the number of fit_results.")
271
+
272
+ if index_values is None:
273
+ index_values = list(range(len(fit_results)))
274
+
275
+ for idx, result in enumerate(fit_results):
276
+ # The minimizer results are stored in result[1]
277
+ # Extract chisqr, redchi, aic, and bic from it. AIC and BIC should not be valid for
278
+ # this analysis where we get goodness of fit
279
+ if isinstance(result, tuple) and len(result) > 1 and result[1] is not None:
280
+ minimizer_result = result[1]
281
+ for i, param in enumerate(GoF_params_to_extract):
282
+ if not hasattr(minimizer_result, param):
283
+ logger.warning(f"Minimizer result does not have attribute '{param}'")
284
+ else:
285
+ goodness_of_fit_metrics[idx, i] = getattr(minimizer_result, param)
286
+ else:
287
+ logger.warning(
288
+ f"Result at {idx} is not properly initialized or missing minimizer result."
289
+ )
290
+
291
+ gof_metrics_df = pd.DataFrame(
292
+ goodness_of_fit_metrics,
293
+ columns=GoF_params_to_extract,
294
+ index=pd.Index(index_values, name=index_key),
295
+ )
296
+
297
+ # Select bad fits
298
+ badfit_ids = []
299
+ # Now check for bad fits based on chisqr
300
+ if gof_metrics_df["chisqr"].isna().all():
301
+ median_chisqr = np.nan
302
+ else:
303
+ median_chisqr = np.nanmedian(gof_metrics_df["chisqr"]).astype(float)
304
+ if bad_fit_chisqr_threshold < 0:
305
+ bad_fit_chisqr_threshold = 3 * median_chisqr
306
+
307
+ badfit_ids = gof_metrics_df.index[
308
+ (gof_metrics_df["chisqr"] >= bad_fit_chisqr_threshold) | gof_metrics_df.isna().any(axis=1)
309
+ ].to_numpy()
310
+
311
+ logger.trace(
312
+ f"Bad fits identified: {len(badfit_ids)} out of {len(gof_metrics_df)}. "
313
+ f"Median chisqr={median_chisqr:.2f}, Threshold={bad_fit_chisqr_threshold:.2f}"
314
+ )
315
+
316
+ # Finally combine the result table into a big multi-index DataFrame mapping to more
317
+ # consistent naming
318
+ # Prepare the results for concatenation
319
+ # Fill in the missing results with NaN DataFrames of the same shape and structure
320
+ # filled with np.nan
321
+ # Select an index that is not in badfit_ids to get the structure
322
+ if badfit_ids.size != 0:
323
+ valid_index = next((i for i in range(len(fit_results)) if i not in badfit_ids), None)
324
+ if valid_index is not None:
325
+ template_df = fit_results[valid_index][0].T.copy()
326
+ nan_df = pd.DataFrame(np.nan, index=template_df.index, columns=template_df.columns)
327
+ else:
328
+ logger.warning(
329
+ "No valid index found to create a template DataFrame. This could mean all "
330
+ "fits are bad. Or something else went wrong"
331
+ )
332
+ nan_df = pd.DataFrame()
333
+ else:
334
+ logger.trace("No bad fits detected; all results are valid.")
335
+ nan_df = pd.DataFrame()
336
+
337
+ # Replace missing or invalid results with NaN DataFrames
338
+ prepared_results = [
339
+ result[0].T if result is not None and result[0] is not None else nan_df.copy()
340
+ for result in fit_results
341
+ ]
342
+
343
+ combined_fit_results_df = pd.concat(
344
+ prepared_results,
345
+ keys=index_values,
346
+ names=[index_key, "parameter"],
347
+ )
348
+
349
+ combined_fit_results_df.reset_index(inplace=True, drop=False)
350
+ mapping = {
351
+ "CRLB(%)": ("amplitude", "crlb"),
352
+ "CRLB(LW%)": ("lw", "crlb"),
353
+ "CRLB(cs%) ": ("chem_shift", "crlb"),
354
+ "CRLB(phase%)": ("phase", "crlb"),
355
+ "LW(Hz)": ("lw", "value"),
356
+ "SNR": ("snr", "value"),
357
+ "amplitude": ("amplitude", "value"),
358
+ "chem shift(ppm)": ("chem_shift", "value"),
359
+ "g": ("g", "value"),
360
+ "g (%)": ("g", "crlb"),
361
+ "g_sd": ("g", "sd"),
362
+ "phase(deg)": ("phase", "value"),
363
+ "sd": ("amplitude", "sd"),
364
+ "sd(Hz)": ("lw", "sd"),
365
+ "sd(deg)": ("phase", "sd"),
366
+ "sd(ppm)": ("chem_shift", "sd"),
367
+ }
368
+ # Based on the mapping add the value type and rename the parameter name
369
+ combined_fit_results_df["value_type"] = combined_fit_results_df["parameter"].map(
370
+ lambda x: mapping[str(x)][1]
371
+ )
372
+ combined_fit_results_df["parameter"] = combined_fit_results_df["parameter"].map(
373
+ lambda x: mapping[str(x)][0]
374
+ )
375
+ combined_fit_results_df.set_index([index_key, "parameter", "value_type"], inplace=True)
376
+ # Adjust chem_shift values by ppm_offset (subtract as ppm_offset is defined as water - 4.68)
377
+ combined_fit_results_df.loc[(slice(None), "chem_shift", "value"), :] -= ppm_offset
378
+
379
+ return FitResults(
380
+ badfit_ids=badfit_ids,
381
+ goodness_of_fit=gof_metrics_df,
382
+ combined_fit_results=combined_fit_results_df,
383
+ )
384
+
385
+
386
+ def save_extracted_results(
387
+ path: str | Path,
388
+ fit_results: FitResults,
389
+ attributes_to_add: dict[str, str | int] | None = None,
390
+ additional_datasets: dict[str, npt.NDArray] | None = None,
391
+ prepend_datetime: bool = False,
392
+ move_old: bool = True,
393
+ ) -> Path:
394
+ """Save the extracted fitting results to an HDF5 file.
395
+
396
+ Parameters
397
+ ----------
398
+ path : str or Path
399
+ The file path where the results will be saved. Forced to a `.h5`
400
+ suffix if it doesn't already end in `.h5`/`.hdf5`.
401
+ fit_results : FitResults
402
+ The fitting results to save (badfit IDs, goodness-of-fit metrics,
403
+ and combined fit results).
404
+ attributes_to_add : dict[str, str or int], optional
405
+ Additional attributes to add to the HDF5 file.
406
+ additional_datasets : dict[str, ndarray], optional
407
+ Additional datasets to write into the HDF5 file.
408
+ prepend_datetime : bool, optional
409
+ Whether to prepend the current datetime to the filename.
410
+ move_old : bool, optional
411
+ Whether to move any existing file with the same stem into an "old"
412
+ subfolder first. Defaults to True.
413
+
414
+ Returns
415
+ -------
416
+ Path
417
+ The path to the saved file.
418
+
419
+ Raises
420
+ ------
421
+ FileExistsError
422
+ If the target path already exists (after any datetime prefix).
423
+ OSError, KeyError, ValueError
424
+ If writing the HDF5 file fails.
425
+ """
426
+ if attributes_to_add is None:
427
+ attributes_to_add = {}
428
+ if additional_datasets is None:
429
+ additional_datasets = {}
430
+
431
+ try:
432
+ # Check if path parent directory exists, if not create it
433
+ path = Path(path)
434
+ if not path.parent.exists():
435
+ path.parent.mkdir(parents=True, exist_ok=True)
436
+ # Check if filename ends with .h5 or .hdf5
437
+ if path.suffix not in [".h5", ".hdf5"]:
438
+ # Change suffix to .h5
439
+ path = path.with_suffix(".h5")
440
+
441
+ if move_old:
442
+ file_helpers.move_files_with_glob(path.parent, f"*{path.stem}.*")
443
+
444
+ # Prepend datetime to filename if requested
445
+ if prepend_datetime:
446
+ current_datetime = datetime.now().strftime("%Y%m%d_%H%M%S")
447
+ path = path.with_name(f"{current_datetime}_{path.name}")
448
+
449
+ # Check if file exists and log an error and raise exception
450
+ if path.exists():
451
+ logger.error(f"File {path} already exists. Aborting to prevent overwrite.")
452
+ raise FileExistsError(f"File {path} already exists.")
453
+
454
+ # Add default attribute SAVED_ON with current datetime if not provided
455
+ if "SAVED_ON" not in attributes_to_add:
456
+ attributes_to_add["SAVED_ON"] = datetime.now().isoformat()
457
+
458
+ with pd.HDFStore(path, mode="w") as store:
459
+ store.put(
460
+ "goodness_of_fit",
461
+ fit_results.goodness_of_fit,
462
+ format="table",
463
+ data_columns=True,
464
+ )
465
+ store.put(
466
+ "combined_fit_results",
467
+ fit_results.combined_fit_results,
468
+ format="table",
469
+ data_columns=True,
470
+ )
471
+ # Save badfit_ids as a Series
472
+ store.put(
473
+ "badfit_ids",
474
+ pd.Series(fit_results.badfit_ids),
475
+ data_columns=True,
476
+ )
477
+
478
+ # Add additional attributes and datasets to the HDF5 file in a single open-close operation
479
+ with h5py.File(path, "r+") as h5file:
480
+ for key, value in attributes_to_add.items():
481
+ h5file.attrs[key] = value
482
+
483
+ if additional_datasets:
484
+ for dataset_name, dataset_value in additional_datasets.items():
485
+ if dataset_name in h5file:
486
+ logger.warning(
487
+ f"Dataset '{dataset_name}' already exists and will be overwritten."
488
+ )
489
+ del h5file[dataset_name]
490
+ h5file.create_dataset(
491
+ dataset_name, data=dataset_value, dtype=dataset_value.dtype
492
+ )
493
+
494
+ except (OSError, KeyError, ValueError) as e:
495
+ logger.exception(f"Failed to save extracted results to {path}: {e}")
496
+ raise
497
+ return Path(path)
498
+
499
+
500
+ def load_extracted_results(path: str | Path) -> tuple[FitResults, dict]:
501
+ """Load extracted fitting results from an HDF5 file.
502
+
503
+ Parameters
504
+ ----------
505
+ path : str or Path
506
+ The file path from which the results will be loaded.
507
+
508
+ Returns
509
+ -------
510
+ tuple[FitResults, dict]
511
+ The loaded fitting results, and a dictionary of user attributes
512
+ stored alongside them.
513
+
514
+ Raises
515
+ ------
516
+ FileNotFoundError, KeyError, OSError, ValueError
517
+ If reading the HDF5 file fails.
518
+ """
519
+ try:
520
+ with pd.HDFStore(path, mode="r") as store:
521
+ goodness_of_fit = pd.DataFrame(store["goodness_of_fit"])
522
+ combined_fit_results = pd.DataFrame(store["combined_fit_results"])
523
+ badfit_ids = store["badfit_ids"].to_numpy()
524
+ # Get all additionally stored attributes
525
+ attrs = store.root._v_attrs
526
+ user_attributes = {name: getattr(attrs, name) for name in attrs._v_attrnamesuser}
527
+
528
+ fit_results = FitResults(
529
+ badfit_ids=badfit_ids,
530
+ goodness_of_fit=goodness_of_fit,
531
+ combined_fit_results=combined_fit_results,
532
+ )
533
+ return fit_results, user_attributes
534
+ except (FileNotFoundError, KeyError, OSError, ValueError) as e:
535
+ logger.exception(f"Failed to load extracted results from {path}: {e}")
536
+ raise
537
+
538
+
539
+ def plot_amares_fitting(
540
+ result,
541
+ title: str | None = None,
542
+ fig_kwargs: dict | None = None,
543
+ ax_kwargs: dict | None = None,
544
+ autophase: bool = DEFAULT_PLOT_PARAMS["ifphase"],
545
+ add_table: bool = False,
546
+ ) -> Figure:
547
+ """Plot an AMARES fitting result, optionally with a parameter table.
548
+
549
+ Parameters
550
+ ----------
551
+ result : pyAMARES fit result
552
+ The result object containing the fitting parameters and data.
553
+ title : str, optional
554
+ The title of the plot. Defaults to "AMARES Fitting Result".
555
+ fig_kwargs : dict, optional
556
+ Additional keyword arguments for `plt.subplots`.
557
+ ax_kwargs : dict, optional
558
+ Additional keyword arguments applied to the main axes via `ax.set`.
559
+ autophase : bool, optional
560
+ Whether to apply autophase to the data. Defaults to
561
+ `DEFAULT_PLOT_PARAMS["ifphase"]`.
562
+ add_table : bool, optional
563
+ Whether to add a parameter table beneath the plot. Defaults to False.
564
+
565
+ Returns
566
+ -------
567
+ matplotlib.figure.Figure
568
+ The combined figure.
569
+ """
570
+ if fig_kwargs is None:
571
+ fig_kwargs = {}
572
+ if ax_kwargs is None:
573
+ ax_kwargs = {}
574
+
575
+ fitted_params = result.fittedParams
576
+ plotParameters = result.plotParameters
577
+ ppm = result.ppm - result.ppm_offset
578
+ xlim = np.subtract(plotParameters.xlim, result.ppm_offset)
579
+ xlabel = "ppm"
580
+ mode = "real"
581
+ label = "Fitted Spectrum"
582
+ p_pd = result.amares_to_plot_pd
583
+ # Offset p_pd by ppm_offset
584
+ p_pd["freq"] = p_pd["freq"] - result.ppm_offset
585
+ fid = result.fid
586
+ fid_fit = pyAMARES.kernel.fid.fft_params(
587
+ timeaxis=result.timeaxis, params=fitted_params, fid=True
588
+ )
589
+ hsvdarr = pyAMARES.kernel.fid.fft_params(
590
+ result.timeaxis, fitted_params, fid=True, return_mat=True
591
+ ).T
592
+ if autophase:
593
+ from nmrglue.process import proc_autophase, proc_base
594
+
595
+ fid, opt = proc_autophase.autops(fid, "acme", return_phases=True, disp=False)
596
+ # Then apply the same phase to the fitted fid
597
+ fid_fit = proc_base.ps(fid_fit, p0=opt[0], p1=opt[1])
598
+ # And apply to hsvdarr
599
+ hsvdarr = proc_base.ps(hsvdarr, p0=opt[0], p1=opt[1])
600
+ # Turn of future phasing in plotParameters
601
+ plotParameters.ifphase = False
602
+
603
+ if not add_table:
604
+ # Plot with single axis
605
+ fig, ax_combined = plt.subplots(figsize=(10, 6), **fig_kwargs)
606
+ else:
607
+ # Plot with two axes to accommodate table
608
+ fig, axes = plt.subplots(2, 1, height_ratios=[2, 1], figsize=(10, 8), **fig_kwargs)
609
+ ax_combined: Axes = axes[0]
610
+ ax_table: Axes = axes[1]
611
+ ax_table.axis("off")
612
+
613
+ try:
614
+ # Get table by extracting from results
615
+ extracted_results = extract_from_fit_results(
616
+ [(result.result_multiplets, result.out_obj)]
617
+ )
618
+ table = extracted_results.combined_fit_results.loc[0].T
619
+ # Check if table is dataframe else raise error
620
+ if not isinstance(table, pd.DataFrame):
621
+ raise ValueError(
622
+ "Extracted table is not a pandas DataFrame. Something went wrong "
623
+ "during results extraction."
624
+ )
625
+ add_colored_table_to_plot(ax_table, table)
626
+ # Below the table add a text with the chisqr and redchi values if the
627
+ # result.out_obj is valid and has those attributes
628
+ if result.out_obj is not None:
629
+ chisqr = getattr(result.out_obj, "chisqr", np.nan)
630
+ redchi = getattr(result.out_obj, "redchi", np.nan)
631
+ if chisqr is not np.nan or redchi is not np.nan:
632
+ ax_table.text(
633
+ 0.5,
634
+ 1,
635
+ f"Fit Metrics: Chi-squared: {chisqr:.5g}, "
636
+ f"Reduced Chi-squared: {redchi:.5g}",
637
+ ha="center",
638
+ va="top",
639
+ fontsize=10,
640
+ )
641
+
642
+ except (KeyError, ValueError, TypeError, AttributeError) as e:
643
+ logger.exception(f"Could not add table to plot: {e}")
644
+
645
+ pyAMARES.util.visualization.preview_HSVD(ax_combined, hsvdarr, ppm, p_pd, xlim)
646
+ # Remove latest line plotted on ax_combined (the hsvdarr summed spectrum)
647
+ ax_combined.lines[-1].remove()
648
+ pyAMARES.util.visualization.plot_fit(
649
+ ax_combined, fid, fid_fit, ppm, xlim, mode, label, plotParameters=plotParameters
650
+ )
651
+ # Set x limits
652
+ ax_combined.set_xlabel(xlabel)
653
+ ax_combined.set_xlim(xlim)
654
+ ax_combined.set(**ax_kwargs)
655
+ fig.suptitle(title if title else "AMARES Fitting Result")
656
+
657
+ return fig
658
+
659
+
660
+ def add_colored_table_to_plot(table_ax: Axes, table: pd.DataFrame, sig: int = 3) -> Axes:
661
+ """Add a colored table beneath the given axis in the plot.
662
+
663
+ Parameters
664
+ ----------
665
+ table_ax : matplotlib.axes.Axes
666
+ The axis to which the table will be added.
667
+ table : pandas.DataFrame
668
+ The table data to be displayed, with a 2-level MultiIndex column
669
+ (parameter, value_type).
670
+ sig : int, optional
671
+ The number of significant digits to round the table values to.
672
+ Defaults to 3.
673
+
674
+ Returns
675
+ -------
676
+ matplotlib.axes.Axes
677
+ The axis the table was added to.
678
+ """
679
+ from matplotlib.table import Cell as mplCell
680
+
681
+ rounded_table = table.map(lambda x: f"{x:5.{sig}g}".strip())
682
+
683
+ # Prepare MultiIndex column labels as strings for display
684
+ col_labels = [f"{col[1]}" for col in rounded_table.columns.values]
685
+ row_labels = rounded_table.index.tolist()
686
+
687
+ # Define colors for each value type
688
+ value_type_colors = {
689
+ "value": "#e6f2ff",
690
+ "sd": "#fff2cc",
691
+ "crlb": "#f9d5e5",
692
+ }
693
+ # Map each column to its value type
694
+ col_value_types = [col[1] for col in rounded_table.columns]
695
+
696
+ n_rows, n_cols = rounded_table.shape
697
+
698
+ tbl = table_ax.table(
699
+ cellText=rounded_table.to_numpy().tolist(),
700
+ colLabels=col_labels,
701
+ rowLabels=row_labels,
702
+ cellLoc="center",
703
+ loc="center",
704
+ )
705
+
706
+ tbl.auto_set_font_size(False)
707
+ tbl.set_fontsize(10)
708
+ tbl.scale(1.2, 1.2)
709
+
710
+ # Style header
711
+ for col_idx in range(n_cols):
712
+ cell = tbl[(0, col_idx)]
713
+ cell.set_fontsize(12)
714
+ cell.set_text_props(weight="bold", color="white")
715
+ cell.set_facecolor("#40466e")
716
+
717
+ # Style row labels
718
+ for row_idx in range(n_rows):
719
+ cell = tbl[(row_idx + 1, -1)]
720
+ cell.set_fontsize(12)
721
+ cell.set_text_props(weight="bold", color="#40466e")
722
+ cell.set_facecolor("#e8eaf6")
723
+
724
+ # Style data cells by value type
725
+ for col_idx, value_type in enumerate(col_value_types):
726
+ for row_idx in range(n_rows):
727
+ cell = tbl[(row_idx + 1, col_idx)]
728
+ cell.set_facecolor(value_type_colors.get(value_type, "white"))
729
+
730
+ # Add a row of cells above the header by getting the cells and adding a custom
731
+ # rectangle above the header that spans three columns
732
+ # First figure out how often the multi-index header appears
733
+ column_names = table.columns.get_level_values(0).to_list()
734
+ from itertools import groupby
735
+
736
+ # This gives a list of tuples with (column name, count)
737
+ cells = tbl.get_celld()
738
+ height = cells[1, 0].get_height()
739
+ cell_width = cells[1, 0].get_width()
740
+ columns_counted = [(k, len(list(g))) for k, g in groupby(column_names)]
741
+ start_col = 0
742
+
743
+ # IMPORTANT to update the positions of the cells before querying their positions
744
+ table_ax.figure.canvas.draw()
745
+ for col_name, count in columns_counted:
746
+ width = cell_width * count
747
+ xy = cells[0, start_col].get_xy()
748
+ # Move the new cell up by its height
749
+ xy = (xy[0], xy[1] + height)
750
+ new_cell = mplCell(xy, width=width, height=height, text=col_name, loc="center")
751
+ table_ax.add_patch(new_cell)
752
+ start_col += count
753
+
754
+ return table_ax
755
+
756
+
757
+ def priorknowledge_to_multiindex(
758
+ pk_df: pd.DataFrame, sections: list[str] | None = None
759
+ ) -> pd.DataFrame:
760
+ """Convert a prior-knowledge DataFrame into a MultiIndex DataFrame.
761
+
762
+ Parameters
763
+ ----------
764
+ pk_df : pandas.DataFrame
765
+ The prior-knowledge DataFrame.
766
+ sections : list of str, optional
767
+ Section headers to group by. Defaults to
768
+ `["Initial Values", "Bounds"]`.
769
+
770
+ Returns
771
+ -------
772
+ pandas.DataFrame
773
+ The DataFrame reindexed with a `(Group, Index)` MultiIndex.
774
+ """
775
+ if sections is None:
776
+ sections = ["Initial Values", "Bounds"]
777
+
778
+ # Reset index to ensure 'Index' is a column
779
+ if pk_df.index.name == "Index":
780
+ pk_df = pk_df.reset_index()
781
+
782
+ mask = pk_df["Index"].isin(sections)
783
+ # Create a new column 'Group' and populate it only at the header rows
784
+ pk_df["Group"] = None
785
+ pk_df.loc[mask, "Group"] = pk_df.loc[mask, "Index"]
786
+ # Use forward fill to propagate the Group name to the rows below
787
+ pk_df["Group"] = pk_df["Group"].ffill()
788
+ # Remove the rows that were just header markers
789
+ pk_df = pk_df[~mask]
790
+ pk_df.set_index(["Group", "Index"], inplace=True)
791
+ return pk_df
792
+
793
+
794
+ def multiindex_to_priorknowledge(
795
+ pk_df: pd.DataFrame, group_order: list[str] | None = None
796
+ ) -> pd.DataFrame:
797
+ """Convert a MultiIndex prior-knowledge DataFrame back into a raw DataFrame.
798
+
799
+ Parameters
800
+ ----------
801
+ pk_df : pandas.DataFrame
802
+ The MultiIndex prior-knowledge DataFrame.
803
+ group_order : list of str, optional
804
+ Section headers to group and sort by. Defaults to
805
+ `["Initial Values", "Bounds"]`.
806
+
807
+ Returns
808
+ -------
809
+ pandas.DataFrame
810
+ The flattened, sorted DataFrame.
811
+ """
812
+ if group_order is None:
813
+ group_order = ["Initial Values", "Bounds"]
814
+
815
+ # Reset the index to flatten the MultiIndex
816
+ raw_df = pk_df.reset_index()
817
+ # Extract the 'Group' column and set it as a new row where the group changes
818
+ group_rows = raw_df["Group"].drop_duplicates().reset_index(drop=True)
819
+ group_rows = pd.DataFrame({"Index": group_rows, "Group": None})
820
+ # Append the group rows back to the dataframe
821
+ raw_df = pd.concat([group_rows, raw_df], ignore_index=True)
822
+ # Fill the 'Group' column with the 'Index' values where applicable
823
+ raw_df["Group"] = raw_df["Group"].fillna(raw_df["Index"])
824
+ # Sort the dataframe by the "Group" column, ensuring "Initial Values" comes before "Bounds"
825
+ raw_df["Group"] = pd.Categorical(raw_df["Group"], categories=group_order, ordered=True)
826
+ raw_df.sort_values(by="Group", inplace=True)
827
+ # Reset the index
828
+ raw_df.set_index("Index", inplace=True)
829
+ # Drop the 'Group' column and set 'Index' as the index
830
+ raw_df.drop(columns=["Group"], inplace=True)
831
+ return raw_df