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

OpenCOMPES / sed / 9845883761

08 Jul 2024 07:40PM UTC coverage: 92.521% (+0.05%) from 92.472%
9845883761

Pull #411

github

rettigl
update energy calibration description
Pull Request #411: Energy calibration bias shift

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

91 existing lines in 2 files now uncovered.

6915 of 7474 relevant lines covered (92.52%)

0.93 hits per line

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

86.26
/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["core"].get("num_cores", N_CPU - 1)
1✔
106
        if num_cores >= N_CPU:
1✔
107
            num_cores = N_CPU - 1
1✔
108
        self._config["core"]["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
                    num_cores=self._config["core"]["num_cores"],
158
                    **self._config["core"].get("copy_tool_kwds", {}),
159
                )
160
            except KeyError:
1✔
161
                self.use_copy_tool = False
1✔
162

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

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

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

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

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

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

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

201
        return html
×
202

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

363
        return path
1✔
364

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1474
        else:
UNCOV
1475
            raise ValueError("No dataframe loaded!")
×
1476
        if preview:
1✔
UNCOV
1477
            print(self._dataframe.head(10))
×
1478
        else:
1479
            if verbose:
1✔
1480
                print(self._dataframe)
1✔
1481

1482
    def add_energy_offset(
1✔
1483
        self,
1484
        constant: float = None,
1485
        columns: str | Sequence[str] = None,
1486
        weights: float | Sequence[float] = None,
1487
        reductions: str | Sequence[str] = None,
1488
        preserve_mean: bool | Sequence[bool] = None,
1489
        preview: bool = False,
1490
        verbose: bool = None,
1491
    ) -> None:
1492
        """Shift the energy axis of the dataframe by a given amount.
1493

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

1511
        Raises:
1512
            ValueError: If the energy column is not in the dataframe.
1513
        """
1514
        if verbose is None:
1✔
1515
            verbose = self.verbose
1✔
1516

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

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

1564
    def save_energy_offset(
1✔
1565
        self,
1566
        filename: str = None,
1567
        overwrite: bool = False,
1568
    ):
1569
        """Save the generated energy calibration parameters to the folder config file.
1570

1571
        Args:
1572
            filename (str, optional): Filename of the config dictionary to save to.
1573
                Defaults to "sed_config.yaml" in the current folder.
1574
            overwrite (bool, optional): Option to overwrite the present dictionary.
1575
                Defaults to False.
1576
        """
UNCOV
1577
        if filename is None:
×
UNCOV
1578
            filename = "sed_config.yaml"
×
UNCOV
1579
        if len(self.ec.offsets) == 0:
×
UNCOV
1580
            raise ValueError("No energy offset parameters to save!")
×
1581

UNCOV
1582
        if "creation_date" not in self.ec.offsets.keys():
×
1583
            self.ec.offsets["creation_date"] = datetime.now().timestamp()
×
1584

1585
        config = {"energy": {"offsets": self.ec.offsets}}
×
1586
        save_config(config, filename, overwrite)
×
UNCOV
1587
        print(f'Saved energy offset parameters to "{filename}".')
×
1588

1589
    def append_tof_ns_axis(
1✔
1590
        self,
1591
        preview: bool = False,
1592
        verbose: bool = None,
1593
        **kwds,
1594
    ):
1595
        """Convert time-of-flight channel steps to nanoseconds.
1596

1597
        Args:
1598
            tof_ns_column (str, optional): Name of the generated column containing the
1599
                time-of-flight in nanosecond.
1600
                Defaults to config["dataframe"]["tof_ns_column"].
1601
            preview (bool, optional): Option to preview the first elements of the data frame.
1602
                Defaults to False.
1603
            verbose (bool, optional): Option to print out diagnostic information.
1604
                Defaults to config["core"]["verbose"].
1605
            **kwds: additional arguments are passed to ``EnergyCalibrator.tof_step_to_ns()``.
1606

1607
        """
1608
        if verbose is None:
1✔
1609
            verbose = self.verbose
1✔
1610

1611
        tof_column = self._config["dataframe"]["tof_column"]
1✔
1612

1613
        if self._dataframe is not None:
1✔
1614
            if verbose:
1✔
1615
                print("Adding time-of-flight column in nanoseconds to dataframe:")
1✔
1616
            # TODO assert order of execution through metadata
1617

1618
            df, metadata = self.ec.append_tof_ns_axis(
1✔
1619
                df=self._dataframe,
1620
                **kwds,
1621
            )
1622
            if self._timed_dataframe is not None and tof_column in self._timed_dataframe.columns:
1✔
1623
                tdf, _ = self.ec.append_tof_ns_axis(
1✔
1624
                    df=self._timed_dataframe,
1625
                    **kwds,
1626
                )
1627

1628
            self._attributes.add(
1✔
1629
                metadata,
1630
                "tof_ns_conversion",
1631
                duplicate_policy="overwrite",
1632
            )
1633
            self._dataframe = df
1✔
1634
            if self._timed_dataframe is not None and tof_column in self._timed_dataframe.columns:
1✔
1635
                self._timed_dataframe = tdf
1✔
1636
        else:
UNCOV
1637
            raise ValueError("No dataframe loaded!")
×
1638
        if preview:
1✔
UNCOV
1639
            print(self._dataframe.head(10))
×
1640
        else:
1641
            if verbose:
1✔
1642
                print(self._dataframe)
1✔
1643

1644
    def align_dld_sectors(
1✔
1645
        self,
1646
        sector_delays: np.ndarray = None,
1647
        preview: bool = False,
1648
        verbose: bool = None,
1649
        **kwds,
1650
    ):
1651
        """Align the 8s sectors of the HEXTOF endstation.
1652

1653
        Args:
1654
            sector_delays (np.ndarray, optional): Array containing the sector delays. Defaults to
1655
                config["dataframe"]["sector_delays"].
1656
            preview (bool, optional): Option to preview the first elements of the data frame.
1657
                Defaults to False.
1658
            verbose (bool, optional): Option to print out diagnostic information.
1659
                Defaults to config["core"]["verbose"].
1660
            **kwds: additional arguments are passed to ``EnergyCalibrator.align_dld_sectors()``.
1661
        """
1662
        if verbose is None:
1✔
1663
            verbose = self.verbose
1✔
1664

1665
        tof_column = self._config["dataframe"]["tof_column"]
1✔
1666

1667
        if self._dataframe is not None:
1✔
1668
            if verbose:
1✔
1669
                print("Aligning 8s sectors of dataframe")
1✔
1670
            # TODO assert order of execution through metadata
1671

1672
            df, metadata = self.ec.align_dld_sectors(
1✔
1673
                df=self._dataframe,
1674
                sector_delays=sector_delays,
1675
                **kwds,
1676
            )
1677
            if self._timed_dataframe is not None and tof_column in self._timed_dataframe.columns:
1✔
UNCOV
1678
                tdf, _ = self.ec.align_dld_sectors(
×
1679
                    df=self._timed_dataframe,
1680
                    sector_delays=sector_delays,
1681
                    **kwds,
1682
                )
1683

1684
            self._attributes.add(
1✔
1685
                metadata,
1686
                "dld_sector_alignment",
1687
                duplicate_policy="raise",
1688
            )
1689
            self._dataframe = df
1✔
1690
            if self._timed_dataframe is not None and tof_column in self._timed_dataframe.columns:
1✔
UNCOV
1691
                self._timed_dataframe = tdf
×
1692
        else:
UNCOV
1693
            raise ValueError("No dataframe loaded!")
×
1694
        if preview:
1✔
UNCOV
1695
            print(self._dataframe.head(10))
×
1696
        else:
1697
            if verbose:
1✔
1698
                print(self._dataframe)
1✔
1699

1700
    # Delay calibration function
1701
    def calibrate_delay_axis(
1✔
1702
        self,
1703
        delay_range: tuple[float, float] = None,
1704
        datafile: str = None,
1705
        preview: bool = False,
1706
        verbose: bool = None,
1707
        **kwds,
1708
    ):
1709
        """Append delay column to dataframe. Either provide delay ranges, or read
1710
        them from a file.
1711

1712
        Args:
1713
            delay_range (tuple[float, float], optional): The scanned delay range in
1714
                picoseconds. Defaults to None.
1715
            datafile (str, optional): The file from which to read the delay ranges.
1716
                Defaults to None.
1717
            preview (bool, optional): Option to preview the first elements of the data frame.
1718
                Defaults to False.
1719
            verbose (bool, optional): Option to print out diagnostic information.
1720
                Defaults to config["core"]["verbose"].
1721
            **kwds: Keyword args passed to ``DelayCalibrator.append_delay_axis``.
1722
        """
1723
        if verbose is None:
1✔
1724
            verbose = self.verbose
1✔
1725

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

1730
        if self._dataframe is not None:
1✔
1731
            if verbose:
1✔
1732
                print("Adding delay column to dataframe:")
1✔
1733

1734
            if delay_range is None and datafile is None:
1✔
1735
                if len(self.dc.calibration) == 0:
1✔
1736
                    try:
1✔
1737
                        datafile = self._files[0]
1✔
UNCOV
1738
                    except IndexError:
×
UNCOV
1739
                        print(
×
1740
                            "No datafile available, specify either",
1741
                            " 'datafile' or 'delay_range'",
1742
                        )
UNCOV
1743
                        raise
×
1744

1745
            df, metadata = self.dc.append_delay_axis(
1✔
1746
                self._dataframe,
1747
                delay_range=delay_range,
1748
                datafile=datafile,
1749
                verbose=verbose,
1750
                **kwds,
1751
            )
1752
            if self._timed_dataframe is not None and adc_column in self._timed_dataframe.columns:
1✔
1753
                tdf, _ = self.dc.append_delay_axis(
1✔
1754
                    self._timed_dataframe,
1755
                    delay_range=delay_range,
1756
                    datafile=datafile,
1757
                    verbose=False,
1758
                    **kwds,
1759
                )
1760

1761
            # Add Metadata
1762
            self._attributes.add(
1✔
1763
                metadata,
1764
                "delay_calibration",
1765
                duplicate_policy="overwrite",
1766
            )
1767
            self._dataframe = df
1✔
1768
            if self._timed_dataframe is not None and adc_column in self._timed_dataframe.columns:
1✔
1769
                self._timed_dataframe = tdf
1✔
1770
        else:
UNCOV
1771
            raise ValueError("No dataframe loaded!")
×
1772
        if preview:
1✔
1773
            print(self._dataframe.head(10))
1✔
1774
        else:
1775
            if self.verbose:
1✔
1776
                print(self._dataframe)
1✔
1777

1778
    def save_delay_calibration(
1✔
1779
        self,
1780
        filename: str = None,
1781
        overwrite: bool = False,
1782
    ) -> None:
1783
        """Save the generated delay calibration parameters to the folder config file.
1784

1785
        Args:
1786
            filename (str, optional): Filename of the config dictionary to save to.
1787
                Defaults to "sed_config.yaml" in the current folder.
1788
            overwrite (bool, optional): Option to overwrite the present dictionary.
1789
                Defaults to False.
1790
        """
1791
        if filename is None:
1✔
UNCOV
1792
            filename = "sed_config.yaml"
×
1793

1794
        if len(self.dc.calibration) == 0:
1✔
UNCOV
1795
            raise ValueError("No delay calibration parameters to save!")
×
1796
        calibration = {}
1✔
1797
        for key, value in self.dc.calibration.items():
1✔
1798
            if key == "datafile":
1✔
1799
                calibration[key] = value
1✔
1800
            elif key in ["adc_range", "delay_range", "delay_range_mm"]:
1✔
1801
                calibration[key] = [float(i) for i in value]
1✔
1802
            else:
1803
                calibration[key] = float(value)
1✔
1804

1805
        if "creation_date" not in calibration:
1✔
UNCOV
1806
            calibration["creation_date"] = datetime.now().timestamp()
×
1807

1808
        config = {
1✔
1809
            "delay": {
1810
                "calibration": calibration,
1811
            },
1812
        }
1813
        save_config(config, filename, overwrite)
1✔
1814

1815
    def add_delay_offset(
1✔
1816
        self,
1817
        constant: float = None,
1818
        flip_delay_axis: bool = None,
1819
        columns: str | Sequence[str] = None,
1820
        weights: float | Sequence[float] = 1.0,
1821
        reductions: str | Sequence[str] = None,
1822
        preserve_mean: bool | Sequence[bool] = False,
1823
        preview: bool = False,
1824
        verbose: bool = None,
1825
    ) -> None:
1826
        """Shift the delay axis of the dataframe by a constant or other columns.
1827

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

1846
        Raises:
1847
            ValueError: If the delay column is not in the dataframe.
1848
        """
1849
        if verbose is None:
1✔
1850
            verbose = self.verbose
1✔
1851

1852
        delay_column = self._config["dataframe"]["delay_column"]
1✔
1853
        if delay_column not in self._dataframe.columns:
1✔
1854
            raise ValueError(f"Delay column {delay_column} not found in dataframe! ")
1✔
1855

1856
        if self.dataframe is not None:
1✔
1857
            if verbose:
1✔
1858
                print("Adding delay offset to dataframe:")
1✔
1859
            df, metadata = self.dc.add_offsets(
1✔
1860
                df=self._dataframe,
1861
                constant=constant,
1862
                flip_delay_axis=flip_delay_axis,
1863
                columns=columns,
1864
                delay_column=delay_column,
1865
                weights=weights,
1866
                reductions=reductions,
1867
                preserve_mean=preserve_mean,
1868
                verbose=verbose,
1869
            )
1870
            if self._timed_dataframe is not None and delay_column in self._timed_dataframe.columns:
1✔
1871
                tdf, _ = self.dc.add_offsets(
1✔
1872
                    df=self._timed_dataframe,
1873
                    constant=constant,
1874
                    flip_delay_axis=flip_delay_axis,
1875
                    columns=columns,
1876
                    delay_column=delay_column,
1877
                    weights=weights,
1878
                    reductions=reductions,
1879
                    preserve_mean=preserve_mean,
1880
                    verbose=False,
1881
                )
1882

1883
            self._attributes.add(
1✔
1884
                metadata,
1885
                "delay_offset",
1886
                duplicate_policy="append",
1887
            )
1888
            self._dataframe = df
1✔
1889
            if self._timed_dataframe is not None and delay_column in self._timed_dataframe.columns:
1✔
1890
                self._timed_dataframe = tdf
1✔
1891
        else:
UNCOV
1892
            raise ValueError("No dataframe loaded!")
×
1893
        if preview:
1✔
1894
            print(self._dataframe.head(10))
1✔
1895
        else:
1896
            if verbose:
1✔
1897
                print(self._dataframe)
1✔
1898

1899
    def save_delay_offsets(
1✔
1900
        self,
1901
        filename: str = None,
1902
        overwrite: bool = False,
1903
    ) -> None:
1904
        """Save the generated delay calibration parameters to the folder config file.
1905

1906
        Args:
1907
            filename (str, optional): Filename of the config dictionary to save to.
1908
                Defaults to "sed_config.yaml" in the current folder.
1909
            overwrite (bool, optional): Option to overwrite the present dictionary.
1910
                Defaults to False.
1911
        """
1912
        if filename is None:
1✔
UNCOV
1913
            filename = "sed_config.yaml"
×
1914
        if len(self.dc.offsets) == 0:
1✔
UNCOV
1915
            raise ValueError("No delay offset parameters to save!")
×
1916

1917
        if "creation_date" not in self.ec.offsets.keys():
1✔
1918
            self.ec.offsets["creation_date"] = datetime.now().timestamp()
1✔
1919

1920
        config = {
1✔
1921
            "delay": {
1922
                "offsets": self.dc.offsets,
1923
            },
1924
        }
1925
        save_config(config, filename, overwrite)
1✔
1926
        print(f'Saved delay offset parameters to "{filename}".')
1✔
1927

1928
    def save_workflow_params(
1✔
1929
        self,
1930
        filename: str = None,
1931
        overwrite: bool = False,
1932
    ) -> None:
1933
        """run all save calibration parameter methods
1934

1935
        Args:
1936
            filename (str, optional): Filename of the config dictionary to save to.
1937
                Defaults to "sed_config.yaml" in the current folder.
1938
            overwrite (bool, optional): Option to overwrite the present dictionary.
1939
                Defaults to False.
1940
        """
UNCOV
1941
        for method in [
×
1942
            self.save_splinewarp,
1943
            self.save_transformations,
1944
            self.save_momentum_calibration,
1945
            self.save_energy_correction,
1946
            self.save_energy_calibration,
1947
            self.save_energy_offset,
1948
            self.save_delay_calibration,
1949
            self.save_delay_offsets,
1950
        ]:
UNCOV
1951
            try:
×
UNCOV
1952
                method(filename, overwrite)
×
UNCOV
1953
            except (ValueError, AttributeError, KeyError):
×
UNCOV
1954
                pass
×
1955

1956
    def add_jitter(
1✔
1957
        self,
1958
        cols: list[str] = None,
1959
        amps: float | Sequence[float] = None,
1960
        **kwds,
1961
    ):
1962
        """Add jitter to the selected dataframe columns.
1963

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

1979
        if amps is None:
1✔
1980
            amps = self._config["dataframe"]["jitter_amps"]
1✔
1981

1982
        self._dataframe = self._dataframe.map_partitions(
1✔
1983
            apply_jitter,
1984
            cols=cols,
1985
            cols_jittered=cols,
1986
            amps=amps,
1987
            **kwds,
1988
        )
1989
        if self._timed_dataframe is not None:
1✔
1990
            cols_timed = cols.copy()
1✔
1991
            for col in cols:
1✔
1992
                if col not in self._timed_dataframe.columns:
1✔
UNCOV
1993
                    cols_timed.remove(col)
×
1994

1995
            if cols_timed:
1✔
1996
                self._timed_dataframe = self._timed_dataframe.map_partitions(
1✔
1997
                    apply_jitter,
1998
                    cols=cols_timed,
1999
                    cols_jittered=cols_timed,
2000
                )
2001
        metadata = []
1✔
2002
        for col in cols:
1✔
2003
            metadata.append(col)
1✔
2004
        # TODO: allow only appending if columns are not jittered yet
2005
        self._attributes.add(metadata, "jittering", duplicate_policy="append")
1✔
2006

2007
    def add_time_stamped_data(
1✔
2008
        self,
2009
        dest_column: str,
2010
        time_stamps: np.ndarray = None,
2011
        data: np.ndarray = None,
2012
        archiver_channel: str = None,
2013
        **kwds,
2014
    ):
2015
        """Add data in form of timestamp/value pairs to the dataframe using interpolation to the
2016
        timestamps in the dataframe. The time-stamped data can either be provided, or fetched from
2017
        an EPICS archiver instance.
2018

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

2034
        if time_stamps is None and data is None:
1✔
UNCOV
2035
            if archiver_channel is None:
×
UNCOV
2036
                raise ValueError(
×
2037
                    "Either archiver_channel or both time_stamps and data have to be present!",
2038
                )
UNCOV
2039
            if self.loader.__name__ != "mpes":
×
UNCOV
2040
                raise NotImplementedError(
×
2041
                    "This function is currently only implemented for the mpes loader!",
2042
                )
UNCOV
2043
            ts_from, ts_to = cast(MpesLoader, self.loader).get_start_and_end_time()
×
2044
            # get channel data with +-5 seconds safety margin
2045
            time_stamps, data = get_archiver_data(
×
2046
                archiver_url=self._config["metadata"].get("archiver_url", ""),
2047
                archiver_channel=archiver_channel,
2048
                ts_from=ts_from - 5,
2049
                ts_to=ts_to + 5,
2050
            )
2051

2052
        self._dataframe = add_time_stamped_data(
1✔
2053
            self._dataframe,
2054
            time_stamps=time_stamps,
2055
            data=data,
2056
            dest_column=dest_column,
2057
            time_stamp_column=time_stamp_column,
2058
            **kwds,
2059
        )
2060
        if self._timed_dataframe is not None:
1✔
2061
            if time_stamp_column in self._timed_dataframe:
1✔
2062
                self._timed_dataframe = add_time_stamped_data(
1✔
2063
                    self._timed_dataframe,
2064
                    time_stamps=time_stamps,
2065
                    data=data,
2066
                    dest_column=dest_column,
2067
                    time_stamp_column=time_stamp_column,
2068
                    **kwds,
2069
                )
2070
        metadata: list[Any] = []
1✔
2071
        metadata.append(dest_column)
1✔
2072
        metadata.append(time_stamps)
1✔
2073
        metadata.append(data)
1✔
2074
        self._attributes.add(metadata, "time_stamped_data", duplicate_policy="append")
1✔
2075

2076
    def pre_binning(
1✔
2077
        self,
2078
        df_partitions: int | Sequence[int] = 100,
2079
        axes: list[str] = None,
2080
        bins: list[int] = None,
2081
        ranges: Sequence[tuple[float, float]] = None,
2082
        **kwds,
2083
    ) -> xr.DataArray:
2084
        """Function to do an initial binning of the dataframe loaded to the class.
2085

2086
        Args:
2087
            df_partitions (int | Sequence[int], optional): Number of dataframe partitions to
2088
                use for the initial binning. Defaults to 100.
2089
            axes (list[str], optional): Axes to bin.
2090
                Defaults to config["momentum"]["axes"].
2091
            bins (list[int], optional): Bin numbers to use for binning.
2092
                Defaults to config["momentum"]["bins"].
2093
            ranges (Sequence[tuple[float, float]], optional): Ranges to use for binning.
2094
                Defaults to config["momentum"]["ranges"].
2095
            **kwds: Keyword argument passed to ``compute``.
2096

2097
        Returns:
2098
            xr.DataArray: pre-binned data-array.
2099
        """
2100
        if axes is None:
1✔
2101
            axes = self._config["momentum"]["axes"]
1✔
2102
        for loc, axis in enumerate(axes):
1✔
2103
            if axis.startswith("@"):
1✔
2104
                axes[loc] = self._config["dataframe"].get(axis.strip("@"))
1✔
2105

2106
        if bins is None:
1✔
2107
            bins = self._config["momentum"]["bins"]
1✔
2108
        if ranges is None:
1✔
2109
            ranges_ = list(self._config["momentum"]["ranges"])
1✔
2110
            ranges_[2] = np.asarray(ranges_[2]) / self._config["dataframe"]["tof_binning"]
1✔
2111
            ranges = [cast(tuple[float, float], tuple(v)) for v in ranges_]
1✔
2112

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

2115
        return self.compute(
1✔
2116
            bins=bins,
2117
            axes=axes,
2118
            ranges=ranges,
2119
            df_partitions=df_partitions,
2120
            **kwds,
2121
        )
2122

2123
    def compute(
1✔
2124
        self,
2125
        bins: int | dict | tuple | list[int] | list[np.ndarray] | list[tuple] = 100,
2126
        axes: str | Sequence[str] = None,
2127
        ranges: Sequence[tuple[float, float]] = None,
2128
        normalize_to_acquisition_time: bool | str = False,
2129
        **kwds,
2130
    ) -> xr.DataArray:
2131
        """Compute the histogram along the given dimensions.
2132

2133
        Args:
2134
            bins (int | dict | tuple | list[int] | list[np.ndarray] | list[tuple], optional):
2135
                Definition of the bins. Can be any of the following cases:
2136

2137
                - an integer describing the number of bins in on all dimensions
2138
                - a tuple of 3 numbers describing start, end and step of the binning
2139
                  range
2140
                - a np.arrays defining the binning edges
2141
                - a list (NOT a tuple) of any of the above (int, tuple or np.ndarray)
2142
                - a dictionary made of the axes as keys and any of the above as values.
2143

2144
                This takes priority over the axes and range arguments. Defaults to 100.
2145
            axes (str | Sequence[str], optional): The names of the axes (columns)
2146
                on which to calculate the histogram. The order will be the order of the
2147
                dimensions in the resulting array. Defaults to None.
2148
            ranges (Sequence[tuple[float, float]], optional): list of tuples containing
2149
                the start and end point of the binning range. Defaults to None.
2150
            normalize_to_acquisition_time (bool | str): Option to normalize the
2151
                result to the acquisition time. If a "slow" axis was scanned, providing
2152
                the name of the scanned axis will compute and apply the corresponding
2153
                normalization histogram. Defaults to False.
2154
            **kwds: Keyword arguments:
2155

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

2178
                Additional kwds are passed to ``bin_dataframe``.
2179

2180
        Raises:
2181
            AssertError: Rises when no dataframe has been loaded.
2182

2183
        Returns:
2184
            xr.DataArray: The result of the n-dimensional binning represented in an
2185
            xarray object, combining the data with the axes.
2186
        """
2187
        assert self._dataframe is not None, "dataframe needs to be loaded first!"
1✔
2188

2189
        hist_mode = kwds.pop("hist_mode", self._config["binning"]["hist_mode"])
1✔
2190
        mode = kwds.pop("mode", self._config["binning"]["mode"])
1✔
2191
        pbar = kwds.pop("pbar", self._config["binning"]["pbar"])
1✔
2192
        num_cores = kwds.pop("num_cores", self._config["core"]["num_cores"])
1✔
2193
        threads_per_worker = kwds.pop(
1✔
2194
            "threads_per_worker",
2195
            self._config["binning"]["threads_per_worker"],
2196
        )
2197
        threadpool_api = kwds.pop(
1✔
2198
            "threadpool_API",
2199
            self._config["binning"]["threadpool_API"],
2200
        )
2201
        df_partitions: int | Sequence[int] = kwds.pop("df_partitions", None)
1✔
2202
        if isinstance(df_partitions, int):
1✔
2203
            df_partitions = list(range(0, min(df_partitions, self._dataframe.npartitions)))
1✔
2204
        if df_partitions is not None:
1✔
2205
            dataframe = self._dataframe.partitions[df_partitions]
1✔
2206
        else:
2207
            dataframe = self._dataframe
1✔
2208

2209
        filter_params = kwds.pop("filter", None)
1✔
2210
        if filter_params is not None:
1✔
2211
            try:
1✔
2212
                for param in filter_params:
1✔
2213
                    if "col" not in param:
1✔
2214
                        raise ValueError(
1✔
2215
                            "'col' needs to be defined for each filter entry! ",
2216
                            f"Not present in {param}.",
2217
                        )
2218
                    assert set(param.keys()).issubset({"col", "lower_bound", "upper_bound"})
1✔
2219
                    dataframe = apply_filter(dataframe, **param)
1✔
2220
            except AssertionError as exc:
1✔
2221
                invalid_keys = set(param.keys()) - {"lower_bound", "upper_bound"}
1✔
2222
                raise ValueError(
1✔
2223
                    "Only 'col', 'lower_bound' and 'upper_bound' allowed as filter entries. ",
2224
                    f"Parameters {invalid_keys} not valid in {param}.",
2225
                ) from exc
2226

2227
        self._binned = bin_dataframe(
1✔
2228
            df=dataframe,
2229
            bins=bins,
2230
            axes=axes,
2231
            ranges=ranges,
2232
            hist_mode=hist_mode,
2233
            mode=mode,
2234
            pbar=pbar,
2235
            n_cores=num_cores,
2236
            threads_per_worker=threads_per_worker,
2237
            threadpool_api=threadpool_api,
2238
            **kwds,
2239
        )
2240

2241
        for dim in self._binned.dims:
1✔
2242
            try:
1✔
2243
                self._binned[dim].attrs["unit"] = self._config["dataframe"]["units"][dim]
1✔
2244
            except KeyError:
1✔
2245
                pass
1✔
2246

2247
        self._binned.attrs["units"] = "counts"
1✔
2248
        self._binned.attrs["long_name"] = "photoelectron counts"
1✔
2249
        self._binned.attrs["metadata"] = self._attributes.metadata
1✔
2250

2251
        if normalize_to_acquisition_time:
1✔
2252
            if isinstance(normalize_to_acquisition_time, str):
1✔
2253
                axis = normalize_to_acquisition_time
1✔
2254
                print(
1✔
2255
                    f"Calculate normalization histogram for axis '{axis}'...",
2256
                )
2257
                self._normalization_histogram = self.get_normalization_histogram(
1✔
2258
                    axis=axis,
2259
                    df_partitions=df_partitions,
2260
                )
2261
                # if the axes are named correctly, xarray figures out the normalization correctly
2262
                self._normalized = self._binned / self._normalization_histogram
1✔
2263
                self._attributes.add(
1✔
2264
                    self._normalization_histogram.values,
2265
                    name="normalization_histogram",
2266
                    duplicate_policy="overwrite",
2267
                )
2268
            else:
UNCOV
2269
                acquisition_time = self.loader.get_elapsed_time(
×
2270
                    fids=df_partitions,
2271
                )
UNCOV
2272
                if acquisition_time > 0:
×
UNCOV
2273
                    self._normalized = self._binned / acquisition_time
×
UNCOV
2274
                self._attributes.add(
×
2275
                    acquisition_time,
2276
                    name="normalization_histogram",
2277
                    duplicate_policy="overwrite",
2278
                )
2279

2280
            self._normalized.attrs["units"] = "counts/second"
1✔
2281
            self._normalized.attrs["long_name"] = "photoelectron counts per second"
1✔
2282
            self._normalized.attrs["metadata"] = self._attributes.metadata
1✔
2283

2284
            return self._normalized
1✔
2285

2286
        return self._binned
1✔
2287

2288
    def get_normalization_histogram(
1✔
2289
        self,
2290
        axis: str = "delay",
2291
        use_time_stamps: bool = False,
2292
        **kwds,
2293
    ) -> xr.DataArray:
2294
        """Generates a normalization histogram from the timed dataframe. Optionally,
2295
        use the TimeStamps column instead.
2296

2297
        Args:
2298
            axis (str, optional): The axis for which to compute histogram.
2299
                Defaults to "delay".
2300
            use_time_stamps (bool, optional): Use the TimeStamps column of the
2301
                dataframe, rather than the timed dataframe. Defaults to False.
2302
            **kwds: Keyword arguments:
2303

2304
                - **df_partitions**: A sequence of dataframe partitions, or the
2305
                  number of the dataframe partitions to use. Defaults to all partitions.
2306

2307
        Raises:
2308
            ValueError: Raised if no data are binned.
2309
            ValueError: Raised if 'axis' not in binned coordinates.
2310
            ValueError: Raised if config["dataframe"]["time_stamp_alias"] not found
2311
                in Dataframe.
2312

2313
        Returns:
2314
            xr.DataArray: The computed normalization histogram (in TimeStamp units
2315
            per bin).
2316
        """
2317

2318
        if self._binned is None:
1✔
2319
            raise ValueError("Need to bin data first!")
1✔
2320
        if axis not in self._binned.coords:
1✔
2321
            raise ValueError(f"Axis '{axis}' not found in binned data!")
1✔
2322

2323
        df_partitions: int | Sequence[int] = kwds.pop("df_partitions", None)
1✔
2324
        if isinstance(df_partitions, int):
1✔
2325
            df_partitions = list(range(0, min(df_partitions, self._dataframe.npartitions)))
1✔
2326
        if use_time_stamps or self._timed_dataframe is None:
1✔
2327
            if df_partitions is not None:
1✔
2328
                self._normalization_histogram = normalization_histogram_from_timestamps(
1✔
2329
                    self._dataframe.partitions[df_partitions],
2330
                    axis,
2331
                    self._binned.coords[axis].values,
2332
                    self._config["dataframe"]["time_stamp_alias"],
2333
                )
2334
            else:
UNCOV
2335
                self._normalization_histogram = normalization_histogram_from_timestamps(
×
2336
                    self._dataframe,
2337
                    axis,
2338
                    self._binned.coords[axis].values,
2339
                    self._config["dataframe"]["time_stamp_alias"],
2340
                )
2341
        else:
2342
            if df_partitions is not None:
1✔
2343
                self._normalization_histogram = normalization_histogram_from_timed_dataframe(
1✔
2344
                    self._timed_dataframe.partitions[df_partitions],
2345
                    axis,
2346
                    self._binned.coords[axis].values,
2347
                    self._config["dataframe"]["timed_dataframe_unit_time"],
2348
                )
2349
            else:
UNCOV
2350
                self._normalization_histogram = normalization_histogram_from_timed_dataframe(
×
2351
                    self._timed_dataframe,
2352
                    axis,
2353
                    self._binned.coords[axis].values,
2354
                    self._config["dataframe"]["timed_dataframe_unit_time"],
2355
                )
2356

2357
        return self._normalization_histogram
1✔
2358

2359
    def view_event_histogram(
1✔
2360
        self,
2361
        dfpid: int,
2362
        ncol: int = 2,
2363
        bins: Sequence[int] = None,
2364
        axes: Sequence[str] = None,
2365
        ranges: Sequence[tuple[float, float]] = None,
2366
        backend: str = "bokeh",
2367
        legend: bool = True,
2368
        histkwds: dict = None,
2369
        legkwds: dict = None,
2370
        **kwds,
2371
    ):
2372
        """Plot individual histograms of specified dimensions (axes) from a substituent
2373
        dataframe partition.
2374

2375
        Args:
2376
            dfpid (int): Number of the data frame partition to look at.
2377
            ncol (int, optional): Number of columns in the plot grid. Defaults to 2.
2378
            bins (Sequence[int], optional): Number of bins to use for the specified
2379
                axes. Defaults to config["histogram"]["bins"].
2380
            axes (Sequence[str], optional): Names of the axes to display.
2381
                Defaults to config["histogram"]["axes"].
2382
            ranges (Sequence[tuple[float, float]], optional): Value ranges of all
2383
                specified axes. Defaults to config["histogram"]["ranges"].
2384
            backend (str, optional): Backend of the plotting library
2385
                ('matplotlib' or 'bokeh'). Defaults to "bokeh".
2386
            legend (bool, optional): Option to include a legend in the histogram plots.
2387
                Defaults to True.
2388
            histkwds (dict, optional): Keyword arguments for histograms
2389
                (see ``matplotlib.pyplot.hist()``). Defaults to {}.
2390
            legkwds (dict, optional): Keyword arguments for legend
2391
                (see ``matplotlib.pyplot.legend()``). Defaults to {}.
2392
            **kwds: Extra keyword arguments passed to
2393
                ``sed.diagnostics.grid_histogram()``.
2394

2395
        Raises:
2396
            TypeError: Raises when the input values are not of the correct type.
2397
        """
2398
        if bins is None:
1✔
2399
            bins = self._config["histogram"]["bins"]
1✔
2400
        if axes is None:
1✔
2401
            axes = self._config["histogram"]["axes"]
1✔
2402
        axes = list(axes)
1✔
2403
        for loc, axis in enumerate(axes):
1✔
2404
            if axis.startswith("@"):
1✔
2405
                axes[loc] = self._config["dataframe"].get(axis.strip("@"))
1✔
2406
        if ranges is None:
1✔
2407
            ranges = list(self._config["histogram"]["ranges"])
1✔
2408
            for loc, axis in enumerate(axes):
1✔
2409
                if axis == self._config["dataframe"]["tof_column"]:
1✔
2410
                    ranges[loc] = np.asarray(ranges[loc]) / self._config["dataframe"]["tof_binning"]
1✔
2411
                elif axis == self._config["dataframe"]["adc_column"]:
1✔
UNCOV
2412
                    ranges[loc] = np.asarray(ranges[loc]) / self._config["dataframe"]["adc_binning"]
×
2413

2414
        input_types = map(type, [axes, bins, ranges])
1✔
2415
        allowed_types = [list, tuple]
1✔
2416

2417
        df = self._dataframe
1✔
2418

2419
        if not set(input_types).issubset(allowed_types):
1✔
UNCOV
2420
            raise TypeError(
×
2421
                "Inputs of axes, bins, ranges need to be list or tuple!",
2422
            )
2423

2424
        # Read out the values for the specified groups
2425
        group_dict_dd = {}
1✔
2426
        dfpart = df.get_partition(dfpid)
1✔
2427
        cols = dfpart.columns
1✔
2428
        for ax in axes:
1✔
2429
            group_dict_dd[ax] = dfpart.values[:, cols.get_loc(ax)]
1✔
2430
        group_dict = ddf.compute(group_dict_dd)[0]
1✔
2431

2432
        # Plot multiple histograms in a grid
2433
        grid_histogram(
1✔
2434
            group_dict,
2435
            ncol=ncol,
2436
            rvs=axes,
2437
            rvbins=bins,
2438
            rvranges=ranges,
2439
            backend=backend,
2440
            legend=legend,
2441
            histkwds=histkwds,
2442
            legkwds=legkwds,
2443
            **kwds,
2444
        )
2445

2446
    def save(
1✔
2447
        self,
2448
        faddr: str,
2449
        **kwds,
2450
    ):
2451
        """Saves the binned data to the provided path and filename.
2452

2453
        Args:
2454
            faddr (str): Path and name of the file to write. Its extension determines
2455
                the file type to write. Valid file types are:
2456

2457
                - "*.tiff", "*.tif": Saves a TIFF stack.
2458
                - "*.h5", "*.hdf5": Saves an HDF5 file.
2459
                - "*.nxs", "*.nexus": Saves a NeXus file.
2460

2461
            **kwds: Keyword arguments, which are passed to the writer functions:
2462
                For TIFF writing:
2463

2464
                - **alias_dict**: Dictionary of dimension aliases to use.
2465

2466
                For HDF5 writing:
2467

2468
                - **mode**: hdf5 read/write mode. Defaults to "w".
2469

2470
                For NeXus:
2471

2472
                - **reader**: Name of the pynxtools reader to use.
2473
                  Defaults to config["nexus"]["reader"]
2474
                - **definition**: NeXus application definition to use for saving.
2475
                  Must be supported by the used ``reader``. Defaults to
2476
                  config["nexus"]["definition"]
2477
                - **input_files**: A list of input files to pass to the reader.
2478
                  Defaults to config["nexus"]["input_files"]
2479
                - **eln_data**: An electronic-lab-notebook file in '.yaml' format
2480
                  to add to the list of files to pass to the reader.
2481
        """
2482
        if self._binned is None:
1✔
2483
            raise NameError("Need to bin data first!")
1✔
2484

2485
        if self._normalized is not None:
1✔
UNCOV
2486
            data = self._normalized
×
2487
        else:
2488
            data = self._binned
1✔
2489

2490
        extension = pathlib.Path(faddr).suffix
1✔
2491

2492
        if extension in (".tif", ".tiff"):
1✔
2493
            to_tiff(
1✔
2494
                data=data,
2495
                faddr=faddr,
2496
                **kwds,
2497
            )
2498
        elif extension in (".h5", ".hdf5"):
1✔
2499
            to_h5(
1✔
2500
                data=data,
2501
                faddr=faddr,
2502
                **kwds,
2503
            )
2504
        elif extension in (".nxs", ".nexus"):
1✔
2505
            try:
1✔
2506
                reader = kwds.pop("reader", self._config["nexus"]["reader"])
1✔
2507
                definition = kwds.pop(
1✔
2508
                    "definition",
2509
                    self._config["nexus"]["definition"],
2510
                )
2511
                input_files = kwds.pop(
1✔
2512
                    "input_files",
2513
                    self._config["nexus"]["input_files"],
2514
                )
UNCOV
2515
            except KeyError as exc:
×
UNCOV
2516
                raise ValueError(
×
2517
                    "The nexus reader, definition and input files need to be provide!",
2518
                ) from exc
2519

2520
            if isinstance(input_files, str):
1✔
2521
                input_files = [input_files]
1✔
2522

2523
            if "eln_data" in kwds:
1✔
UNCOV
2524
                input_files.append(kwds.pop("eln_data"))
×
2525

2526
            to_nexus(
1✔
2527
                data=data,
2528
                faddr=faddr,
2529
                reader=reader,
2530
                definition=definition,
2531
                input_files=input_files,
2532
                **kwds,
2533
            )
2534

2535
        else:
2536
            raise NotImplementedError(
1✔
2537
                f"Unrecognized file format: {extension}.",
2538
            )
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

© 2026 Coveralls, Inc