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.
- mnutils/GEExam.py +495 -0
- mnutils/GESeries.py +1578 -0
- mnutils/__init__.py +54 -0
- mnutils/_scripts.py +177 -0
- mnutils/fitting/AMARES.py +831 -0
- mnutils/fitting/__init__.py +5 -0
- mnutils/matlab/__init__.py +13 -0
- mnutils/matlab/setup_matlab.py +116 -0
- mnutils/plotting/__init__.py +38 -0
- mnutils/plotting/_widgets/__init__.py +5 -0
- mnutils/plotting/_widgets/_html.py +34 -0
- mnutils/plotting/_widgets/_shared/__init__.py +50 -0
- mnutils/plotting/_widgets/_shared/dom.js +124 -0
- mnutils/plotting/_widgets/_shared/theme.css +140 -0
- mnutils/plotting/_widgets/image_grid.css +202 -0
- mnutils/plotting/_widgets/image_grid.js +212 -0
- mnutils/plotting/_widgets/image_grid.py +86 -0
- mnutils/plotting/_widgets/mrsi_inspector.css +376 -0
- mnutils/plotting/_widgets/mrsi_inspector.js +681 -0
- mnutils/plotting/_widgets/mrsi_inspector.py +70 -0
- mnutils/plotting/_widgets/slice_viewer.css +38 -0
- mnutils/plotting/_widgets/slice_viewer.js +41 -0
- mnutils/plotting/_widgets/slice_viewer.py +35 -0
- mnutils/plotting/images.py +1399 -0
- mnutils/plotting/saving.py +138 -0
- mnutils/plotting/spectra.py +357 -0
- mnutils/py.typed +0 -0
- mnutils/testing/__init__.py +3 -0
- mnutils/testing/_spectra.py +338 -0
- mnutils/testing/synthetic.py +373 -0
- mnutils/utils/__init__.py +25 -0
- mnutils/utils/data_loaders.py +218 -0
- mnutils/utils/file_helpers.py +708 -0
- mnutils/utils/images.py +202 -0
- mnutils/utils/nifti.py +379 -0
- mnutils/utils/spectra.py +149 -0
- mnutils-1.2.0.dist-info/METADATA +161 -0
- mnutils-1.2.0.dist-info/RECORD +41 -0
- mnutils-1.2.0.dist-info/WHEEL +4 -0
- mnutils-1.2.0.dist-info/entry_points.txt +7 -0
- 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
|