• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

OpenCOMPES / sed / 9636247350

23 Jun 2024 08:32PM UTC coverage: 91.894% (+0.08%) from 91.814%
9636247350

Pull #411

github

rettigl
Merge remote-tracking branch 'origin/v1_feature_branch' into energy_calibration_bias_shift
Pull Request #411: Energy calibration bias shift

104 of 116 new or added lines in 4 files covered. (89.66%)

125 existing lines in 3 files now uncovered.

6462 of 7032 relevant lines covered (91.89%)

0.92 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

86.22
/sed/core/processor.py
1
"""This module contains the core class for the sed package
2

3
"""
4
from __future__ import annotations
1✔
5

6
import pathlib
1✔
7
from collections.abc import Sequence
1✔
8
from datetime import datetime
1✔
9
from typing import Any
1✔
10
from typing import cast
1✔
11

12
import dask.dataframe as ddf
1✔
13
import matplotlib.pyplot as plt
1✔
14
import numpy as np
1✔
15
import pandas as pd
1✔
16
import psutil
1✔
17
import xarray as xr
1✔
18

19
from sed.binning import bin_dataframe
1✔
20
from sed.binning.binning import normalization_histogram_from_timed_dataframe
1✔
21
from sed.binning.binning import normalization_histogram_from_timestamps
1✔
22
from sed.calibrator import DelayCalibrator
1✔
23
from sed.calibrator import EnergyCalibrator
1✔
24
from sed.calibrator import MomentumCorrector
1✔
25
from sed.core.config import parse_config
1✔
26
from sed.core.config import save_config
1✔
27
from sed.core.dfops import add_time_stamped_data
1✔
28
from sed.core.dfops import apply_filter
1✔
29
from sed.core.dfops import apply_jitter
1✔
30
from sed.core.metadata import MetaHandler
1✔
31
from sed.diagnostics import grid_histogram
1✔
32
from sed.io import to_h5
1✔
33
from sed.io import to_nexus
1✔
34
from sed.io import to_tiff
1✔
35
from sed.loader import CopyTool
1✔
36
from sed.loader import get_loader
1✔
37
from sed.loader.mpes.loader import get_archiver_data
1✔
38
from sed.loader.mpes.loader import MpesLoader
1✔
39

40
N_CPU = psutil.cpu_count()
1✔
41

42

43
class SedProcessor:
1✔
44
    """Processor class of sed. Contains wrapper functions defining a work flow for data
45
    correction, calibration and binning.
46

47
    Args:
48
        metadata (dict, optional): Dict of external Metadata. Defaults to None.
49
        config (dict | str, optional): Config dictionary or config file name.
50
            Defaults to None.
51
        dataframe (pd.DataFrame | ddf.DataFrame, optional): dataframe to load
52
            into the class. Defaults to None.
53
        files (list[str], optional): List of files to pass to the loader defined in
54
            the config. Defaults to None.
55
        folder (str, optional): Folder containing files to pass to the loader
56
            defined in the config. Defaults to None.
57
        runs (Sequence[str], optional): List of run identifiers to pass to the loader
58
            defined in the config. Defaults to None.
59
        collect_metadata (bool): Option to collect metadata from files.
60
            Defaults to False.
61
        verbose (bool, optional): Option to print out diagnostic information.
62
            Defaults to config["core"]["verbose"] or False.
63
        **kwds: Keyword arguments passed to the reader.
64
    """
65

66
    def __init__(
1✔
67
        self,
68
        metadata: dict = None,
69
        config: dict | str = None,
70
        dataframe: pd.DataFrame | ddf.DataFrame = None,
71
        files: list[str] = None,
72
        folder: str = None,
73
        runs: Sequence[str] = None,
74
        collect_metadata: bool = False,
75
        verbose: bool = None,
76
        **kwds,
77
    ):
78
        """Processor class of sed. Contains wrapper functions defining a work flow
79
        for data correction, calibration, and binning.
80

81
        Args:
82
            metadata (dict, optional): Dict of external Metadata. Defaults to None.
83
            config (dict | str, optional): Config dictionary or config file name.
84
                Defaults to None.
85
            dataframe (pd.DataFrame | ddf.DataFrame, optional): dataframe to load
86
                into the class. Defaults to None.
87
            files (list[str], optional): List of files to pass to the loader defined in
88
                the config. Defaults to None.
89
            folder (str, optional): Folder containing files to pass to the loader
90
                defined in the config. Defaults to None.
91
            runs (Sequence[str], optional): List of run identifiers to pass to the loader
92
                defined in the config. Defaults to None.
93
            collect_metadata (bool, optional): Option to collect metadata from files.
94
                Defaults to False.
95
            verbose (bool, optional): Option to print out diagnostic information.
96
                Defaults to config["core"]["verbose"] or False.
97
            **kwds: Keyword arguments passed to parse_config and to the reader.
98
        """
99
        config_kwds = {
1✔
100
            key: value for key, value in kwds.items() if key in parse_config.__code__.co_varnames
101
        }
102
        for key in config_kwds.keys():
1✔
103
            del kwds[key]
1✔
104
        self._config = parse_config(config, **config_kwds)
1✔
105
        num_cores = self._config.get("binning", {}).get("num_cores", N_CPU - 1)
1✔
106
        if num_cores >= N_CPU:
1✔
107
            num_cores = N_CPU - 1
1✔
108
        self._config["binning"]["num_cores"] = num_cores
1✔
109

110
        if verbose is None:
1✔
111
            self.verbose = self._config["core"].get("verbose", False)
1✔
112
        else:
113
            self.verbose = verbose
1✔
114

115
        self._dataframe: pd.DataFrame | ddf.DataFrame = None
1✔
116
        self._timed_dataframe: pd.DataFrame | ddf.DataFrame = None
1✔
117
        self._files: list[str] = []
1✔
118

119
        self._binned: xr.DataArray = None
1✔
120
        self._pre_binned: xr.DataArray = None
1✔
121
        self._normalization_histogram: xr.DataArray = None
1✔
122
        self._normalized: xr.DataArray = None
1✔
123

124
        self._attributes = MetaHandler(meta=metadata)
1✔
125

126
        loader_name = self._config["core"]["loader"]
1✔
127
        self.loader = get_loader(
1✔
128
            loader_name=loader_name,
129
            config=self._config,
130
        )
131

132
        self.ec = EnergyCalibrator(
1✔
133
            loader=get_loader(
134
                loader_name=loader_name,
135
                config=self._config,
136
            ),
137
            config=self._config,
138
        )
139

140
        self.mc = MomentumCorrector(
1✔
141
            config=self._config,
142
        )
143

144
        self.dc = DelayCalibrator(
1✔
145
            config=self._config,
146
        )
147

148
        self.use_copy_tool = self._config.get("core", {}).get(
1✔
149
            "use_copy_tool",
150
            False,
151
        )
152
        if self.use_copy_tool:
1✔
153
            try:
1✔
154
                self.ct = CopyTool(
1✔
155
                    source=self._config["core"]["copy_tool_source"],
156
                    dest=self._config["core"]["copy_tool_dest"],
157
                    **self._config["core"].get("copy_tool_kwds", {}),
158
                )
159
            except KeyError:
1✔
160
                self.use_copy_tool = False
1✔
161

162
        # Load data if provided:
163
        if dataframe is not None or files is not None or folder is not None or runs is not None:
1✔
164
            self.load(
1✔
165
                dataframe=dataframe,
166
                metadata=metadata,
167
                files=files,
168
                folder=folder,
169
                runs=runs,
170
                collect_metadata=collect_metadata,
171
                **kwds,
172
            )
173

174
    def __repr__(self):
1✔
175
        if self._dataframe is None:
1✔
176
            df_str = "Dataframe: No Data loaded"
1✔
177
        else:
178
            df_str = self._dataframe.__repr__()
1✔
179
        pretty_str = df_str + "\n" + "Metadata: " + "\n" + self._attributes.__repr__()
1✔
180
        return pretty_str
1✔
181

182
    def _repr_html_(self):
1✔
183
        html = "<div>"
×
184

185
        if self._dataframe is None:
×
186
            df_html = "Dataframe: No Data loaded"
×
187
        else:
188
            df_html = self._dataframe._repr_html_()
×
189

190
        html += f"<details><summary>Dataframe</summary>{df_html}</details>"
×
191

192
        # Add expandable section for attributes
193
        html += "<details><summary>Metadata</summary>"
×
194
        html += "<div style='padding-left: 10px;'>"
×
195
        html += self._attributes._repr_html_()
×
196
        html += "</div></details>"
×
197

198
        html += "</div>"
×
199

200
        return html
×
201

202
    ## Suggestion:
203
    # @property
204
    # def overview_panel(self):
205
    #     """Provides an overview panel with plots of different data attributes."""
206
    #     self.view_event_histogram(dfpid=2, backend="matplotlib")
207

208
    @property
1✔
209
    def dataframe(self) -> pd.DataFrame | ddf.DataFrame:
1✔
210
        """Accessor to the underlying dataframe.
211

212
        Returns:
213
            pd.DataFrame | ddf.DataFrame: Dataframe object.
214
        """
215
        return self._dataframe
1✔
216

217
    @dataframe.setter
1✔
218
    def dataframe(self, dataframe: pd.DataFrame | ddf.DataFrame):
1✔
219
        """Setter for the underlying dataframe.
220

221
        Args:
222
            dataframe (pd.DataFrame | ddf.DataFrame): The dataframe object to set.
223
        """
224
        if not isinstance(dataframe, (pd.DataFrame, ddf.DataFrame)) or not isinstance(
1✔
225
            dataframe,
226
            self._dataframe.__class__,
227
        ):
228
            raise ValueError(
1✔
229
                "'dataframe' has to be a Pandas or Dask dataframe and has to be of the same kind "
230
                "as the dataframe loaded into the SedProcessor!.\n"
231
                f"Loaded type: {self._dataframe.__class__}, provided type: {dataframe}.",
232
            )
233
        self._dataframe = dataframe
1✔
234

235
    @property
1✔
236
    def timed_dataframe(self) -> pd.DataFrame | ddf.DataFrame:
1✔
237
        """Accessor to the underlying timed_dataframe.
238

239
        Returns:
240
            pd.DataFrame | ddf.DataFrame: Timed Dataframe object.
241
        """
242
        return self._timed_dataframe
1✔
243

244
    @timed_dataframe.setter
1✔
245
    def timed_dataframe(self, timed_dataframe: pd.DataFrame | ddf.DataFrame):
1✔
246
        """Setter for the underlying timed dataframe.
247

248
        Args:
249
            timed_dataframe (pd.DataFrame | ddf.DataFrame): The timed dataframe object to set
250
        """
251
        if not isinstance(timed_dataframe, (pd.DataFrame, ddf.DataFrame)) or not isinstance(
×
252
            timed_dataframe,
253
            self._timed_dataframe.__class__,
254
        ):
255
            raise ValueError(
×
256
                "'timed_dataframe' has to be a Pandas or Dask dataframe and has to be of the same "
257
                "kind as the dataframe loaded into the SedProcessor!.\n"
258
                f"Loaded type: {self._timed_dataframe.__class__}, "
259
                f"provided type: {timed_dataframe}.",
260
            )
261
        self._timed_dataframe = timed_dataframe
×
262

263
    @property
1✔
264
    def attributes(self) -> MetaHandler:
1✔
265
        """Accessor to the metadata dict.
266

267
        Returns:
268
            MetaHandler: The metadata object
269
        """
270
        return self._attributes
1✔
271

272
    def add_attribute(self, attributes: dict, name: str, **kwds):
1✔
273
        """Function to add element to the attributes dict.
274

275
        Args:
276
            attributes (dict): The attributes dictionary object to add.
277
            name (str): Key under which to add the dictionary to the attributes.
278
        """
279
        self._attributes.add(
1✔
280
            entry=attributes,
281
            name=name,
282
            **kwds,
283
        )
284

285
    @property
1✔
286
    def config(self) -> dict[Any, Any]:
1✔
287
        """Getter attribute for the config dictionary
288

289
        Returns:
290
            dict: The config dictionary.
291
        """
292
        return self._config
1✔
293

294
    @property
1✔
295
    def files(self) -> list[str]:
1✔
296
        """Getter attribute for the list of files
297

298
        Returns:
299
            list[str]: The list of loaded files
300
        """
301
        return self._files
1✔
302

303
    @property
1✔
304
    def binned(self) -> xr.DataArray:
1✔
305
        """Getter attribute for the binned data array
306

307
        Returns:
308
            xr.DataArray: The binned data array
309
        """
310
        if self._binned is None:
1✔
311
            raise ValueError("No binned data available, need to compute histogram first!")
×
312
        return self._binned
1✔
313

314
    @property
1✔
315
    def normalized(self) -> xr.DataArray:
1✔
316
        """Getter attribute for the normalized data array
317

318
        Returns:
319
            xr.DataArray: The normalized data array
320
        """
321
        if self._normalized is None:
1✔
322
            raise ValueError(
×
323
                "No normalized data available, compute data with normalization enabled!",
324
            )
325
        return self._normalized
1✔
326

327
    @property
1✔
328
    def normalization_histogram(self) -> xr.DataArray:
1✔
329
        """Getter attribute for the normalization histogram
330

331
        Returns:
332
            xr.DataArray: The normalizazion histogram
333
        """
334
        if self._normalization_histogram is None:
1✔
335
            raise ValueError("No normalization histogram available, generate histogram first!")
×
336
        return self._normalization_histogram
1✔
337

338
    def cpy(self, path: str | list[str]) -> str | list[str]:
1✔
339
        """Function to mirror a list of files or a folder from a network drive to a
340
        local storage. Returns either the original or the copied path to the given
341
        path. The option to use this functionality is set by
342
        config["core"]["use_copy_tool"].
343

344
        Args:
345
            path (str | list[str]): Source path or path list.
346

347
        Returns:
348
            str | list[str]: Source or destination path or path list.
349
        """
350
        if self.use_copy_tool:
1✔
351
            if isinstance(path, list):
1✔
352
                path_out = []
1✔
353
                for file in path:
1✔
354
                    path_out.append(self.ct.copy(file))
1✔
355
                return path_out
1✔
356

357
            return self.ct.copy(path)
×
358

359
        if isinstance(path, list):
1✔
360
            return path
1✔
361

362
        return path
1✔
363

364
    def load(
1✔
365
        self,
366
        dataframe: pd.DataFrame | ddf.DataFrame = None,
367
        metadata: dict = None,
368
        files: list[str] = None,
369
        folder: str = None,
370
        runs: Sequence[str] = None,
371
        collect_metadata: bool = False,
372
        **kwds,
373
    ):
374
        """Load tabular data of single events into the dataframe object in the class.
375

376
        Args:
377
            dataframe (pd.DataFrame | ddf.DataFrame, optional): data in tabular
378
                format. Accepts anything which can be interpreted by pd.DataFrame as
379
                an input. Defaults to None.
380
            metadata (dict, optional): Dict of external Metadata. Defaults to None.
381
            files (list[str], optional): List of file paths to pass to the loader.
382
                Defaults to None.
383
            runs (Sequence[str], optional): List of run identifiers to pass to the
384
                loader. Defaults to None.
385
            folder (str, optional): Folder path to pass to the loader.
386
                Defaults to None.
387
            collect_metadata (bool, optional): Option for collecting metadata in the reader.
388
            **kwds: Keyword parameters passed to the reader.
389

390
        Raises:
391
            ValueError: Raised if no valid input is provided.
392
        """
393
        if metadata is None:
1✔
394
            metadata = {}
1✔
395
        if dataframe is not None:
1✔
396
            timed_dataframe = kwds.pop("timed_dataframe", None)
1✔
397
        elif runs is not None:
1✔
398
            # If runs are provided, we only use the copy tool if also folder is provided.
399
            # In that case, we copy the whole provided base folder tree, and pass the copied
400
            # version to the loader as base folder to look for the runs.
401
            if folder is not None:
1✔
402
                dataframe, timed_dataframe, metadata = self.loader.read_dataframe(
1✔
403
                    folders=cast(str, self.cpy(folder)),
404
                    runs=runs,
405
                    metadata=metadata,
406
                    collect_metadata=collect_metadata,
407
                    **kwds,
408
                )
409
            else:
410
                dataframe, timed_dataframe, metadata = self.loader.read_dataframe(
×
411
                    runs=runs,
412
                    metadata=metadata,
413
                    collect_metadata=collect_metadata,
414
                    **kwds,
415
                )
416

417
        elif folder is not None:
1✔
418
            dataframe, timed_dataframe, metadata = self.loader.read_dataframe(
1✔
419
                folders=cast(str, self.cpy(folder)),
420
                metadata=metadata,
421
                collect_metadata=collect_metadata,
422
                **kwds,
423
            )
424
        elif files is not None:
1✔
425
            dataframe, timed_dataframe, metadata = self.loader.read_dataframe(
1✔
426
                files=cast(list[str], self.cpy(files)),
427
                metadata=metadata,
428
                collect_metadata=collect_metadata,
429
                **kwds,
430
            )
431
        else:
432
            raise ValueError(
1✔
433
                "Either 'dataframe', 'files', 'folder', or 'runs' needs to be provided!",
434
            )
435

436
        self._dataframe = dataframe
1✔
437
        self._timed_dataframe = timed_dataframe
1✔
438
        self._files = self.loader.files
1✔
439

440
        for key in metadata:
1✔
441
            self._attributes.add(
1✔
442
                entry=metadata[key],
443
                name=key,
444
                duplicate_policy="merge",
445
            )
446

447
    def filter_column(
1✔
448
        self,
449
        column: str,
450
        min_value: float = -np.inf,
451
        max_value: float = np.inf,
452
    ) -> None:
453
        """Filter values in a column which are outside of a given range
454

455
        Args:
456
            column (str): Name of the column to filter
457
            min_value (float, optional): Minimum value to keep. Defaults to None.
458
            max_value (float, optional): Maximum value to keep. Defaults to None.
459
        """
460
        if column != "index" and column not in self._dataframe.columns:
1✔
461
            raise KeyError(f"Column {column} not found in dataframe!")
1✔
462
        if min_value >= max_value:
1✔
463
            raise ValueError("min_value has to be smaller than max_value!")
1✔
464
        if self._dataframe is not None:
1✔
465
            self._dataframe = apply_filter(
1✔
466
                self._dataframe,
467
                col=column,
468
                lower_bound=min_value,
469
                upper_bound=max_value,
470
            )
471
        if self._timed_dataframe is not None and column in self._timed_dataframe.columns:
1✔
472
            self._timed_dataframe = apply_filter(
1✔
473
                self._timed_dataframe,
474
                column,
475
                lower_bound=min_value,
476
                upper_bound=max_value,
477
            )
478
        metadata = {
1✔
479
            "filter": {
480
                "column": column,
481
                "min_value": min_value,
482
                "max_value": max_value,
483
            },
484
        }
485
        self._attributes.add(metadata, "filter", duplicate_policy="merge")
1✔
486

487
    # Momentum calibration workflow
488
    # 1. Bin raw detector data for distortion correction
489
    def bin_and_load_momentum_calibration(
1✔
490
        self,
491
        df_partitions: int | Sequence[int] = 100,
492
        axes: list[str] = None,
493
        bins: list[int] = None,
494
        ranges: Sequence[tuple[float, float]] = None,
495
        plane: int = 0,
496
        width: int = 5,
497
        apply: bool = False,
498
        **kwds,
499
    ):
500
        """1st step of momentum correction work flow. Function to do an initial binning
501
        of the dataframe loaded to the class, slice a plane from it using an
502
        interactive view, and load it into the momentum corrector class.
503

504
        Args:
505
            df_partitions (int | Sequence[int], optional): Number of dataframe partitions
506
                to use for the initial binning. Defaults to 100.
507
            axes (list[str], optional): Axes to bin.
508
                Defaults to config["momentum"]["axes"].
509
            bins (list[int], optional): Bin numbers to use for binning.
510
                Defaults to config["momentum"]["bins"].
511
            ranges (Sequence[tuple[float, float]], optional): Ranges to use for binning.
512
                Defaults to config["momentum"]["ranges"].
513
            plane (int, optional): Initial value for the plane slider. Defaults to 0.
514
            width (int, optional): Initial value for the width slider. Defaults to 5.
515
            apply (bool, optional): Option to directly apply the values and select the
516
                slice. Defaults to False.
517
            **kwds: Keyword argument passed to the pre_binning function.
518
        """
519
        self._pre_binned = self.pre_binning(
1✔
520
            df_partitions=df_partitions,
521
            axes=axes,
522
            bins=bins,
523
            ranges=ranges,
524
            **kwds,
525
        )
526

527
        self.mc.load_data(data=self._pre_binned)
1✔
528
        self.mc.select_slicer(plane=plane, width=width, apply=apply)
1✔
529

530
    # 2. Generate the spline warp correction from momentum features.
531
    # Either autoselect features, or input features from view above.
532
    def define_features(
1✔
533
        self,
534
        features: np.ndarray = None,
535
        rotation_symmetry: int = 6,
536
        auto_detect: bool = False,
537
        include_center: bool = True,
538
        apply: bool = False,
539
        **kwds,
540
    ):
541
        """2. Step of the distortion correction workflow: Define feature points in
542
        momentum space. They can be either manually selected using a GUI tool, be
543
        ptovided as list of feature points, or auto-generated using a
544
        feature-detection algorithm.
545

546
        Args:
547
            features (np.ndarray, optional): np.ndarray of features. Defaults to None.
548
            rotation_symmetry (int, optional): Number of rotational symmetry axes.
549
                Defaults to 6.
550
            auto_detect (bool, optional): Whether to auto-detect the features.
551
                Defaults to False.
552
            include_center (bool, optional): Option to include a point at the center
553
                in the feature list. Defaults to True.
554
            apply (bool, optional): Option to directly apply the values and select the
555
                slice. Defaults to False.
556
            **kwds: Keyword arguments for ``MomentumCorrector.feature_extract()`` and
557
                ``MomentumCorrector.feature_select()``.
558
        """
559
        if auto_detect:  # automatic feature selection
1✔
560
            sigma = kwds.pop("sigma", self._config["momentum"]["sigma"])
×
561
            fwhm = kwds.pop("fwhm", self._config["momentum"]["fwhm"])
×
562
            sigma_radius = kwds.pop(
×
563
                "sigma_radius",
564
                self._config["momentum"]["sigma_radius"],
565
            )
566
            self.mc.feature_extract(
×
567
                sigma=sigma,
568
                fwhm=fwhm,
569
                sigma_radius=sigma_radius,
570
                rotsym=rotation_symmetry,
571
                **kwds,
572
            )
573
            features = self.mc.peaks
×
574

575
        self.mc.feature_select(
1✔
576
            rotsym=rotation_symmetry,
577
            include_center=include_center,
578
            features=features,
579
            apply=apply,
580
            **kwds,
581
        )
582

583
    # 3. Generate the spline warp correction from momentum features.
584
    # If no features have been selected before, use class defaults.
585
    def generate_splinewarp(
1✔
586
        self,
587
        use_center: bool = None,
588
        verbose: bool = None,
589
        **kwds,
590
    ):
591
        """3. Step of the distortion correction workflow: Generate the correction
592
        function restoring the symmetry in the image using a splinewarp algortihm.
593

594
        Args:
595
            use_center (bool, optional): Option to use the position of the
596
                center point in the correction. Default is read from config, or set to True.
597
            verbose (bool, optional): Option to print out diagnostic information.
598
                Defaults to config["core"]["verbose"].
599
            **kwds: Keyword arguments for MomentumCorrector.spline_warp_estimate().
600
        """
601
        if verbose is None:
1✔
602
            verbose = self.verbose
1✔
603

604
        self.mc.spline_warp_estimate(use_center=use_center, verbose=verbose, **kwds)
1✔
605

606
        if self.mc.slice is not None and verbose:
1✔
607
            print("Original slice with reference features")
1✔
608
            self.mc.view(annotated=True, backend="bokeh", crosshair=True)
1✔
609

610
            print("Corrected slice with target features")
1✔
611
            self.mc.view(
1✔
612
                image=self.mc.slice_corrected,
613
                annotated=True,
614
                points={"feats": self.mc.ptargs},
615
                backend="bokeh",
616
                crosshair=True,
617
            )
618

619
            print("Original slice with target features")
1✔
620
            self.mc.view(
1✔
621
                image=self.mc.slice,
622
                points={"feats": self.mc.ptargs},
623
                annotated=True,
624
                backend="bokeh",
625
            )
626

627
    # 3a. Save spline-warp parameters to config file.
628
    def save_splinewarp(
1✔
629
        self,
630
        filename: str = None,
631
        overwrite: bool = False,
632
    ):
633
        """Save the generated spline-warp parameters to the folder config file.
634

635
        Args:
636
            filename (str, optional): Filename of the config dictionary to save to.
637
                Defaults to "sed_config.yaml" in the current folder.
638
            overwrite (bool, optional): Option to overwrite the present dictionary.
639
                Defaults to False.
640
        """
641
        if filename is None:
1✔
642
            filename = "sed_config.yaml"
×
643
        if len(self.mc.correction) == 0:
1✔
644
            raise ValueError("No momentum correction parameters to save!")
×
645
        correction = {}
1✔
646
        for key, value in self.mc.correction.items():
1✔
647
            if key in ["reference_points", "target_points", "cdeform_field", "rdeform_field"]:
1✔
648
                continue
1✔
649
            if key in ["use_center", "rotation_symmetry"]:
1✔
650
                correction[key] = value
1✔
651
            elif key in ["center_point", "ascale"]:
1✔
652
                correction[key] = [float(i) for i in value]
1✔
653
            elif key in ["outer_points", "feature_points"]:
1✔
654
                correction[key] = []
1✔
655
                for point in value:
1✔
656
                    correction[key].append([float(i) for i in point])
1✔
657
            else:
658
                correction[key] = float(value)
1✔
659

660
        if "creation_date" not in correction:
1✔
661
            correction["creation_date"] = datetime.now().timestamp()
×
662

663
        config = {
1✔
664
            "momentum": {
665
                "correction": correction,
666
            },
667
        }
668
        save_config(config, filename, overwrite)
1✔
669
        print(f'Saved momentum correction parameters to "{filename}".')
1✔
670

671
    # 4. Pose corrections. Provide interactive interface for correcting
672
    # scaling, shift and rotation
673
    def pose_adjustment(
1✔
674
        self,
675
        transformations: dict[str, Any] = None,
676
        apply: bool = False,
677
        use_correction: bool = True,
678
        reset: bool = True,
679
        verbose: bool = None,
680
        **kwds,
681
    ):
682
        """3. step of the distortion correction workflow: Generate an interactive panel
683
        to adjust affine transformations that are applied to the image. Applies first
684
        a scaling, next an x/y translation, and last a rotation around the center of
685
        the image.
686

687
        Args:
688
            transformations (dict[str, Any], optional): Dictionary with transformations.
689
                Defaults to self.transformations or config["momentum"]["transformtions"].
690
            apply (bool, optional): Option to directly apply the provided
691
                transformations. Defaults to False.
692
            use_correction (bool, option): Whether to use the spline warp correction
693
                or not. Defaults to True.
694
            reset (bool, optional): Option to reset the correction before transformation.
695
                Defaults to True.
696
            verbose (bool, optional): Option to print out diagnostic information.
697
                Defaults to config["core"]["verbose"].
698
            **kwds: Keyword parameters defining defaults for the transformations:
699

700
                - **scale** (float): Initial value of the scaling slider.
701
                - **xtrans** (float): Initial value of the xtrans slider.
702
                - **ytrans** (float): Initial value of the ytrans slider.
703
                - **angle** (float): Initial value of the angle slider.
704
        """
705
        if verbose is None:
1✔
706
            verbose = self.verbose
1✔
707

708
        # Generate homomorphy as default if no distortion correction has been applied
709
        if self.mc.slice_corrected is None:
1✔
710
            if self.mc.slice is None:
1✔
711
                self.mc.slice = np.zeros(self._config["momentum"]["bins"][0:2])
1✔
712
            self.mc.slice_corrected = self.mc.slice
1✔
713

714
        if not use_correction:
1✔
715
            self.mc.reset_deformation()
1✔
716

717
        if self.mc.cdeform_field is None or self.mc.rdeform_field is None:
1✔
718
            # Generate distortion correction from config values
719
            self.mc.spline_warp_estimate(verbose=verbose)
×
720

721
        self.mc.pose_adjustment(
1✔
722
            transformations=transformations,
723
            apply=apply,
724
            reset=reset,
725
            verbose=verbose,
726
            **kwds,
727
        )
728

729
    # 4a. Save pose adjustment parameters to config file.
730
    def save_transformations(
1✔
731
        self,
732
        filename: str = None,
733
        overwrite: bool = False,
734
    ):
735
        """Save the pose adjustment parameters to the folder config file.
736

737
        Args:
738
            filename (str, optional): Filename of the config dictionary to save to.
739
                Defaults to "sed_config.yaml" in the current folder.
740
            overwrite (bool, optional): Option to overwrite the present dictionary.
741
                Defaults to False.
742
        """
743
        if filename is None:
1✔
744
            filename = "sed_config.yaml"
×
745
        if len(self.mc.transformations) == 0:
1✔
746
            raise ValueError("No momentum transformation parameters to save!")
×
747
        transformations = {}
1✔
748
        for key, value in self.mc.transformations.items():
1✔
749
            transformations[key] = float(value)
1✔
750

751
        if "creation_date" not in transformations:
1✔
752
            transformations["creation_date"] = datetime.now().timestamp()
×
753

754
        config = {
1✔
755
            "momentum": {
756
                "transformations": transformations,
757
            },
758
        }
759
        save_config(config, filename, overwrite)
1✔
760
        print(f'Saved momentum transformation parameters to "{filename}".')
1✔
761

762
    # 5. Apply the momentum correction to the dataframe
763
    def apply_momentum_correction(
1✔
764
        self,
765
        preview: bool = False,
766
        verbose: bool = None,
767
        **kwds,
768
    ):
769
        """Applies the distortion correction and pose adjustment (optional)
770
        to the dataframe.
771

772
        Args:
773
            preview (bool, optional): Option to preview the first elements of the data frame.
774
                Defaults to False.
775
            verbose (bool, optional): Option to print out diagnostic information.
776
                Defaults to config["core"]["verbose"].
777
            **kwds: Keyword parameters for ``MomentumCorrector.apply_correction``:
778

779
                - **rdeform_field** (np.ndarray, optional): Row deformation field.
780
                - **cdeform_field** (np.ndarray, optional): Column deformation field.
781
                - **inv_dfield** (np.ndarray, optional): Inverse deformation field.
782

783
        """
784
        if verbose is None:
1✔
785
            verbose = self.verbose
1✔
786

787
        x_column = self._config["dataframe"]["x_column"]
1✔
788
        y_column = self._config["dataframe"]["y_column"]
1✔
789

790
        if self._dataframe is not None:
1✔
791
            if verbose:
1✔
792
                print("Adding corrected X/Y columns to dataframe:")
1✔
793
            df, metadata = self.mc.apply_corrections(
1✔
794
                df=self._dataframe,
795
                verbose=verbose,
796
                **kwds,
797
            )
798
            if (
1✔
799
                self._timed_dataframe is not None
800
                and x_column in self._timed_dataframe.columns
801
                and y_column in self._timed_dataframe.columns
802
            ):
803
                tdf, _ = self.mc.apply_corrections(
1✔
804
                    self._timed_dataframe,
805
                    verbose=False,
806
                    **kwds,
807
                )
808

809
            # Add Metadata
810
            self._attributes.add(
1✔
811
                metadata,
812
                "momentum_correction",
813
                duplicate_policy="merge",
814
            )
815
            self._dataframe = df
1✔
816
            if (
1✔
817
                self._timed_dataframe is not None
818
                and x_column in self._timed_dataframe.columns
819
                and y_column in self._timed_dataframe.columns
820
            ):
821
                self._timed_dataframe = tdf
1✔
822
        else:
823
            raise ValueError("No dataframe loaded!")
×
824
        if preview:
1✔
825
            print(self._dataframe.head(10))
×
826
        else:
827
            if self.verbose:
1✔
828
                print(self._dataframe)
1✔
829

830
    # Momentum calibration work flow
831
    # 1. Calculate momentum calibration
832
    def calibrate_momentum_axes(
1✔
833
        self,
834
        point_a: np.ndarray | list[int] = None,
835
        point_b: np.ndarray | list[int] = None,
836
        k_distance: float = None,
837
        k_coord_a: np.ndarray | list[float] = None,
838
        k_coord_b: np.ndarray | list[float] = np.array([0.0, 0.0]),
839
        equiscale: bool = True,
840
        apply=False,
841
    ):
842
        """1. step of the momentum calibration workflow. Calibrate momentum
843
        axes using either provided pixel coordinates of a high-symmetry point and its
844
        distance to the BZ center, or the k-coordinates of two points in the BZ
845
        (depending on the equiscale option). Opens an interactive panel for selecting
846
        the points.
847

848
        Args:
849
            point_a (np.ndarray | list[int], optional): Pixel coordinates of the first
850
                point used for momentum calibration.
851
            point_b (np.ndarray | list[int], optional): Pixel coordinates of the
852
                second point used for momentum calibration.
853
                Defaults to config["momentum"]["center_pixel"].
854
            k_distance (float, optional): Momentum distance between point a and b.
855
                Needs to be provided if no specific k-koordinates for the two points
856
                are given. Defaults to None.
857
            k_coord_a (np.ndarray | list[float], optional): Momentum coordinate
858
                of the first point used for calibration. Used if equiscale is False.
859
                Defaults to None.
860
            k_coord_b (np.ndarray | list[float], optional): Momentum coordinate
861
                of the second point used for calibration. Defaults to [0.0, 0.0].
862
            equiscale (bool, optional): Option to apply different scales to kx and ky.
863
                If True, the distance between points a and b, and the absolute
864
                position of point a are used for defining the scale. If False, the
865
                scale is calculated from the k-positions of both points a and b.
866
                Defaults to True.
867
            apply (bool, optional): Option to directly store the momentum calibration
868
                in the class. Defaults to False.
869
        """
870
        if point_b is None:
1✔
871
            point_b = self._config["momentum"]["center_pixel"]
1✔
872

873
        self.mc.select_k_range(
1✔
874
            point_a=point_a,
875
            point_b=point_b,
876
            k_distance=k_distance,
877
            k_coord_a=k_coord_a,
878
            k_coord_b=k_coord_b,
879
            equiscale=equiscale,
880
            apply=apply,
881
        )
882

883
    # 1a. Save momentum calibration parameters to config file.
884
    def save_momentum_calibration(
1✔
885
        self,
886
        filename: str = None,
887
        overwrite: bool = False,
888
    ):
889
        """Save the generated momentum calibration parameters to the folder config file.
890

891
        Args:
892
            filename (str, optional): Filename of the config dictionary to save to.
893
                Defaults to "sed_config.yaml" in the current folder.
894
            overwrite (bool, optional): Option to overwrite the present dictionary.
895
                Defaults to False.
896
        """
897
        if filename is None:
1✔
898
            filename = "sed_config.yaml"
×
899
        if len(self.mc.calibration) == 0:
1✔
900
            raise ValueError("No momentum calibration parameters to save!")
×
901
        calibration = {}
1✔
902
        for key, value in self.mc.calibration.items():
1✔
903
            if key in ["kx_axis", "ky_axis", "grid", "extent"]:
1✔
904
                continue
1✔
905

906
            calibration[key] = float(value)
1✔
907

908
        if "creation_date" not in calibration:
1✔
909
            calibration["creation_date"] = datetime.now().timestamp()
×
910

911
        config = {"momentum": {"calibration": calibration}}
1✔
912
        save_config(config, filename, overwrite)
1✔
913
        print(f"Saved momentum calibration parameters to {filename}")
1✔
914

915
    # 2. Apply correction and calibration to the dataframe
916
    def apply_momentum_calibration(
1✔
917
        self,
918
        calibration: dict = None,
919
        preview: bool = False,
920
        verbose: bool = None,
921
        **kwds,
922
    ):
923
        """2. step of the momentum calibration work flow: Apply the momentum
924
        calibration stored in the class to the dataframe. If corrected X/Y axis exist,
925
        these are used.
926

927
        Args:
928
            calibration (dict, optional): Optional dictionary with calibration data to
929
                use. Defaults to None.
930
            preview (bool, optional): Option to preview the first elements of the data frame.
931
                Defaults to False.
932
            verbose (bool, optional): Option to print out diagnostic information.
933
                Defaults to config["core"]["verbose"].
934
            **kwds: Keyword args passed to ``DelayCalibrator.append_delay_axis``.
935
        """
936
        if verbose is None:
1✔
937
            verbose = self.verbose
1✔
938

939
        x_column = self._config["dataframe"]["x_column"]
1✔
940
        y_column = self._config["dataframe"]["y_column"]
1✔
941

942
        if self._dataframe is not None:
1✔
943
            if verbose:
1✔
944
                print("Adding kx/ky columns to dataframe:")
1✔
945
            df, metadata = self.mc.append_k_axis(
1✔
946
                df=self._dataframe,
947
                calibration=calibration,
948
                **kwds,
949
            )
950
            if (
1✔
951
                self._timed_dataframe is not None
952
                and x_column in self._timed_dataframe.columns
953
                and y_column in self._timed_dataframe.columns
954
            ):
955
                tdf, _ = self.mc.append_k_axis(
1✔
956
                    df=self._timed_dataframe,
957
                    calibration=calibration,
958
                    **kwds,
959
                )
960

961
            # Add Metadata
962
            self._attributes.add(
1✔
963
                metadata,
964
                "momentum_calibration",
965
                duplicate_policy="merge",
966
            )
967
            self._dataframe = df
1✔
968
            if (
1✔
969
                self._timed_dataframe is not None
970
                and x_column in self._timed_dataframe.columns
971
                and y_column in self._timed_dataframe.columns
972
            ):
973
                self._timed_dataframe = tdf
1✔
974
        else:
975
            raise ValueError("No dataframe loaded!")
×
976
        if preview:
1✔
977
            print(self._dataframe.head(10))
×
978
        else:
979
            if self.verbose:
1✔
980
                print(self._dataframe)
1✔
981

982
    # Energy correction workflow
983
    # 1. Adjust the energy correction parameters
984
    def adjust_energy_correction(
1✔
985
        self,
986
        correction_type: str = None,
987
        amplitude: float = None,
988
        center: tuple[float, float] = None,
989
        apply=False,
990
        **kwds,
991
    ):
992
        """1. step of the energy crrection workflow: Opens an interactive plot to
993
        adjust the parameters for the TOF/energy correction. Also pre-bins the data if
994
        they are not present yet.
995

996
        Args:
997
            correction_type (str, optional): Type of correction to apply to the TOF
998
                axis. Valid values are:
999

1000
                - 'spherical'
1001
                - 'Lorentzian'
1002
                - 'Gaussian'
1003
                - 'Lorentzian_asymmetric'
1004

1005
                Defaults to config["energy"]["correction_type"].
1006
            amplitude (float, optional): Amplitude of the correction.
1007
                Defaults to config["energy"]["correction"]["amplitude"].
1008
            center (tuple[float, float], optional): Center X/Y coordinates for the
1009
                correction. Defaults to config["energy"]["correction"]["center"].
1010
            apply (bool, optional): Option to directly apply the provided or default
1011
                correction parameters. Defaults to False.
1012
            **kwds: Keyword parameters passed to ``EnergyCalibrator.adjust_energy_correction()``.
1013
        """
1014
        if self._pre_binned is None:
1✔
1015
            print(
1✔
1016
                "Pre-binned data not present, binning using defaults from config...",
1017
            )
1018
            self._pre_binned = self.pre_binning()
1✔
1019

1020
        self.ec.adjust_energy_correction(
1✔
1021
            self._pre_binned,
1022
            correction_type=correction_type,
1023
            amplitude=amplitude,
1024
            center=center,
1025
            apply=apply,
1026
            **kwds,
1027
        )
1028

1029
    # 1a. Save energy correction parameters to config file.
1030
    def save_energy_correction(
1✔
1031
        self,
1032
        filename: str = None,
1033
        overwrite: bool = False,
1034
    ):
1035
        """Save the generated energy correction parameters to the folder config file.
1036

1037
        Args:
1038
            filename (str, optional): Filename of the config dictionary to save to.
1039
                Defaults to "sed_config.yaml" in the current folder.
1040
            overwrite (bool, optional): Option to overwrite the present dictionary.
1041
                Defaults to False.
1042
        """
1043
        if filename is None:
1✔
1044
            filename = "sed_config.yaml"
1✔
1045
        if len(self.ec.correction) == 0:
1✔
1046
            raise ValueError("No energy correction parameters to save!")
×
1047
        correction = {}
1✔
1048
        for key, val in self.ec.correction.items():
1✔
1049
            if key == "correction_type":
1✔
1050
                correction[key] = val
1✔
1051
            elif key == "center":
1✔
1052
                correction[key] = [float(i) for i in val]
1✔
1053
            else:
1054
                correction[key] = float(val)
1✔
1055

1056
        if "creation_date" not in correction:
1✔
1057
            correction["creation_date"] = datetime.now().timestamp()
×
1058

1059
        config = {"energy": {"correction": correction}}
1✔
1060
        save_config(config, filename, overwrite)
1✔
1061
        print(f"Saved energy correction parameters to {filename}")
1✔
1062

1063
    # 2. Apply energy correction to dataframe
1064
    def apply_energy_correction(
1✔
1065
        self,
1066
        correction: dict = None,
1067
        preview: bool = False,
1068
        verbose: bool = None,
1069
        **kwds,
1070
    ):
1071
        """2. step of the energy correction workflow: Apply the enery correction
1072
        parameters stored in the class to the dataframe.
1073

1074
        Args:
1075
            correction (dict, optional): Dictionary containing the correction
1076
                parameters. Defaults to config["energy"]["calibration"].
1077
            preview (bool, optional): Option to preview the first elements of the data frame.
1078
                Defaults to False.
1079
            verbose (bool, optional): Option to print out diagnostic information.
1080
                Defaults to config["core"]["verbose"].
1081
            **kwds:
1082
                Keyword args passed to ``EnergyCalibrator.apply_energy_correction()``.
1083
        """
1084
        if verbose is None:
1✔
1085
            verbose = self.verbose
1✔
1086

1087
        tof_column = self._config["dataframe"]["tof_column"]
1✔
1088

1089
        if self._dataframe is not None:
1✔
1090
            if verbose:
1✔
1091
                print("Applying energy correction to dataframe...")
1✔
1092
            df, metadata = self.ec.apply_energy_correction(
1✔
1093
                df=self._dataframe,
1094
                correction=correction,
1095
                verbose=verbose,
1096
                **kwds,
1097
            )
1098
            if self._timed_dataframe is not None and tof_column in self._timed_dataframe.columns:
1✔
1099
                tdf, _ = self.ec.apply_energy_correction(
1✔
1100
                    df=self._timed_dataframe,
1101
                    correction=correction,
1102
                    verbose=False,
1103
                    **kwds,
1104
                )
1105

1106
            # Add Metadata
1107
            self._attributes.add(
1✔
1108
                metadata,
1109
                "energy_correction",
1110
            )
1111
            self._dataframe = df
1✔
1112
            if self._timed_dataframe is not None and tof_column in self._timed_dataframe.columns:
1✔
1113
                self._timed_dataframe = tdf
1✔
1114
        else:
1115
            raise ValueError("No dataframe loaded!")
×
1116
        if preview:
1✔
1117
            print(self._dataframe.head(10))
×
1118
        else:
1119
            if verbose:
1✔
1120
                print(self._dataframe)
×
1121

1122
    # Energy calibrator workflow
1123
    # 1. Load and normalize data
1124
    def load_bias_series(
1✔
1125
        self,
1126
        binned_data: xr.DataArray | tuple[np.ndarray, np.ndarray, np.ndarray] = None,
1127
        data_files: list[str] = None,
1128
        axes: list[str] = None,
1129
        bins: list = None,
1130
        ranges: Sequence[tuple[float, float]] = None,
1131
        biases: np.ndarray = None,
1132
        bias_key: str = None,
1133
        normalize: bool = None,
1134
        span: int = None,
1135
        order: int = None,
1136
    ):
1137
        """1. step of the energy calibration workflow: Load and bin data from
1138
        single-event files, or load binned bias/TOF traces.
1139

1140
        Args:
1141
            binned_data (xr.DataArray | tuple[np.ndarray, np.ndarray, np.ndarray], optional):
1142
                Binned data If provided as DataArray, Needs to contain dimensions
1143
                config["dataframe"]["tof_column"] and config["dataframe"]["bias_column"]. If
1144
                provided as tuple, needs to contain elements tof, biases, traces.
1145
            data_files (list[str], optional): list of file paths to bin
1146
            axes (list[str], optional): bin axes.
1147
                Defaults to config["dataframe"]["tof_column"].
1148
            bins (list, optional): number of bins.
1149
                Defaults to config["energy"]["bins"].
1150
            ranges (Sequence[tuple[float, float]], optional): bin ranges.
1151
                Defaults to config["energy"]["ranges"].
1152
            biases (np.ndarray, optional): Bias voltages used. If missing, bias
1153
                voltages are extracted from the data files.
1154
            bias_key (str, optional): hdf5 path where bias values are stored.
1155
                Defaults to config["energy"]["bias_key"].
1156
            normalize (bool, optional): Option to normalize traces.
1157
                Defaults to config["energy"]["normalize"].
1158
            span (int, optional): span smoothing parameters of the LOESS method
1159
                (see ``scipy.signal.savgol_filter()``).
1160
                Defaults to config["energy"]["normalize_span"].
1161
            order (int, optional): order smoothing parameters of the LOESS method
1162
                (see ``scipy.signal.savgol_filter()``).
1163
                Defaults to config["energy"]["normalize_order"].
1164
        """
1165
        if binned_data is not None:
1✔
1166
            if isinstance(binned_data, xr.DataArray):
1✔
1167
                if (
1✔
1168
                    self._config["dataframe"]["tof_column"] not in binned_data.dims
1169
                    or self._config["dataframe"]["bias_column"] not in binned_data.dims
1170
                ):
1171
                    raise ValueError(
1✔
1172
                        "If binned_data is provided as an xarray, it needs to contain dimensions "
1173
                        f"'{self._config['dataframe']['tof_column']}' and "
1174
                        f"'{self._config['dataframe']['bias_column']}'!.",
1175
                    )
1176
                tof = binned_data.coords[self._config["dataframe"]["tof_column"]].values
1✔
1177
                biases = binned_data.coords[self._config["dataframe"]["bias_column"]].values
1✔
1178
                traces = binned_data.values[:, :]
1✔
1179
            else:
1180
                try:
1✔
1181
                    (tof, biases, traces) = binned_data
1✔
1182
                except ValueError as exc:
1✔
1183
                    raise ValueError(
1✔
1184
                        "If binned_data is provided as tuple, it needs to contain "
1185
                        "(tof, biases, traces)!",
1186
                    ) from exc
1187
            self.ec.load_data(biases=biases, traces=traces, tof=tof)
1✔
1188

1189
        elif data_files is not None:
1✔
1190
            self.ec.bin_data(
1✔
1191
                data_files=cast(list[str], self.cpy(data_files)),
1192
                axes=axes,
1193
                bins=bins,
1194
                ranges=ranges,
1195
                biases=biases,
1196
                bias_key=bias_key,
1197
            )
1198

1199
        else:
1200
            raise ValueError("Either binned_data or data_files needs to be provided!")
1✔
1201

1202
        if (normalize is not None and normalize is True) or (
1✔
1203
            normalize is None and self._config["energy"]["normalize"]
1204
        ):
1205
            if span is None:
1✔
1206
                span = self._config["energy"]["normalize_span"]
1✔
1207
            if order is None:
1✔
1208
                order = self._config["energy"]["normalize_order"]
1✔
1209
            self.ec.normalize(smooth=True, span=span, order=order)
1✔
1210
        self.ec.view(
1✔
1211
            traces=self.ec.traces_normed,
1212
            xaxis=self.ec.tof,
1213
            backend="bokeh",
1214
        )
1215

1216
    # 2. extract ranges and get peak positions
1217
    def find_bias_peaks(
1✔
1218
        self,
1219
        ranges: list[tuple] | tuple,
1220
        ref_id: int = 0,
1221
        infer_others: bool = True,
1222
        mode: str = "replace",
1223
        radius: int = None,
1224
        peak_window: int = None,
1225
        apply: bool = False,
1226
    ):
1227
        """2. step of the energy calibration workflow: Find a peak within a given range
1228
        for the indicated reference trace, and tries to find the same peak for all
1229
        other traces. Uses fast_dtw to align curves, which might not be too good if the
1230
        shape of curves changes qualitatively. Ideally, choose a reference trace in the
1231
        middle of the set, and don't choose the range too narrow around the peak.
1232
        Alternatively, a list of ranges for all traces can be provided.
1233

1234
        Args:
1235
            ranges (list[tuple] | tuple): Tuple of TOF values indicating a range.
1236
                Alternatively, a list of ranges for all traces can be given.
1237
            ref_id (int, optional): The id of the trace the range refers to.
1238
                Defaults to 0.
1239
            infer_others (bool, optional): Whether to determine the range for the other
1240
                traces. Defaults to True.
1241
            mode (str, optional): Whether to "add" or "replace" existing ranges.
1242
                Defaults to "replace".
1243
            radius (int, optional): Radius parameter for fast_dtw.
1244
                Defaults to config["energy"]["fastdtw_radius"].
1245
            peak_window (int, optional): Peak_window parameter for the peak detection
1246
                algorthm. amount of points that have to have to behave monotoneously
1247
                around a peak. Defaults to config["energy"]["peak_window"].
1248
            apply (bool, optional): Option to directly apply the provided parameters.
1249
                Defaults to False.
1250
        """
1251
        if radius is None:
1✔
1252
            radius = self._config["energy"]["fastdtw_radius"]
1✔
1253
        if peak_window is None:
1✔
1254
            peak_window = self._config["energy"]["peak_window"]
1✔
1255
        if not infer_others:
1✔
1256
            self.ec.add_ranges(
1✔
1257
                ranges=ranges,
1258
                ref_id=ref_id,
1259
                infer_others=infer_others,
1260
                mode=mode,
1261
                radius=radius,
1262
            )
1263
            print(self.ec.featranges)
1✔
1264
            try:
1✔
1265
                self.ec.feature_extract(peak_window=peak_window)
1✔
1266
                self.ec.view(
1✔
1267
                    traces=self.ec.traces_normed,
1268
                    segs=self.ec.featranges,
1269
                    xaxis=self.ec.tof,
1270
                    peaks=self.ec.peaks,
1271
                    backend="bokeh",
1272
                )
1273
            except IndexError:
×
1274
                print("Could not determine all peaks!")
×
1275
                raise
×
1276
        else:
1277
            # New adjustment tool
1278
            assert isinstance(ranges, tuple)
1✔
1279
            self.ec.adjust_ranges(
1✔
1280
                ranges=ranges,
1281
                ref_id=ref_id,
1282
                traces=self.ec.traces_normed,
1283
                infer_others=infer_others,
1284
                radius=radius,
1285
                peak_window=peak_window,
1286
                apply=apply,
1287
            )
1288

1289
    # 3. Fit the energy calibration relation
1290
    def calibrate_energy_axis(
1✔
1291
        self,
1292
        ref_energy: float,
1293
        method: str = None,
1294
        energy_scale: str = None,
1295
        verbose: bool = None,
1296
        **kwds,
1297
    ):
1298
        """3. Step of the energy calibration workflow: Calculate the calibration
1299
        function for the energy axis, and apply it to the dataframe. Two
1300
        approximations are implemented, a (normally 3rd order) polynomial
1301
        approximation, and a d^2/(t-t0)^2 relation.
1302

1303
        Args:
1304
            ref_energy (float): Binding/kinetic energy of the detected feature.
1305
            method (str, optional): Method for determining the energy calibration.
1306

1307
                - **'lmfit'**: Energy calibration using lmfit and 1/t^2 form.
1308
                - **'lstsq'**, **'lsqr'**: Energy calibration using polynomial form.
1309

1310
                Defaults to config["energy"]["calibration_method"]
1311
            energy_scale (str, optional): Direction of increasing energy scale.
1312

1313
                - **'kinetic'**: increasing energy with decreasing TOF.
1314
                - **'binding'**: increasing energy with increasing TOF.
1315

1316
                Defaults to config["energy"]["energy_scale"]
1317
            verbose (bool, optional): Option to print out diagnostic information.
1318
                Defaults to config["core"]["verbose"].
1319
            **kwds**: Keyword parameters passed to ``EnergyCalibrator.calibrate()``.
1320
        """
1321
        if verbose is None:
1✔
1322
            verbose = self.verbose
1✔
1323

1324
        if method is None:
1✔
1325
            method = self._config["energy"]["calibration_method"]
1✔
1326

1327
        if energy_scale is None:
1✔
1328
            energy_scale = self._config["energy"]["energy_scale"]
1✔
1329

1330
        self.ec.calibrate(
1✔
1331
            ref_energy=ref_energy,
1332
            method=method,
1333
            energy_scale=energy_scale,
1334
            verbose=verbose,
1335
            **kwds,
1336
        )
1337
        if verbose:
1✔
1338
            print("Quality of Calibration:")
1✔
1339
            self.ec.view(
1✔
1340
                traces=self.ec.traces_normed,
1341
                xaxis=self.ec.calibration["axis"],
1342
                align=True,
1343
                energy_scale=energy_scale,
1344
                backend="bokeh",
1345
            )
1346
            print("E/TOF relationship:")
1✔
1347
            self.ec.view(
1✔
1348
                traces=self.ec.calibration["axis"][None, :] + self.ec.biases[0],
1349
                xaxis=self.ec.tof,
1350
                backend="matplotlib",
1351
                show_legend=False,
1352
            )
1353
            if energy_scale == "kinetic":
1✔
1354
                plt.scatter(
1✔
1355
                    self.ec.peaks[:, 0],
1356
                    -(self.ec.biases - self.ec.biases[0]) + ref_energy,
1357
                    s=50,
1358
                    c="k",
1359
                )
1360
            elif energy_scale == "binding":
1✔
1361
                plt.scatter(
1✔
1362
                    self.ec.peaks[:, 0],
1363
                    self.ec.biases - self.ec.biases[0] + ref_energy,
1364
                    s=50,
1365
                    c="k",
1366
                )
1367
            else:
UNCOV
1368
                raise ValueError(
×
1369
                    'energy_scale needs to be either "binding" or "kinetic"',
1370
                    f", got {energy_scale}.",
1371
                )
1372
            plt.xlabel("Time-of-flight", fontsize=15)
1✔
1373
            plt.ylabel("Energy (eV)", fontsize=15)
1✔
1374
            plt.show()
1✔
1375

1376
    # 3a. Save energy calibration parameters to config file.
1377
    def save_energy_calibration(
1✔
1378
        self,
1379
        filename: str = None,
1380
        overwrite: bool = False,
1381
    ):
1382
        """Save the generated energy calibration parameters to the folder config file.
1383

1384
        Args:
1385
            filename (str, optional): Filename of the config dictionary to save to.
1386
                Defaults to "sed_config.yaml" in the current folder.
1387
            overwrite (bool, optional): Option to overwrite the present dictionary.
1388
                Defaults to False.
1389
        """
1390
        if filename is None:
1✔
UNCOV
1391
            filename = "sed_config.yaml"
×
1392
        if len(self.ec.calibration) == 0:
1✔
UNCOV
1393
            raise ValueError("No energy calibration parameters to save!")
×
1394
        calibration = {}
1✔
1395
        for key, value in self.ec.calibration.items():
1✔
1396
            if key in ["axis", "refid", "Tmat", "bvec"]:
1✔
1397
                continue
1✔
1398
            if key == "energy_scale":
1✔
1399
                calibration[key] = value
1✔
1400
            elif key == "coeffs":
1✔
1401
                calibration[key] = [float(i) for i in value]
1✔
1402
            else:
1403
                calibration[key] = float(value)
1✔
1404

1405
        if "creation_date" not in calibration:
1✔
UNCOV
1406
            calibration["creation_date"] = datetime.now().timestamp()
×
1407

1408
        config = {"energy": {"calibration": calibration}}
1✔
1409
        save_config(config, filename, overwrite)
1✔
1410
        print(f'Saved energy calibration parameters to "{filename}".')
1✔
1411

1412
    # 4. Apply energy calibration to the dataframe
1413
    def append_energy_axis(
1✔
1414
        self,
1415
        calibration: dict = None,
1416
        bias_voltage: float = None,
1417
        preview: bool = False,
1418
        verbose: bool = None,
1419
        **kwds,
1420
    ):
1421
        """4. step of the energy calibration workflow: Apply the calibration function
1422
        to to the dataframe. Two approximations are implemented, a (normally 3rd order)
1423
        polynomial approximation, and a d^2/(t-t0)^2 relation. a calibration dictionary
1424
        can be provided.
1425

1426
        Args:
1427
            calibration (dict, optional): Calibration dict containing calibration
1428
                parameters. Overrides calibration from class or config.
1429
                Defaults to None.
1430
            bias_voltage (float, optional): Sample bias voltage of the scan data. If omitted,
1431
                the bias voltage is being read from the dataframe. If it is not found there,
1432
                a warning is printed and the calibrated data will not be offset correctly.
1433
            preview (bool): Option to preview the first elements of the data frame.
1434
            verbose (bool, optional): Option to print out diagnostic information.
1435
                Defaults to config["core"]["verbose"].
1436
            **kwds:
1437
                Keyword args passed to ``EnergyCalibrator.append_energy_axis()``.
1438
        """
1439
        if verbose is None:
1✔
1440
            verbose = self.verbose
1✔
1441

1442
        tof_column = self._config["dataframe"]["tof_column"]
1✔
1443

1444
        if self._dataframe is not None:
1✔
1445
            if verbose:
1✔
1446
                print("Adding energy column to dataframe:")
1✔
1447
            df, metadata = self.ec.append_energy_axis(
1✔
1448
                df=self._dataframe,
1449
                calibration=calibration,
1450
                verbose=verbose,
1451
                **kwds,
1452
            )
1453
            if self._timed_dataframe is not None and tof_column in self._timed_dataframe.columns:
1✔
1454
                tdf, _ = self.ec.append_energy_axis(
1✔
1455
                    df=self._timed_dataframe,
1456
                    calibration=calibration,
1457
                    verbose=False,
1458
                    **kwds,
1459
                )
1460

1461
            # Add Metadata
1462
            self._attributes.add(
1✔
1463
                metadata,
1464
                "energy_calibration",
1465
                duplicate_policy="merge",
1466
            )
1467
            self._dataframe = df
1✔
1468
            if self._timed_dataframe is not None and tof_column in self._timed_dataframe.columns:
1✔
1469
                self._timed_dataframe = tdf
1✔
1470

1471
        else:
UNCOV
1472
            raise ValueError("No dataframe loaded!")
×
1473

1474
        if bias_voltage is not None:
1✔
NEW
UNCOV
1475
            self.add_energy_offset(constant=bias_voltage, verbose=verbose, preview=preview)
×
1476
        elif self.config["dataframe"]["bias_column"] in self._dataframe.columns:
1✔
1477
            self.add_energy_offset(
1✔
1478
                columns=[self.config["dataframe"]["bias_column"]],
1479
                verbose=verbose,
1480
                preview=preview,
1481
            )
1482
        else:
1483
            print("Sample bias data not found or provided. Calibrated energy will be offset.")
1✔
1484
            # Preview only if no offset applied
1485
            if preview:
1✔
NEW
UNCOV
1486
                print(self._dataframe.head(10))
×
1487
            else:
1488
                if verbose:
1✔
1489
                    print(self._dataframe)
1✔
1490

1491
    def add_energy_offset(
1✔
1492
        self,
1493
        constant: float = None,
1494
        columns: str | Sequence[str] = None,
1495
        weights: float | Sequence[float] = None,
1496
        reductions: str | Sequence[str] = None,
1497
        preserve_mean: bool | Sequence[bool] = None,
1498
        preview: bool = False,
1499
        verbose: bool = None,
1500
    ) -> None:
1501
        """Shift the energy axis of the dataframe by a given amount.
1502

1503
        Args:
1504
            constant (float, optional): The constant to shift the energy axis by.
1505
            columns (str | Sequence[str], optional): Name of the column(s) to apply the shift from.
1506
            weights (float | Sequence[float], optional): weights to apply to the columns.
1507
                Can also be used to flip the sign (e.g. -1). Defaults to 1.
1508
            reductions (str | Sequence[str], optional): The reduction to apply to the column.
1509
                Should be an available method of dask.dataframe.Series. For example "mean". In this
1510
                case the function is applied to the column to generate a single value for the whole
1511
                dataset. If None, the shift is applied per-dataframe-row. Defaults to None.
1512
                Currently only "mean" is supported.
1513
            preserve_mean (bool | Sequence[bool], optional): Whether to subtract the mean of the
1514
                column before applying the shift. Defaults to False.
1515
            preview (bool, optional): Option to preview the first elements of the data frame.
1516
                Defaults to False.
1517
            verbose (bool, optional): Option to print out diagnostic information.
1518
                Defaults to config["core"]["verbose"].
1519

1520
        Raises:
1521
            ValueError: If the energy column is not in the dataframe.
1522
        """
1523
        if verbose is None:
1✔
1524
            verbose = self.verbose
1✔
1525

1526
        energy_column = self._config["dataframe"]["energy_column"]
1✔
1527
        if energy_column not in self._dataframe.columns:
1✔
1528
            raise ValueError(
1✔
1529
                f"Energy column {energy_column} not found in dataframe! "
1530
                "Run `append_energy_axis()` first.",
1531
            )
1532
        if self.dataframe is not None:
1✔
1533
            if verbose:
1✔
1534
                print("Adding energy offset to dataframe:")
1✔
1535
            df, metadata = self.ec.add_offsets(
1✔
1536
                df=self._dataframe,
1537
                constant=constant,
1538
                columns=columns,
1539
                energy_column=energy_column,
1540
                weights=weights,
1541
                reductions=reductions,
1542
                preserve_mean=preserve_mean,
1543
                verbose=verbose,
1544
            )
1545
            if self._timed_dataframe is not None and energy_column in self._timed_dataframe.columns:
1✔
1546
                tdf, _ = self.ec.add_offsets(
1✔
1547
                    df=self._timed_dataframe,
1548
                    constant=constant,
1549
                    columns=columns,
1550
                    energy_column=energy_column,
1551
                    weights=weights,
1552
                    reductions=reductions,
1553
                    preserve_mean=preserve_mean,
1554
                )
1555

1556
            self._attributes.add(
1✔
1557
                metadata,
1558
                "add_energy_offset",
1559
                # TODO: allow only appending when no offset along this column(s) was applied
1560
                # TODO: clear memory of modifications if the energy axis is recalculated
1561
                duplicate_policy="append",
1562
            )
1563
            self._dataframe = df
1✔
1564
            if self._timed_dataframe is not None and energy_column in self._timed_dataframe.columns:
1✔
1565
                self._timed_dataframe = tdf
1✔
1566
        else:
UNCOV
1567
            raise ValueError("No dataframe loaded!")
×
1568
        if preview:
1✔
UNCOV
1569
            print(self._dataframe.head(10))
×
1570
        elif verbose:
1✔
1571
            print(self._dataframe)
1✔
1572

1573
    def save_energy_offset(
1✔
1574
        self,
1575
        filename: str = None,
1576
        overwrite: bool = False,
1577
    ):
1578
        """Save the generated energy calibration parameters to the folder config file.
1579

1580
        Args:
1581
            filename (str, optional): Filename of the config dictionary to save to.
1582
                Defaults to "sed_config.yaml" in the current folder.
1583
            overwrite (bool, optional): Option to overwrite the present dictionary.
1584
                Defaults to False.
1585
        """
UNCOV
1586
        if filename is None:
×
1587
            filename = "sed_config.yaml"
×
1588
        if len(self.ec.offsets) == 0:
×
UNCOV
1589
            raise ValueError("No energy offset parameters to save!")
×
1590

1591
        if "creation_date" not in self.ec.offsets.keys():
×
1592
            self.ec.offsets["creation_date"] = datetime.now().timestamp()
×
1593

UNCOV
1594
        config = {"energy": {"offsets": self.ec.offsets}}
×
UNCOV
1595
        save_config(config, filename, overwrite)
×
UNCOV
1596
        print(f'Saved energy offset parameters to "{filename}".')
×
1597

1598
    def append_tof_ns_axis(
1✔
1599
        self,
1600
        preview: bool = False,
1601
        verbose: bool = None,
1602
        **kwds,
1603
    ):
1604
        """Convert time-of-flight channel steps to nanoseconds.
1605

1606
        Args:
1607
            tof_ns_column (str, optional): Name of the generated column containing the
1608
                time-of-flight in nanosecond.
1609
                Defaults to config["dataframe"]["tof_ns_column"].
1610
            preview (bool, optional): Option to preview the first elements of the data frame.
1611
                Defaults to False.
1612
            verbose (bool, optional): Option to print out diagnostic information.
1613
                Defaults to config["core"]["verbose"].
1614
            **kwds: additional arguments are passed to ``EnergyCalibrator.tof_step_to_ns()``.
1615

1616
        """
1617
        if verbose is None:
1✔
1618
            verbose = self.verbose
1✔
1619

1620
        tof_column = self._config["dataframe"]["tof_column"]
1✔
1621

1622
        if self._dataframe is not None:
1✔
1623
            if verbose:
1✔
1624
                print("Adding time-of-flight column in nanoseconds to dataframe:")
1✔
1625
            # TODO assert order of execution through metadata
1626

1627
            df, metadata = self.ec.append_tof_ns_axis(
1✔
1628
                df=self._dataframe,
1629
                **kwds,
1630
            )
1631
            if self._timed_dataframe is not None and tof_column in self._timed_dataframe.columns:
1✔
1632
                tdf, _ = self.ec.append_tof_ns_axis(
1✔
1633
                    df=self._timed_dataframe,
1634
                    **kwds,
1635
                )
1636

1637
            self._attributes.add(
1✔
1638
                metadata,
1639
                "tof_ns_conversion",
1640
                duplicate_policy="overwrite",
1641
            )
1642
            self._dataframe = df
1✔
1643
            if self._timed_dataframe is not None and tof_column in self._timed_dataframe.columns:
1✔
1644
                self._timed_dataframe = tdf
1✔
1645
        else:
UNCOV
1646
            raise ValueError("No dataframe loaded!")
×
1647
        if preview:
1✔
UNCOV
1648
            print(self._dataframe.head(10))
×
1649
        else:
1650
            if verbose:
1✔
1651
                print(self._dataframe)
1✔
1652

1653
    def align_dld_sectors(
1✔
1654
        self,
1655
        sector_delays: np.ndarray = None,
1656
        preview: bool = False,
1657
        verbose: bool = None,
1658
        **kwds,
1659
    ):
1660
        """Align the 8s sectors of the HEXTOF endstation.
1661

1662
        Args:
1663
            sector_delays (np.ndarray, optional): Array containing the sector delays. Defaults to
1664
                config["dataframe"]["sector_delays"].
1665
            preview (bool, optional): Option to preview the first elements of the data frame.
1666
                Defaults to False.
1667
            verbose (bool, optional): Option to print out diagnostic information.
1668
                Defaults to config["core"]["verbose"].
1669
            **kwds: additional arguments are passed to ``EnergyCalibrator.align_dld_sectors()``.
1670
        """
1671
        if verbose is None:
1✔
1672
            verbose = self.verbose
1✔
1673

1674
        tof_column = self._config["dataframe"]["tof_column"]
1✔
1675

1676
        if self._dataframe is not None:
1✔
1677
            if verbose:
1✔
1678
                print("Aligning 8s sectors of dataframe")
1✔
1679
            # TODO assert order of execution through metadata
1680

1681
            df, metadata = self.ec.align_dld_sectors(
1✔
1682
                df=self._dataframe,
1683
                sector_delays=sector_delays,
1684
                **kwds,
1685
            )
1686
            if self._timed_dataframe is not None and tof_column in self._timed_dataframe.columns:
1✔
UNCOV
1687
                tdf, _ = self.ec.align_dld_sectors(
×
1688
                    df=self._timed_dataframe,
1689
                    sector_delays=sector_delays,
1690
                    **kwds,
1691
                )
1692

1693
            self._attributes.add(
1✔
1694
                metadata,
1695
                "dld_sector_alignment",
1696
                duplicate_policy="raise",
1697
            )
1698
            self._dataframe = df
1✔
1699
            if self._timed_dataframe is not None and tof_column in self._timed_dataframe.columns:
1✔
1700
                self._timed_dataframe = tdf
×
1701
        else:
UNCOV
1702
            raise ValueError("No dataframe loaded!")
×
1703
        if preview:
1✔
UNCOV
1704
            print(self._dataframe.head(10))
×
1705
        else:
1706
            if verbose:
1✔
1707
                print(self._dataframe)
1✔
1708

1709
    # Delay calibration function
1710
    def calibrate_delay_axis(
1✔
1711
        self,
1712
        delay_range: tuple[float, float] = None,
1713
        datafile: str = None,
1714
        preview: bool = False,
1715
        verbose: bool = None,
1716
        **kwds,
1717
    ):
1718
        """Append delay column to dataframe. Either provide delay ranges, or read
1719
        them from a file.
1720

1721
        Args:
1722
            delay_range (tuple[float, float], optional): The scanned delay range in
1723
                picoseconds. Defaults to None.
1724
            datafile (str, optional): The file from which to read the delay ranges.
1725
                Defaults to None.
1726
            preview (bool, optional): Option to preview the first elements of the data frame.
1727
                Defaults to False.
1728
            verbose (bool, optional): Option to print out diagnostic information.
1729
                Defaults to config["core"]["verbose"].
1730
            **kwds: Keyword args passed to ``DelayCalibrator.append_delay_axis``.
1731
        """
1732
        if verbose is None:
1✔
1733
            verbose = self.verbose
1✔
1734

1735
        adc_column = self._config["dataframe"]["adc_column"]
1✔
1736
        if adc_column not in self._dataframe.columns:
1✔
UNCOV
1737
            raise ValueError(f"ADC column {adc_column} not found in dataframe, cannot calibrate!")
×
1738

1739
        if self._dataframe is not None:
1✔
1740
            if verbose:
1✔
1741
                print("Adding delay column to dataframe:")
1✔
1742

1743
            if delay_range is None and datafile is None:
1✔
1744
                if len(self.dc.calibration) == 0:
1✔
1745
                    try:
1✔
1746
                        datafile = self._files[0]
1✔
UNCOV
1747
                    except IndexError:
×
1748
                        print(
×
1749
                            "No datafile available, specify either",
1750
                            " 'datafile' or 'delay_range'",
1751
                        )
UNCOV
1752
                        raise
×
1753

1754
            df, metadata = self.dc.append_delay_axis(
1✔
1755
                self._dataframe,
1756
                delay_range=delay_range,
1757
                datafile=datafile,
1758
                verbose=verbose,
1759
                **kwds,
1760
            )
1761
            if self._timed_dataframe is not None and adc_column in self._timed_dataframe.columns:
1✔
1762
                tdf, _ = self.dc.append_delay_axis(
1✔
1763
                    self._timed_dataframe,
1764
                    delay_range=delay_range,
1765
                    datafile=datafile,
1766
                    verbose=False,
1767
                    **kwds,
1768
                )
1769

1770
            # Add Metadata
1771
            self._attributes.add(
1✔
1772
                metadata,
1773
                "delay_calibration",
1774
                duplicate_policy="overwrite",
1775
            )
1776
            self._dataframe = df
1✔
1777
            if self._timed_dataframe is not None and adc_column in self._timed_dataframe.columns:
1✔
1778
                self._timed_dataframe = tdf
1✔
1779
        else:
UNCOV
1780
            raise ValueError("No dataframe loaded!")
×
1781
        if preview:
1✔
1782
            print(self._dataframe.head(10))
1✔
1783
        else:
1784
            if self.verbose:
1✔
1785
                print(self._dataframe)
1✔
1786

1787
    def save_delay_calibration(
1✔
1788
        self,
1789
        filename: str = None,
1790
        overwrite: bool = False,
1791
    ) -> None:
1792
        """Save the generated delay calibration parameters to the folder config file.
1793

1794
        Args:
1795
            filename (str, optional): Filename of the config dictionary to save to.
1796
                Defaults to "sed_config.yaml" in the current folder.
1797
            overwrite (bool, optional): Option to overwrite the present dictionary.
1798
                Defaults to False.
1799
        """
1800
        if filename is None:
1✔
UNCOV
1801
            filename = "sed_config.yaml"
×
1802

1803
        if len(self.dc.calibration) == 0:
1✔
UNCOV
1804
            raise ValueError("No delay calibration parameters to save!")
×
1805
        calibration = {}
1✔
1806
        for key, value in self.dc.calibration.items():
1✔
1807
            if key == "datafile":
1✔
1808
                calibration[key] = value
1✔
1809
            elif key in ["adc_range", "delay_range", "delay_range_mm"]:
1✔
1810
                calibration[key] = [float(i) for i in value]
1✔
1811
            else:
1812
                calibration[key] = float(value)
1✔
1813

1814
        if "creation_date" not in calibration:
1✔
UNCOV
1815
            calibration["creation_date"] = datetime.now().timestamp()
×
1816

1817
        config = {
1✔
1818
            "delay": {
1819
                "calibration": calibration,
1820
            },
1821
        }
1822
        save_config(config, filename, overwrite)
1✔
1823

1824
    def add_delay_offset(
1✔
1825
        self,
1826
        constant: float = None,
1827
        flip_delay_axis: bool = None,
1828
        columns: str | Sequence[str] = None,
1829
        weights: float | Sequence[float] = 1.0,
1830
        reductions: str | Sequence[str] = None,
1831
        preserve_mean: bool | Sequence[bool] = False,
1832
        preview: bool = False,
1833
        verbose: bool = None,
1834
    ) -> None:
1835
        """Shift the delay axis of the dataframe by a constant or other columns.
1836

1837
        Args:
1838
            constant (float, optional): The constant to shift the delay axis by.
1839
            flip_delay_axis (bool, optional): Option to reverse the direction of the delay axis.
1840
            columns (str | Sequence[str], optional): Name of the column(s) to apply the shift from.
1841
            weights (float | Sequence[float], optional): weights to apply to the columns.
1842
                Can also be used to flip the sign (e.g. -1). Defaults to 1.
1843
            reductions (str | Sequence[str], optional): The reduction to apply to the column.
1844
                Should be an available method of dask.dataframe.Series. For example "mean". In this
1845
                case the function is applied to the column to generate a single value for the whole
1846
                dataset. If None, the shift is applied per-dataframe-row. Defaults to None.
1847
                Currently only "mean" is supported.
1848
            preserve_mean (bool | Sequence[bool], optional): Whether to subtract the mean of the
1849
                column before applying the shift. Defaults to False.
1850
            preview (bool, optional): Option to preview the first elements of the data frame.
1851
                Defaults to False.
1852
            verbose (bool, optional): Option to print out diagnostic information.
1853
                Defaults to config["core"]["verbose"].
1854

1855
        Raises:
1856
            ValueError: If the delay column is not in the dataframe.
1857
        """
1858
        if verbose is None:
1✔
1859
            verbose = self.verbose
1✔
1860

1861
        delay_column = self._config["dataframe"]["delay_column"]
1✔
1862
        if delay_column not in self._dataframe.columns:
1✔
1863
            raise ValueError(f"Delay column {delay_column} not found in dataframe! ")
1✔
1864

1865
        if self.dataframe is not None:
1✔
1866
            if verbose:
1✔
1867
                print("Adding delay offset to dataframe:")
1✔
1868
            df, metadata = self.dc.add_offsets(
1✔
1869
                df=self._dataframe,
1870
                constant=constant,
1871
                flip_delay_axis=flip_delay_axis,
1872
                columns=columns,
1873
                delay_column=delay_column,
1874
                weights=weights,
1875
                reductions=reductions,
1876
                preserve_mean=preserve_mean,
1877
                verbose=verbose,
1878
            )
1879
            if self._timed_dataframe is not None and delay_column in self._timed_dataframe.columns:
1✔
1880
                tdf, _ = self.dc.add_offsets(
1✔
1881
                    df=self._timed_dataframe,
1882
                    constant=constant,
1883
                    flip_delay_axis=flip_delay_axis,
1884
                    columns=columns,
1885
                    delay_column=delay_column,
1886
                    weights=weights,
1887
                    reductions=reductions,
1888
                    preserve_mean=preserve_mean,
1889
                    verbose=False,
1890
                )
1891

1892
            self._attributes.add(
1✔
1893
                metadata,
1894
                "delay_offset",
1895
                duplicate_policy="append",
1896
            )
1897
            self._dataframe = df
1✔
1898
            if self._timed_dataframe is not None and delay_column in self._timed_dataframe.columns:
1✔
1899
                self._timed_dataframe = tdf
1✔
1900
        else:
UNCOV
1901
            raise ValueError("No dataframe loaded!")
×
1902
        if preview:
1✔
1903
            print(self._dataframe.head(10))
1✔
1904
        else:
1905
            if verbose:
1✔
1906
                print(self._dataframe)
1✔
1907

1908
    def save_delay_offsets(
1✔
1909
        self,
1910
        filename: str = None,
1911
        overwrite: bool = False,
1912
    ) -> None:
1913
        """Save the generated delay calibration parameters to the folder config file.
1914

1915
        Args:
1916
            filename (str, optional): Filename of the config dictionary to save to.
1917
                Defaults to "sed_config.yaml" in the current folder.
1918
            overwrite (bool, optional): Option to overwrite the present dictionary.
1919
                Defaults to False.
1920
        """
1921
        if filename is None:
1✔
UNCOV
1922
            filename = "sed_config.yaml"
×
1923
        if len(self.dc.offsets) == 0:
1✔
UNCOV
1924
            raise ValueError("No delay offset parameters to save!")
×
1925

1926
        if "creation_date" not in self.ec.offsets.keys():
1✔
1927
            self.ec.offsets["creation_date"] = datetime.now().timestamp()
1✔
1928

1929
        config = {
1✔
1930
            "delay": {
1931
                "offsets": self.dc.offsets,
1932
            },
1933
        }
1934
        save_config(config, filename, overwrite)
1✔
1935
        print(f'Saved delay offset parameters to "{filename}".')
1✔
1936

1937
    def save_workflow_params(
1✔
1938
        self,
1939
        filename: str = None,
1940
        overwrite: bool = False,
1941
    ) -> None:
1942
        """run all save calibration parameter methods
1943

1944
        Args:
1945
            filename (str, optional): Filename of the config dictionary to save to.
1946
                Defaults to "sed_config.yaml" in the current folder.
1947
            overwrite (bool, optional): Option to overwrite the present dictionary.
1948
                Defaults to False.
1949
        """
UNCOV
1950
        for method in [
×
1951
            self.save_splinewarp,
1952
            self.save_transformations,
1953
            self.save_momentum_calibration,
1954
            self.save_energy_correction,
1955
            self.save_energy_calibration,
1956
            self.save_energy_offset,
1957
            self.save_delay_calibration,
1958
            self.save_delay_offsets,
1959
        ]:
UNCOV
1960
            try:
×
UNCOV
1961
                method(filename, overwrite)
×
UNCOV
1962
            except (ValueError, AttributeError, KeyError):
×
UNCOV
1963
                pass
×
1964

1965
    def add_jitter(
1✔
1966
        self,
1967
        cols: list[str] = None,
1968
        amps: float | Sequence[float] = None,
1969
        **kwds,
1970
    ):
1971
        """Add jitter to the selected dataframe columns.
1972

1973
        Args:
1974
            cols (list[str], optional): The colums onto which to apply jitter.
1975
                Defaults to config["dataframe"]["jitter_cols"].
1976
            amps (float | Sequence[float], optional): Amplitude scalings for the
1977
                jittering noise. If one number is given, the same is used for all axes.
1978
                For uniform noise (default) it will cover the interval [-amp, +amp].
1979
                Defaults to config["dataframe"]["jitter_amps"].
1980
            **kwds: additional keyword arguments passed to ``apply_jitter``.
1981
        """
1982
        if cols is None:
1✔
1983
            cols = self._config["dataframe"]["jitter_cols"]
1✔
1984
        for loc, col in enumerate(cols):
1✔
1985
            if col.startswith("@"):
1✔
1986
                cols[loc] = self._config["dataframe"].get(col.strip("@"))
1✔
1987

1988
        if amps is None:
1✔
1989
            amps = self._config["dataframe"]["jitter_amps"]
1✔
1990

1991
        self._dataframe = self._dataframe.map_partitions(
1✔
1992
            apply_jitter,
1993
            cols=cols,
1994
            cols_jittered=cols,
1995
            amps=amps,
1996
            **kwds,
1997
        )
1998
        if self._timed_dataframe is not None:
1✔
1999
            cols_timed = cols.copy()
1✔
2000
            for col in cols:
1✔
2001
                if col not in self._timed_dataframe.columns:
1✔
UNCOV
2002
                    cols_timed.remove(col)
×
2003

2004
            if cols_timed:
1✔
2005
                self._timed_dataframe = self._timed_dataframe.map_partitions(
1✔
2006
                    apply_jitter,
2007
                    cols=cols_timed,
2008
                    cols_jittered=cols_timed,
2009
                )
2010
        metadata = []
1✔
2011
        for col in cols:
1✔
2012
            metadata.append(col)
1✔
2013
        # TODO: allow only appending if columns are not jittered yet
2014
        self._attributes.add(metadata, "jittering", duplicate_policy="append")
1✔
2015

2016
    def add_time_stamped_data(
1✔
2017
        self,
2018
        dest_column: str,
2019
        time_stamps: np.ndarray = None,
2020
        data: np.ndarray = None,
2021
        archiver_channel: str = None,
2022
        **kwds,
2023
    ):
2024
        """Add data in form of timestamp/value pairs to the dataframe using interpolation to the
2025
        timestamps in the dataframe. The time-stamped data can either be provided, or fetched from
2026
        an EPICS archiver instance.
2027

2028
        Args:
2029
            dest_column (str): destination column name
2030
            time_stamps (np.ndarray, optional): Time stamps of the values to add. If omitted,
2031
                time stamps are retrieved from the epics archiver
2032
            data (np.ndarray, optional): Values corresponding at the time stamps in time_stamps.
2033
                If omitted, data are retrieved from the epics archiver.
2034
            archiver_channel (str, optional): EPICS archiver channel from which to retrieve data.
2035
                Either this or data and time_stamps have to be present.
2036
            **kwds: additional keyword arguments passed to ``add_time_stamped_data``.
2037
        """
2038
        time_stamp_column = kwds.pop(
1✔
2039
            "time_stamp_column",
2040
            self._config["dataframe"].get("time_stamp_alias", ""),
2041
        )
2042

2043
        if time_stamps is None and data is None:
1✔
2044
            if archiver_channel is None:
×
2045
                raise ValueError(
×
2046
                    "Either archiver_channel or both time_stamps and data have to be present!",
2047
                )
2048
            if self.loader.__name__ != "mpes":
×
UNCOV
2049
                raise NotImplementedError(
×
2050
                    "This function is currently only implemented for the mpes loader!",
2051
                )
UNCOV
2052
            ts_from, ts_to = cast(MpesLoader, self.loader).get_start_and_end_time()
×
2053
            # get channel data with +-5 seconds safety margin
UNCOV
2054
            time_stamps, data = get_archiver_data(
×
2055
                archiver_url=self._config["metadata"].get("archiver_url", ""),
2056
                archiver_channel=archiver_channel,
2057
                ts_from=ts_from - 5,
2058
                ts_to=ts_to + 5,
2059
            )
2060

2061
        self._dataframe = add_time_stamped_data(
1✔
2062
            self._dataframe,
2063
            time_stamps=time_stamps,
2064
            data=data,
2065
            dest_column=dest_column,
2066
            time_stamp_column=time_stamp_column,
2067
            **kwds,
2068
        )
2069
        if self._timed_dataframe is not None:
1✔
2070
            if time_stamp_column in self._timed_dataframe:
1✔
2071
                self._timed_dataframe = add_time_stamped_data(
1✔
2072
                    self._timed_dataframe,
2073
                    time_stamps=time_stamps,
2074
                    data=data,
2075
                    dest_column=dest_column,
2076
                    time_stamp_column=time_stamp_column,
2077
                    **kwds,
2078
                )
2079
        metadata: list[Any] = []
1✔
2080
        metadata.append(dest_column)
1✔
2081
        metadata.append(time_stamps)
1✔
2082
        metadata.append(data)
1✔
2083
        self._attributes.add(metadata, "time_stamped_data", duplicate_policy="append")
1✔
2084

2085
    def pre_binning(
1✔
2086
        self,
2087
        df_partitions: int | Sequence[int] = 100,
2088
        axes: list[str] = None,
2089
        bins: list[int] = None,
2090
        ranges: Sequence[tuple[float, float]] = None,
2091
        **kwds,
2092
    ) -> xr.DataArray:
2093
        """Function to do an initial binning of the dataframe loaded to the class.
2094

2095
        Args:
2096
            df_partitions (int | Sequence[int], optional): Number of dataframe partitions to
2097
                use for the initial binning. Defaults to 100.
2098
            axes (list[str], optional): Axes to bin.
2099
                Defaults to config["momentum"]["axes"].
2100
            bins (list[int], optional): Bin numbers to use for binning.
2101
                Defaults to config["momentum"]["bins"].
2102
            ranges (Sequence[tuple[float, float]], optional): Ranges to use for binning.
2103
                Defaults to config["momentum"]["ranges"].
2104
            **kwds: Keyword argument passed to ``compute``.
2105

2106
        Returns:
2107
            xr.DataArray: pre-binned data-array.
2108
        """
2109
        if axes is None:
1✔
2110
            axes = self._config["momentum"]["axes"]
1✔
2111
        for loc, axis in enumerate(axes):
1✔
2112
            if axis.startswith("@"):
1✔
2113
                axes[loc] = self._config["dataframe"].get(axis.strip("@"))
1✔
2114

2115
        if bins is None:
1✔
2116
            bins = self._config["momentum"]["bins"]
1✔
2117
        if ranges is None:
1✔
2118
            ranges_ = list(self._config["momentum"]["ranges"])
1✔
2119
            ranges_[2] = np.asarray(ranges_[2]) / 2 ** (
1✔
2120
                self._config["dataframe"]["tof_binning"] - 1
2121
            )
2122
            ranges = [cast(tuple[float, float], tuple(v)) for v in ranges_]
1✔
2123

2124
        assert self._dataframe is not None, "dataframe needs to be loaded first!"
1✔
2125

2126
        return self.compute(
1✔
2127
            bins=bins,
2128
            axes=axes,
2129
            ranges=ranges,
2130
            df_partitions=df_partitions,
2131
            **kwds,
2132
        )
2133

2134
    def compute(
1✔
2135
        self,
2136
        bins: int | dict | tuple | list[int] | list[np.ndarray] | list[tuple] = 100,
2137
        axes: str | Sequence[str] = None,
2138
        ranges: Sequence[tuple[float, float]] = None,
2139
        normalize_to_acquisition_time: bool | str = False,
2140
        **kwds,
2141
    ) -> xr.DataArray:
2142
        """Compute the histogram along the given dimensions.
2143

2144
        Args:
2145
            bins (int | dict | tuple | list[int] | list[np.ndarray] | list[tuple], optional):
2146
                Definition of the bins. Can be any of the following cases:
2147

2148
                - an integer describing the number of bins in on all dimensions
2149
                - a tuple of 3 numbers describing start, end and step of the binning
2150
                  range
2151
                - a np.arrays defining the binning edges
2152
                - a list (NOT a tuple) of any of the above (int, tuple or np.ndarray)
2153
                - a dictionary made of the axes as keys and any of the above as values.
2154

2155
                This takes priority over the axes and range arguments. Defaults to 100.
2156
            axes (str | Sequence[str], optional): The names of the axes (columns)
2157
                on which to calculate the histogram. The order will be the order of the
2158
                dimensions in the resulting array. Defaults to None.
2159
            ranges (Sequence[tuple[float, float]], optional): list of tuples containing
2160
                the start and end point of the binning range. Defaults to None.
2161
            normalize_to_acquisition_time (bool | str): Option to normalize the
2162
                result to the acquistion time. If a "slow" axis was scanned, providing
2163
                the name of the scanned axis will compute and apply the corresponding
2164
                normalization histogram. Defaults to False.
2165
            **kwds: Keyword arguments:
2166

2167
                - **hist_mode**: Histogram calculation method. "numpy" or "numba". See
2168
                  ``bin_dataframe`` for details. Defaults to
2169
                  config["binning"]["hist_mode"].
2170
                - **mode**: Defines how the results from each partition are combined.
2171
                  "fast", "lean" or "legacy". See ``bin_dataframe`` for details.
2172
                  Defaults to config["binning"]["mode"].
2173
                - **pbar**: Option to show the tqdm progress bar. Defaults to
2174
                  config["binning"]["pbar"].
2175
                - **n_cores**: Number of CPU cores to use for parallelization.
2176
                  Defaults to config["binning"]["num_cores"] or N_CPU-1.
2177
                - **threads_per_worker**: Limit the number of threads that
2178
                  multiprocessing can spawn per binning thread. Defaults to
2179
                  config["binning"]["threads_per_worker"].
2180
                - **threadpool_api**: The API to use for multiprocessing. "blas",
2181
                  "openmp" or None. See ``threadpool_limit`` for details. Defaults to
2182
                  config["binning"]["threadpool_API"].
2183
                - **df_partitions**: A sequence of dataframe partitions, or the
2184
                  number of the dataframe partitions to use. Defaults to all partitions.
2185
                - **filter**: A Sequence of Dictionaries with entries "col", "lower_bound",
2186
                  "upper_bound" to apply as filter to the dataframe before binning. The
2187
                  dataframe in the class remains unmodified by this.
2188

2189
                Additional kwds are passed to ``bin_dataframe``.
2190

2191
        Raises:
2192
            AssertError: Rises when no dataframe has been loaded.
2193

2194
        Returns:
2195
            xr.DataArray: The result of the n-dimensional binning represented in an
2196
            xarray object, combining the data with the axes.
2197
        """
2198
        assert self._dataframe is not None, "dataframe needs to be loaded first!"
1✔
2199

2200
        hist_mode = kwds.pop("hist_mode", self._config["binning"]["hist_mode"])
1✔
2201
        mode = kwds.pop("mode", self._config["binning"]["mode"])
1✔
2202
        pbar = kwds.pop("pbar", self._config["binning"]["pbar"])
1✔
2203
        num_cores = kwds.pop("num_cores", self._config["binning"]["num_cores"])
1✔
2204
        threads_per_worker = kwds.pop(
1✔
2205
            "threads_per_worker",
2206
            self._config["binning"]["threads_per_worker"],
2207
        )
2208
        threadpool_api = kwds.pop(
1✔
2209
            "threadpool_API",
2210
            self._config["binning"]["threadpool_API"],
2211
        )
2212
        df_partitions: int | Sequence[int] = kwds.pop("df_partitions", None)
1✔
2213
        if isinstance(df_partitions, int):
1✔
2214
            df_partitions = list(range(0, min(df_partitions, self._dataframe.npartitions)))
1✔
2215
        if df_partitions is not None:
1✔
2216
            dataframe = self._dataframe.partitions[df_partitions]
1✔
2217
        else:
2218
            dataframe = self._dataframe
1✔
2219

2220
        filter_params = kwds.pop("filter", None)
1✔
2221
        if filter_params is not None:
1✔
2222
            try:
1✔
2223
                for param in filter_params:
1✔
2224
                    if "col" not in param:
1✔
2225
                        raise ValueError(
1✔
2226
                            "'col' needs to be defined for each filter entry! ",
2227
                            f"Not present in {param}.",
2228
                        )
2229
                    assert set(param.keys()).issubset({"col", "lower_bound", "upper_bound"})
1✔
2230
                    dataframe = apply_filter(dataframe, **param)
1✔
2231
            except AssertionError as exc:
1✔
2232
                invalid_keys = set(param.keys()) - {"lower_bound", "upper_bound"}
1✔
2233
                raise ValueError(
1✔
2234
                    "Only 'col', 'lower_bound' and 'upper_bound' allowed as filter entries. ",
2235
                    f"Parameters {invalid_keys} not valid in {param}.",
2236
                ) from exc
2237

2238
        self._binned = bin_dataframe(
1✔
2239
            df=dataframe,
2240
            bins=bins,
2241
            axes=axes,
2242
            ranges=ranges,
2243
            hist_mode=hist_mode,
2244
            mode=mode,
2245
            pbar=pbar,
2246
            n_cores=num_cores,
2247
            threads_per_worker=threads_per_worker,
2248
            threadpool_api=threadpool_api,
2249
            **kwds,
2250
        )
2251

2252
        for dim in self._binned.dims:
1✔
2253
            try:
1✔
2254
                self._binned[dim].attrs["unit"] = self._config["dataframe"]["units"][dim]
1✔
2255
            except KeyError:
1✔
2256
                pass
1✔
2257

2258
        self._binned.attrs["units"] = "counts"
1✔
2259
        self._binned.attrs["long_name"] = "photoelectron counts"
1✔
2260
        self._binned.attrs["metadata"] = self._attributes.metadata
1✔
2261

2262
        if normalize_to_acquisition_time:
1✔
2263
            if isinstance(normalize_to_acquisition_time, str):
1✔
2264
                axis = normalize_to_acquisition_time
1✔
2265
                print(
1✔
2266
                    f"Calculate normalization histogram for axis '{axis}'...",
2267
                )
2268
                self._normalization_histogram = self.get_normalization_histogram(
1✔
2269
                    axis=axis,
2270
                    df_partitions=df_partitions,
2271
                )
2272
                # if the axes are named correctly, xarray figures out the normalization correctly
2273
                self._normalized = self._binned / self._normalization_histogram
1✔
2274
                self._attributes.add(
1✔
2275
                    self._normalization_histogram.values,
2276
                    name="normalization_histogram",
2277
                    duplicate_policy="overwrite",
2278
                )
2279
            else:
2280
                acquisition_time = self.loader.get_elapsed_time(
×
2281
                    fids=df_partitions,
2282
                )
UNCOV
2283
                if acquisition_time > 0:
×
UNCOV
2284
                    self._normalized = self._binned / acquisition_time
×
UNCOV
2285
                self._attributes.add(
×
2286
                    acquisition_time,
2287
                    name="normalization_histogram",
2288
                    duplicate_policy="overwrite",
2289
                )
2290

2291
            self._normalized.attrs["units"] = "counts/second"
1✔
2292
            self._normalized.attrs["long_name"] = "photoelectron counts per second"
1✔
2293
            self._normalized.attrs["metadata"] = self._attributes.metadata
1✔
2294

2295
            return self._normalized
1✔
2296

2297
        return self._binned
1✔
2298

2299
    def get_normalization_histogram(
1✔
2300
        self,
2301
        axis: str = "delay",
2302
        use_time_stamps: bool = False,
2303
        **kwds,
2304
    ) -> xr.DataArray:
2305
        """Generates a normalization histogram from the timed dataframe. Optionally,
2306
        use the TimeStamps column instead.
2307

2308
        Args:
2309
            axis (str, optional): The axis for which to compute histogram.
2310
                Defaults to "delay".
2311
            use_time_stamps (bool, optional): Use the TimeStamps column of the
2312
                dataframe, rather than the timed dataframe. Defaults to False.
2313
            **kwds: Keyword arguments:
2314

2315
                - **df_partitions**: A sequence of dataframe partitions, or the
2316
                  number of the dataframe partitions to use. Defaults to all partitions.
2317

2318
        Raises:
2319
            ValueError: Raised if no data are binned.
2320
            ValueError: Raised if 'axis' not in binned coordinates.
2321
            ValueError: Raised if config["dataframe"]["time_stamp_alias"] not found
2322
                in Dataframe.
2323

2324
        Returns:
2325
            xr.DataArray: The computed normalization histogram (in TimeStamp units
2326
            per bin).
2327
        """
2328

2329
        if self._binned is None:
1✔
2330
            raise ValueError("Need to bin data first!")
1✔
2331
        if axis not in self._binned.coords:
1✔
2332
            raise ValueError(f"Axis '{axis}' not found in binned data!")
1✔
2333

2334
        df_partitions: int | Sequence[int] = kwds.pop("df_partitions", None)
1✔
2335
        if isinstance(df_partitions, int):
1✔
2336
            df_partitions = list(range(0, min(df_partitions, self._dataframe.npartitions)))
1✔
2337
        if use_time_stamps or self._timed_dataframe is None:
1✔
2338
            if df_partitions is not None:
1✔
2339
                self._normalization_histogram = normalization_histogram_from_timestamps(
1✔
2340
                    self._dataframe.partitions[df_partitions],
2341
                    axis,
2342
                    self._binned.coords[axis].values,
2343
                    self._config["dataframe"]["time_stamp_alias"],
2344
                )
2345
            else:
UNCOV
2346
                self._normalization_histogram = normalization_histogram_from_timestamps(
×
2347
                    self._dataframe,
2348
                    axis,
2349
                    self._binned.coords[axis].values,
2350
                    self._config["dataframe"]["time_stamp_alias"],
2351
                )
2352
        else:
2353
            if df_partitions is not None:
1✔
2354
                self._normalization_histogram = normalization_histogram_from_timed_dataframe(
1✔
2355
                    self._timed_dataframe.partitions[df_partitions],
2356
                    axis,
2357
                    self._binned.coords[axis].values,
2358
                    self._config["dataframe"]["timed_dataframe_unit_time"],
2359
                )
2360
            else:
UNCOV
2361
                self._normalization_histogram = normalization_histogram_from_timed_dataframe(
×
2362
                    self._timed_dataframe,
2363
                    axis,
2364
                    self._binned.coords[axis].values,
2365
                    self._config["dataframe"]["timed_dataframe_unit_time"],
2366
                )
2367

2368
        return self._normalization_histogram
1✔
2369

2370
    def view_event_histogram(
1✔
2371
        self,
2372
        dfpid: int,
2373
        ncol: int = 2,
2374
        bins: Sequence[int] = None,
2375
        axes: Sequence[str] = None,
2376
        ranges: Sequence[tuple[float, float]] = None,
2377
        backend: str = "bokeh",
2378
        legend: bool = True,
2379
        histkwds: dict = None,
2380
        legkwds: dict = None,
2381
        **kwds,
2382
    ):
2383
        """Plot individual histograms of specified dimensions (axes) from a substituent
2384
        dataframe partition.
2385

2386
        Args:
2387
            dfpid (int): Number of the data frame partition to look at.
2388
            ncol (int, optional): Number of columns in the plot grid. Defaults to 2.
2389
            bins (Sequence[int], optional): Number of bins to use for the speicified
2390
                axes. Defaults to config["histogram"]["bins"].
2391
            axes (Sequence[str], optional): Names of the axes to display.
2392
                Defaults to config["histogram"]["axes"].
2393
            ranges (Sequence[tuple[float, float]], optional): Value ranges of all
2394
                specified axes. Defaults toconfig["histogram"]["ranges"].
2395
            backend (str, optional): Backend of the plotting library
2396
                ('matplotlib' or 'bokeh'). Defaults to "bokeh".
2397
            legend (bool, optional): Option to include a legend in the histogram plots.
2398
                Defaults to True.
2399
            histkwds (dict, optional): Keyword arguments for histograms
2400
                (see ``matplotlib.pyplot.hist()``). Defaults to {}.
2401
            legkwds (dict, optional): Keyword arguments for legend
2402
                (see ``matplotlib.pyplot.legend()``). Defaults to {}.
2403
            **kwds: Extra keyword arguments passed to
2404
                ``sed.diagnostics.grid_histogram()``.
2405

2406
        Raises:
2407
            TypeError: Raises when the input values are not of the correct type.
2408
        """
2409
        if bins is None:
1✔
2410
            bins = self._config["histogram"]["bins"]
1✔
2411
        if axes is None:
1✔
2412
            axes = self._config["histogram"]["axes"]
1✔
2413
        axes = list(axes)
1✔
2414
        for loc, axis in enumerate(axes):
1✔
2415
            if axis.startswith("@"):
1✔
2416
                axes[loc] = self._config["dataframe"].get(axis.strip("@"))
1✔
2417
        if ranges is None:
1✔
2418
            ranges = list(self._config["histogram"]["ranges"])
1✔
2419
            for loc, axis in enumerate(axes):
1✔
2420
                if axis == self._config["dataframe"]["tof_column"]:
1✔
2421
                    ranges[loc] = np.asarray(ranges[loc]) / 2 ** (
1✔
2422
                        self._config["dataframe"]["tof_binning"] - 1
2423
                    )
2424
                elif axis == self._config["dataframe"]["adc_column"]:
1✔
UNCOV
2425
                    ranges[loc] = np.asarray(ranges[loc]) / 2 ** (
×
2426
                        self._config["dataframe"]["adc_binning"] - 1
2427
                    )
2428

2429
        input_types = map(type, [axes, bins, ranges])
1✔
2430
        allowed_types = [list, tuple]
1✔
2431

2432
        df = self._dataframe
1✔
2433

2434
        if not set(input_types).issubset(allowed_types):
1✔
UNCOV
2435
            raise TypeError(
×
2436
                "Inputs of axes, bins, ranges need to be list or tuple!",
2437
            )
2438

2439
        # Read out the values for the specified groups
2440
        group_dict_dd = {}
1✔
2441
        dfpart = df.get_partition(dfpid)
1✔
2442
        cols = dfpart.columns
1✔
2443
        for ax in axes:
1✔
2444
            group_dict_dd[ax] = dfpart.values[:, cols.get_loc(ax)]
1✔
2445
        group_dict = ddf.compute(group_dict_dd)[0]
1✔
2446

2447
        # Plot multiple histograms in a grid
2448
        grid_histogram(
1✔
2449
            group_dict,
2450
            ncol=ncol,
2451
            rvs=axes,
2452
            rvbins=bins,
2453
            rvranges=ranges,
2454
            backend=backend,
2455
            legend=legend,
2456
            histkwds=histkwds,
2457
            legkwds=legkwds,
2458
            **kwds,
2459
        )
2460

2461
    def save(
1✔
2462
        self,
2463
        faddr: str,
2464
        **kwds,
2465
    ):
2466
        """Saves the binned data to the provided path and filename.
2467

2468
        Args:
2469
            faddr (str): Path and name of the file to write. Its extension determines
2470
                the file type to write. Valid file types are:
2471

2472
                - "*.tiff", "*.tif": Saves a TIFF stack.
2473
                - "*.h5", "*.hdf5": Saves an HDF5 file.
2474
                - "*.nxs", "*.nexus": Saves a NeXus file.
2475

2476
            **kwds: Keyword argumens, which are passed to the writer functions:
2477
                For TIFF writing:
2478

2479
                - **alias_dict**: Dictionary of dimension aliases to use.
2480

2481
                For HDF5 writing:
2482

2483
                - **mode**: hdf5 read/write mode. Defaults to "w".
2484

2485
                For NeXus:
2486

2487
                - **reader**: Name of the nexustools reader to use.
2488
                  Defaults to config["nexus"]["reader"]
2489
                - **definiton**: NeXus application definition to use for saving.
2490
                  Must be supported by the used ``reader``. Defaults to
2491
                  config["nexus"]["definition"]
2492
                - **input_files**: A list of input files to pass to the reader.
2493
                  Defaults to config["nexus"]["input_files"]
2494
                - **eln_data**: An electronic-lab-notebook file in '.yaml' format
2495
                  to add to the list of files to pass to the reader.
2496
        """
2497
        if self._binned is None:
1✔
2498
            raise NameError("Need to bin data first!")
1✔
2499

2500
        if self._normalized is not None:
1✔
UNCOV
2501
            data = self._normalized
×
2502
        else:
2503
            data = self._binned
1✔
2504

2505
        extension = pathlib.Path(faddr).suffix
1✔
2506

2507
        if extension in (".tif", ".tiff"):
1✔
2508
            to_tiff(
1✔
2509
                data=data,
2510
                faddr=faddr,
2511
                **kwds,
2512
            )
2513
        elif extension in (".h5", ".hdf5"):
1✔
2514
            to_h5(
1✔
2515
                data=data,
2516
                faddr=faddr,
2517
                **kwds,
2518
            )
2519
        elif extension in (".nxs", ".nexus"):
1✔
2520
            try:
1✔
2521
                reader = kwds.pop("reader", self._config["nexus"]["reader"])
1✔
2522
                definition = kwds.pop(
1✔
2523
                    "definition",
2524
                    self._config["nexus"]["definition"],
2525
                )
2526
                input_files = kwds.pop(
1✔
2527
                    "input_files",
2528
                    self._config["nexus"]["input_files"],
2529
                )
UNCOV
2530
            except KeyError as exc:
×
UNCOV
2531
                raise ValueError(
×
2532
                    "The nexus reader, definition and input files need to be provide!",
2533
                ) from exc
2534

2535
            if isinstance(input_files, str):
1✔
2536
                input_files = [input_files]
1✔
2537

2538
            if "eln_data" in kwds:
1✔
UNCOV
2539
                input_files.append(kwds.pop("eln_data"))
×
2540

2541
            to_nexus(
1✔
2542
                data=data,
2543
                faddr=faddr,
2544
                reader=reader,
2545
                definition=definition,
2546
                input_files=input_files,
2547
                **kwds,
2548
            )
2549

2550
        else:
2551
            raise NotImplementedError(
1✔
2552
                f"Unrecognized file format: {extension}.",
2553
            )
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc