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

OpenCOMPES / sed / 6764066363

05 Nov 2023 10:13PM UTC coverage: 90.741% (-0.02%) from 90.759%
6764066363

Pull #227

github

rettigl
add time-stamped data to timed dataframe
Pull Request #227: Time stamped data

79 of 90 new or added lines in 5 files covered. (87.78%)

54 existing lines in 1 file now uncovered.

4969 of 5476 relevant lines covered (90.74%)

0.91 hits per line

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

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

3
"""
4
import pathlib
1✔
5
from typing import Any
1✔
6
from typing import cast
1✔
7
from typing import Dict
1✔
8
from typing import List
1✔
9
from typing import Sequence
1✔
10
from typing import Tuple
1✔
11
from typing import Union
1✔
12

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

20
from sed.binning import bin_dataframe
1✔
21
from sed.binning.binning import normalization_histogram_from_timed_dataframe
1✔
22
from sed.binning.binning import normalization_histogram_from_timestamps
1✔
23
from sed.calibrator import DelayCalibrator
1✔
24
from sed.calibrator import EnergyCalibrator
1✔
25
from sed.calibrator import MomentumCorrector
1✔
26
from sed.core.config import parse_config
1✔
27
from sed.core.config import save_config
1✔
28
from sed.core.dfops import add_time_stamped_data
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 (Union[dict, str], optional): Config dictionary or config file name.
50
            Defaults to None.
51
        dataframe (Union[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
        collect_metadata (bool): Option to collect metadata from files.
58
            Defaults to False.
59
        **kwds: Keyword arguments passed to the reader.
60
    """
61

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

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

103
        self._dataframe: Union[pd.DataFrame, ddf.DataFrame] = None
1✔
104
        self._timed_dataframe: Union[pd.DataFrame, ddf.DataFrame] = None
1✔
105
        self._files: List[str] = []
1✔
106

107
        self._binned: xr.DataArray = None
1✔
108
        self._pre_binned: xr.DataArray = None
1✔
109
        self._normalization_histogram: xr.DataArray = None
1✔
110
        self._normalized: xr.DataArray = None
1✔
111

112
        self._attributes = MetaHandler(meta=metadata)
1✔
113

114
        loader_name = self._config["core"]["loader"]
1✔
115
        self.loader = get_loader(
1✔
116
            loader_name=loader_name,
117
            config=self._config,
118
        )
119

120
        self.ec = EnergyCalibrator(
1✔
121
            loader=self.loader,
122
            config=self._config,
123
        )
124

125
        self.mc = MomentumCorrector(
1✔
126
            config=self._config,
127
        )
128

129
        self.dc = DelayCalibrator(
1✔
130
            config=self._config,
131
        )
132

133
        self.use_copy_tool = self._config.get("core", {}).get(
1✔
134
            "use_copy_tool",
135
            False,
136
        )
137
        if self.use_copy_tool:
1✔
138
            try:
1✔
139
                self.ct = CopyTool(
1✔
140
                    source=self._config["core"]["copy_tool_source"],
141
                    dest=self._config["core"]["copy_tool_dest"],
142
                    **self._config["core"].get("copy_tool_kwds", {}),
143
                )
144
            except KeyError:
1✔
145
                self.use_copy_tool = False
1✔
146

147
        # Load data if provided:
148
        if dataframe is not None or files is not None or folder is not None or runs is not None:
1✔
149
            self.load(
1✔
150
                dataframe=dataframe,
151
                metadata=metadata,
152
                files=files,
153
                folder=folder,
154
                runs=runs,
155
                collect_metadata=collect_metadata,
156
                **kwds,
157
            )
158

159
    def __repr__(self):
1✔
160
        if self._dataframe is None:
1✔
161
            df_str = "Data Frame: No Data loaded"
1✔
162
        else:
163
            df_str = self._dataframe.__repr__()
1✔
164
        attributes_str = f"Metadata: {self._attributes.metadata}"
1✔
165
        pretty_str = df_str + "\n" + attributes_str
1✔
166
        return pretty_str
1✔
167

168
    @property
1✔
169
    def dataframe(self) -> Union[pd.DataFrame, ddf.DataFrame]:
1✔
170
        """Accessor to the underlying dataframe.
171

172
        Returns:
173
            Union[pd.DataFrame, ddf.DataFrame]: Dataframe object.
174
        """
175
        return self._dataframe
1✔
176

177
    @dataframe.setter
1✔
178
    def dataframe(self, dataframe: Union[pd.DataFrame, ddf.DataFrame]):
1✔
179
        """Setter for the underlying dataframe.
180

181
        Args:
182
            dataframe (Union[pd.DataFrame, ddf.DataFrame]): The dataframe object to set.
183
        """
184
        if not isinstance(dataframe, (pd.DataFrame, ddf.DataFrame)) or not isinstance(
1✔
185
            dataframe,
186
            self._dataframe.__class__,
187
        ):
188
            raise ValueError(
1✔
189
                "'dataframe' has to be a Pandas or Dask dataframe and has to be of the same kind "
190
                "as the dataframe loaded into the SedProcessor!.\n"
191
                f"Loaded type: {self._dataframe.__class__}, provided type: {dataframe}.",
192
            )
193
        self._dataframe = dataframe
1✔
194

195
    @property
1✔
196
    def timed_dataframe(self) -> Union[pd.DataFrame, ddf.DataFrame]:
1✔
197
        """Accessor to the underlying timed_dataframe.
198

199
        Returns:
200
            Union[pd.DataFrame, ddf.DataFrame]: Timed Dataframe object.
201
        """
202
        return self._timed_dataframe
1✔
203

204
    @timed_dataframe.setter
1✔
205
    def timed_dataframe(self, timed_dataframe: Union[pd.DataFrame, ddf.DataFrame]):
1✔
206
        """Setter for the underlying timed dataframe.
207

208
        Args:
209
            timed_dataframe (Union[pd.DataFrame, ddf.DataFrame]): The timed dataframe object to set
210
        """
UNCOV
211
        if not isinstance(timed_dataframe, (pd.DataFrame, ddf.DataFrame)) or not isinstance(
×
212
            timed_dataframe,
213
            self._timed_dataframe.__class__,
214
        ):
UNCOV
215
            raise ValueError(
×
216
                "'timed_dataframe' has to be a Pandas or Dask dataframe and has to be of the same "
217
                "kind as the dataframe loaded into the SedProcessor!.\n"
218
                f"Loaded type: {self._timed_dataframe.__class__}, "
219
                f"provided type: {timed_dataframe}.",
220
            )
UNCOV
221
        self._timed_dataframe = timed_dataframe
×
222

223
    @property
1✔
224
    def attributes(self) -> dict:
1✔
225
        """Accessor to the metadata dict.
226

227
        Returns:
228
            dict: The metadata dict.
229
        """
230
        return self._attributes.metadata
1✔
231

232
    def add_attribute(self, attributes: dict, name: str, **kwds):
1✔
233
        """Function to add element to the attributes dict.
234

235
        Args:
236
            attributes (dict): The attributes dictionary object to add.
237
            name (str): Key under which to add the dictionary to the attributes.
238
        """
239
        self._attributes.add(
1✔
240
            entry=attributes,
241
            name=name,
242
            **kwds,
243
        )
244

245
    @property
1✔
246
    def config(self) -> Dict[Any, Any]:
1✔
247
        """Getter attribute for the config dictionary
248

249
        Returns:
250
            Dict: The config dictionary.
251
        """
252
        return self._config
1✔
253

254
    @property
1✔
255
    def files(self) -> List[str]:
1✔
256
        """Getter attribute for the list of files
257

258
        Returns:
259
            List[str]: The list of loaded files
260
        """
261
        return self._files
1✔
262

263
    @property
1✔
264
    def binned(self) -> xr.DataArray:
1✔
265
        """Getter attribute for the binned data array
266

267
        Returns:
268
            xr.DataArray: The binned data array
269
        """
270
        if self._binned is None:
1✔
UNCOV
271
            raise ValueError("No binned data available, need to compute histogram first!")
×
272
        return self._binned
1✔
273

274
    @property
1✔
275
    def normalized(self) -> xr.DataArray:
1✔
276
        """Getter attribute for the normalized data array
277

278
        Returns:
279
            xr.DataArray: The normalized data array
280
        """
281
        if self._normalized is None:
1✔
UNCOV
282
            raise ValueError(
×
283
                "No normalized data available, compute data with normalization enabled!",
284
            )
285
        return self._normalized
1✔
286

287
    @property
1✔
288
    def normalization_histogram(self) -> xr.DataArray:
1✔
289
        """Getter attribute for the normalization histogram
290

291
        Returns:
292
            xr.DataArray: The normalizazion histogram
293
        """
294
        if self._normalization_histogram is None:
1✔
UNCOV
295
            raise ValueError("No normalization histogram available, generate histogram first!")
×
296
        return self._normalization_histogram
1✔
297

298
    def cpy(self, path: Union[str, List[str]]) -> Union[str, List[str]]:
1✔
299
        """Function to mirror a list of files or a folder from a network drive to a
300
        local storage. Returns either the original or the copied path to the given
301
        path. The option to use this functionality is set by
302
        config["core"]["use_copy_tool"].
303

304
        Args:
305
            path (Union[str, List[str]]): Source path or path list.
306

307
        Returns:
308
            Union[str, List[str]]: Source or destination path or path list.
309
        """
310
        if self.use_copy_tool:
1✔
311
            if isinstance(path, list):
1✔
312
                path_out = []
1✔
313
                for file in path:
1✔
314
                    path_out.append(self.ct.copy(file))
1✔
315
                return path_out
1✔
316

UNCOV
317
            return self.ct.copy(path)
×
318

319
        if isinstance(path, list):
1✔
320
            return path
1✔
321

322
        return path
1✔
323

324
    def load(
1✔
325
        self,
326
        dataframe: Union[pd.DataFrame, ddf.DataFrame] = None,
327
        metadata: dict = None,
328
        files: List[str] = None,
329
        folder: str = None,
330
        runs: Sequence[str] = None,
331
        collect_metadata: bool = False,
332
        **kwds,
333
    ):
334
        """Load tabular data of single events into the dataframe object in the class.
335

336
        Args:
337
            dataframe (Union[pd.DataFrame, ddf.DataFrame], optional): data in tabular
338
                format. Accepts anything which can be interpreted by pd.DataFrame as
339
                an input. Defaults to None.
340
            metadata (dict, optional): Dict of external Metadata. Defaults to None.
341
            files (List[str], optional): List of file paths to pass to the loader.
342
                Defaults to None.
343
            runs (Sequence[str], optional): List of run identifiers to pass to the
344
                loader. Defaults to None.
345
            folder (str, optional): Folder path to pass to the loader.
346
                Defaults to None.
347

348
        Raises:
349
            ValueError: Raised if no valid input is provided.
350
        """
351
        if metadata is None:
1✔
352
            metadata = {}
1✔
353
        if dataframe is not None:
1✔
354
            timed_dataframe = kwds.pop("timed_dataframe", None)
1✔
355
        elif runs is not None:
1✔
356
            # If runs are provided, we only use the copy tool if also folder is provided.
357
            # In that case, we copy the whole provided base folder tree, and pass the copied
358
            # version to the loader as base folder to look for the runs.
359
            if folder is not None:
1✔
360
                dataframe, timed_dataframe, metadata = self.loader.read_dataframe(
1✔
361
                    folders=cast(str, self.cpy(folder)),
362
                    runs=runs,
363
                    metadata=metadata,
364
                    collect_metadata=collect_metadata,
365
                    **kwds,
366
                )
367
            else:
UNCOV
368
                dataframe, timed_dataframe, metadata = self.loader.read_dataframe(
×
369
                    runs=runs,
370
                    metadata=metadata,
371
                    collect_metadata=collect_metadata,
372
                    **kwds,
373
                )
374

375
        elif folder is not None:
1✔
376
            dataframe, timed_dataframe, metadata = self.loader.read_dataframe(
1✔
377
                folders=cast(str, self.cpy(folder)),
378
                metadata=metadata,
379
                collect_metadata=collect_metadata,
380
                **kwds,
381
            )
382
        elif files is not None:
1✔
383
            dataframe, timed_dataframe, metadata = self.loader.read_dataframe(
1✔
384
                files=cast(List[str], self.cpy(files)),
385
                metadata=metadata,
386
                collect_metadata=collect_metadata,
387
                **kwds,
388
            )
389
        else:
390
            raise ValueError(
1✔
391
                "Either 'dataframe', 'files', 'folder', or 'runs' needs to be provided!",
392
            )
393

394
        self._dataframe = dataframe
1✔
395
        self._timed_dataframe = timed_dataframe
1✔
396
        self._files = self.loader.files
1✔
397

398
        for key in metadata:
1✔
399
            self._attributes.add(
1✔
400
                entry=metadata[key],
401
                name=key,
402
                duplicate_policy="merge",
403
            )
404

405
    # Momentum calibration workflow
406
    # 1. Bin raw detector data for distortion correction
407
    def bin_and_load_momentum_calibration(
1✔
408
        self,
409
        df_partitions: int = 100,
410
        axes: List[str] = None,
411
        bins: List[int] = None,
412
        ranges: Sequence[Tuple[float, float]] = None,
413
        plane: int = 0,
414
        width: int = 5,
415
        apply: bool = False,
416
        **kwds,
417
    ):
418
        """1st step of momentum correction work flow. Function to do an initial binning
419
        of the dataframe loaded to the class, slice a plane from it using an
420
        interactive view, and load it into the momentum corrector class.
421

422
        Args:
423
            df_partitions (int, optional): Number of dataframe partitions to use for
424
                the initial binning. Defaults to 100.
425
            axes (List[str], optional): Axes to bin.
426
                Defaults to config["momentum"]["axes"].
427
            bins (List[int], optional): Bin numbers to use for binning.
428
                Defaults to config["momentum"]["bins"].
429
            ranges (List[Tuple], optional): Ranges to use for binning.
430
                Defaults to config["momentum"]["ranges"].
431
            plane (int, optional): Initial value for the plane slider. Defaults to 0.
432
            width (int, optional): Initial value for the width slider. Defaults to 5.
433
            apply (bool, optional): Option to directly apply the values and select the
434
                slice. Defaults to False.
435
            **kwds: Keyword argument passed to the pre_binning function.
436
        """
437
        self._pre_binned = self.pre_binning(
1✔
438
            df_partitions=df_partitions,
439
            axes=axes,
440
            bins=bins,
441
            ranges=ranges,
442
            **kwds,
443
        )
444

445
        self.mc.load_data(data=self._pre_binned)
1✔
446
        self.mc.select_slicer(plane=plane, width=width, apply=apply)
1✔
447

448
    # 2. Generate the spline warp correction from momentum features.
449
    # Either autoselect features, or input features from view above.
450
    def define_features(
1✔
451
        self,
452
        features: np.ndarray = None,
453
        rotation_symmetry: int = 6,
454
        auto_detect: bool = False,
455
        include_center: bool = True,
456
        apply: bool = False,
457
        **kwds,
458
    ):
459
        """2. Step of the distortion correction workflow: Define feature points in
460
        momentum space. They can be either manually selected using a GUI tool, be
461
        ptovided as list of feature points, or auto-generated using a
462
        feature-detection algorithm.
463

464
        Args:
465
            features (np.ndarray, optional): np.ndarray of features. Defaults to None.
466
            rotation_symmetry (int, optional): Number of rotational symmetry axes.
467
                Defaults to 6.
468
            auto_detect (bool, optional): Whether to auto-detect the features.
469
                Defaults to False.
470
            include_center (bool, optional): Option to include a point at the center
471
                in the feature list. Defaults to True.
472
            ***kwds: Keyword arguments for MomentumCorrector.feature_extract() and
473
                MomentumCorrector.feature_select()
474
        """
475
        if auto_detect:  # automatic feature selection
1✔
UNCOV
476
            sigma = kwds.pop("sigma", self._config["momentum"]["sigma"])
×
UNCOV
477
            fwhm = kwds.pop("fwhm", self._config["momentum"]["fwhm"])
×
UNCOV
478
            sigma_radius = kwds.pop(
×
479
                "sigma_radius",
480
                self._config["momentum"]["sigma_radius"],
481
            )
UNCOV
482
            self.mc.feature_extract(
×
483
                sigma=sigma,
484
                fwhm=fwhm,
485
                sigma_radius=sigma_radius,
486
                rotsym=rotation_symmetry,
487
                **kwds,
488
            )
UNCOV
489
            features = self.mc.peaks
×
490

491
        self.mc.feature_select(
1✔
492
            rotsym=rotation_symmetry,
493
            include_center=include_center,
494
            features=features,
495
            apply=apply,
496
            **kwds,
497
        )
498

499
    # 3. Generate the spline warp correction from momentum features.
500
    # If no features have been selected before, use class defaults.
501
    def generate_splinewarp(
1✔
502
        self,
503
        use_center: bool = None,
504
        **kwds,
505
    ):
506
        """3. Step of the distortion correction workflow: Generate the correction
507
        function restoring the symmetry in the image using a splinewarp algortihm.
508

509
        Args:
510
            use_center (bool, optional): Option to use the position of the
511
                center point in the correction. Default is read from config, or set to True.
512
            **kwds: Keyword arguments for MomentumCorrector.spline_warp_estimate().
513
        """
514
        self.mc.spline_warp_estimate(use_center=use_center, **kwds)
1✔
515

516
        if self.mc.slice is not None:
1✔
517
            print("Original slice with reference features")
1✔
518
            self.mc.view(annotated=True, backend="bokeh", crosshair=True)
1✔
519

520
            print("Corrected slice with target features")
1✔
521
            self.mc.view(
1✔
522
                image=self.mc.slice_corrected,
523
                annotated=True,
524
                points={"feats": self.mc.ptargs},
525
                backend="bokeh",
526
                crosshair=True,
527
            )
528

529
            print("Original slice with target features")
1✔
530
            self.mc.view(
1✔
531
                image=self.mc.slice,
532
                points={"feats": self.mc.ptargs},
533
                annotated=True,
534
                backend="bokeh",
535
            )
536

537
    # 3a. Save spline-warp parameters to config file.
538
    def save_splinewarp(
1✔
539
        self,
540
        filename: str = None,
541
        overwrite: bool = False,
542
    ):
543
        """Save the generated spline-warp parameters to the folder config file.
544

545
        Args:
546
            filename (str, optional): Filename of the config dictionary to save to.
547
                Defaults to "sed_config.yaml" in the current folder.
548
            overwrite (bool, optional): Option to overwrite the present dictionary.
549
                Defaults to False.
550
        """
551
        if filename is None:
1✔
UNCOV
552
            filename = "sed_config.yaml"
×
553
        points = []
1✔
554
        try:
1✔
555
            for point in self.mc.pouter_ord:
1✔
556
                points.append([float(i) for i in point])
1✔
557
            if self.mc.include_center:
1✔
558
                points.append([float(i) for i in self.mc.pcent])
1✔
UNCOV
559
        except AttributeError as exc:
×
UNCOV
560
            raise AttributeError(
×
561
                "Momentum correction parameters not found, need to generate parameters first!",
562
            ) from exc
563
        config = {
1✔
564
            "momentum": {
565
                "correction": {
566
                    "rotation_symmetry": self.mc.rotsym,
567
                    "feature_points": points,
568
                    "include_center": self.mc.include_center,
569
                    "use_center": self.mc.use_center,
570
                },
571
            },
572
        }
573
        save_config(config, filename, overwrite)
1✔
574

575
    # 4. Pose corrections. Provide interactive interface for correcting
576
    # scaling, shift and rotation
577
    def pose_adjustment(
1✔
578
        self,
579
        scale: float = 1,
580
        xtrans: float = 0,
581
        ytrans: float = 0,
582
        angle: float = 0,
583
        apply: bool = False,
584
        use_correction: bool = True,
585
        reset: bool = True,
586
    ):
587
        """3. step of the distortion correction workflow: Generate an interactive panel
588
        to adjust affine transformations that are applied to the image. Applies first
589
        a scaling, next an x/y translation, and last a rotation around the center of
590
        the image.
591

592
        Args:
593
            scale (float, optional): Initial value of the scaling slider.
594
                Defaults to 1.
595
            xtrans (float, optional): Initial value of the xtrans slider.
596
                Defaults to 0.
597
            ytrans (float, optional): Initial value of the ytrans slider.
598
                Defaults to 0.
599
            angle (float, optional): Initial value of the angle slider.
600
                Defaults to 0.
601
            apply (bool, optional): Option to directly apply the provided
602
                transformations. Defaults to False.
603
            use_correction (bool, option): Whether to use the spline warp correction
604
                or not. Defaults to True.
605
            reset (bool, optional):
606
                Option to reset the correction before transformation. Defaults to True.
607
        """
608
        # Generate homomorphy as default if no distortion correction has been applied
609
        if self.mc.slice_corrected is None:
1✔
610
            if self.mc.slice is None:
1✔
611
                raise ValueError(
1✔
612
                    "No slice for corrections and transformations loaded!",
613
                )
UNCOV
614
            self.mc.slice_corrected = self.mc.slice
×
615

616
        if not use_correction:
1✔
617
            self.mc.reset_deformation()
1✔
618

619
        if self.mc.cdeform_field is None or self.mc.rdeform_field is None:
1✔
620
            # Generate distortion correction from config values
UNCOV
621
            self.mc.add_features()
×
UNCOV
622
            self.mc.spline_warp_estimate()
×
623

624
        self.mc.pose_adjustment(
1✔
625
            scale=scale,
626
            xtrans=xtrans,
627
            ytrans=ytrans,
628
            angle=angle,
629
            apply=apply,
630
            reset=reset,
631
        )
632

633
    # 5. Apply the momentum correction to the dataframe
634
    def apply_momentum_correction(
1✔
635
        self,
636
        preview: bool = False,
637
    ):
638
        """Applies the distortion correction and pose adjustment (optional)
639
        to the dataframe.
640

641
        Args:
642
            rdeform_field (np.ndarray, optional): Row deformation field.
643
                Defaults to None.
644
            cdeform_field (np.ndarray, optional): Column deformation field.
645
                Defaults to None.
646
            inv_dfield (np.ndarray, optional): Inverse deformation field.
647
                Defaults to None.
648
            preview (bool): Option to preview the first elements of the data frame.
649
        """
650
        if self._dataframe is not None:
1✔
651
            print("Adding corrected X/Y columns to dataframe:")
1✔
652
            self._dataframe, metadata = self.mc.apply_corrections(
1✔
653
                df=self._dataframe,
654
            )
655
            if self._timed_dataframe is not None:
1✔
656
                if (
1✔
657
                    self._config["dataframe"]["x_column"] in self._timed_dataframe.columns
658
                    and self._config["dataframe"]["y_column"] in self._timed_dataframe.columns
659
                ):
660
                    self._timed_dataframe, _ = self.mc.apply_corrections(
1✔
661
                        self._timed_dataframe,
662
                    )
663
            # Add Metadata
664
            self._attributes.add(
1✔
665
                metadata,
666
                "momentum_correction",
667
                duplicate_policy="merge",
668
            )
669
            if preview:
1✔
UNCOV
670
                print(self._dataframe.head(10))
×
671
            else:
672
                print(self._dataframe)
1✔
673

674
    # Momentum calibration work flow
675
    # 1. Calculate momentum calibration
676
    def calibrate_momentum_axes(
1✔
677
        self,
678
        point_a: Union[np.ndarray, List[int]] = None,
679
        point_b: Union[np.ndarray, List[int]] = None,
680
        k_distance: float = None,
681
        k_coord_a: Union[np.ndarray, List[float]] = None,
682
        k_coord_b: Union[np.ndarray, List[float]] = np.array([0.0, 0.0]),
683
        equiscale: bool = True,
684
        apply=False,
685
    ):
686
        """1. step of the momentum calibration workflow. Calibrate momentum
687
        axes using either provided pixel coordinates of a high-symmetry point and its
688
        distance to the BZ center, or the k-coordinates of two points in the BZ
689
        (depending on the equiscale option). Opens an interactive panel for selecting
690
        the points.
691

692
        Args:
693
            point_a (Union[np.ndarray, List[int]]): Pixel coordinates of the first
694
                point used for momentum calibration.
695
            point_b (Union[np.ndarray, List[int]], optional): Pixel coordinates of the
696
                second point used for momentum calibration.
697
                Defaults to config["momentum"]["center_pixel"].
698
            k_distance (float, optional): Momentum distance between point a and b.
699
                Needs to be provided if no specific k-koordinates for the two points
700
                are given. Defaults to None.
701
            k_coord_a (Union[np.ndarray, List[float]], optional): Momentum coordinate
702
                of the first point used for calibration. Used if equiscale is False.
703
                Defaults to None.
704
            k_coord_b (Union[np.ndarray, List[float]], optional): Momentum coordinate
705
                of the second point used for calibration. Defaults to [0.0, 0.0].
706
            equiscale (bool, optional): Option to apply different scales to kx and ky.
707
                If True, the distance between points a and b, and the absolute
708
                position of point a are used for defining the scale. If False, the
709
                scale is calculated from the k-positions of both points a and b.
710
                Defaults to True.
711
            apply (bool, optional): Option to directly store the momentum calibration
712
                in the class. Defaults to False.
713
        """
714
        if point_b is None:
1✔
715
            point_b = self._config["momentum"]["center_pixel"]
1✔
716

717
        self.mc.select_k_range(
1✔
718
            point_a=point_a,
719
            point_b=point_b,
720
            k_distance=k_distance,
721
            k_coord_a=k_coord_a,
722
            k_coord_b=k_coord_b,
723
            equiscale=equiscale,
724
            apply=apply,
725
        )
726

727
    # 1a. Save momentum calibration parameters to config file.
728
    def save_momentum_calibration(
1✔
729
        self,
730
        filename: str = None,
731
        overwrite: bool = False,
732
    ):
733
        """Save the generated momentum calibration parameters to the folder config file.
734

735
        Args:
736
            filename (str, optional): Filename of the config dictionary to save to.
737
                Defaults to "sed_config.yaml" in the current folder.
738
            overwrite (bool, optional): Option to overwrite the present dictionary.
739
                Defaults to False.
740
        """
741
        if filename is None:
1✔
UNCOV
742
            filename = "sed_config.yaml"
×
743
        calibration = {}
1✔
744
        try:
1✔
745
            for key in [
1✔
746
                "kx_scale",
747
                "ky_scale",
748
                "x_center",
749
                "y_center",
750
                "rstart",
751
                "cstart",
752
                "rstep",
753
                "cstep",
754
            ]:
755
                calibration[key] = float(self.mc.calibration[key])
1✔
UNCOV
756
        except KeyError as exc:
×
UNCOV
757
            raise KeyError(
×
758
                "Momentum calibration parameters not found, need to generate parameters first!",
759
            ) from exc
760

761
        config = {"momentum": {"calibration": calibration}}
1✔
762
        save_config(config, filename, overwrite)
1✔
763

764
    # 2. Apply correction and calibration to the dataframe
765
    def apply_momentum_calibration(
1✔
766
        self,
767
        calibration: dict = None,
768
        preview: bool = False,
769
    ):
770
        """2. step of the momentum calibration work flow: Apply the momentum
771
        calibration stored in the class to the dataframe. If corrected X/Y axis exist,
772
        these are used.
773

774
        Args:
775
            calibration (dict, optional): Optional dictionary with calibration data to
776
                use. Defaults to None.
777
            preview (bool): Option to preview the first elements of the data frame.
778
        """
779
        if self._dataframe is not None:
1✔
780

781
            print("Adding kx/ky columns to dataframe:")
1✔
782
            self._dataframe, metadata = self.mc.append_k_axis(
1✔
783
                df=self._dataframe,
784
                calibration=calibration,
785
            )
786
            if self._timed_dataframe is not None:
1✔
787
                if (
1✔
788
                    self._config["dataframe"]["x_column"] in self._timed_dataframe.columns
789
                    and self._config["dataframe"]["y_column"] in self._timed_dataframe.columns
790
                ):
791
                    self._timed_dataframe, _ = self.mc.append_k_axis(
1✔
792
                        df=self._timed_dataframe,
793
                        calibration=calibration,
794
                    )
795

796
            # Add Metadata
797
            self._attributes.add(
1✔
798
                metadata,
799
                "momentum_calibration",
800
                duplicate_policy="merge",
801
            )
802
            if preview:
1✔
UNCOV
803
                print(self._dataframe.head(10))
×
804
            else:
805
                print(self._dataframe)
1✔
806

807
    # Energy correction workflow
808
    # 1. Adjust the energy correction parameters
809
    def adjust_energy_correction(
1✔
810
        self,
811
        correction_type: str = None,
812
        amplitude: float = None,
813
        center: Tuple[float, float] = None,
814
        apply=False,
815
        **kwds,
816
    ):
817
        """1. step of the energy crrection workflow: Opens an interactive plot to
818
        adjust the parameters for the TOF/energy correction. Also pre-bins the data if
819
        they are not present yet.
820

821
        Args:
822
            correction_type (str, optional): Type of correction to apply to the TOF
823
                axis. Valid values are:
824

825
                - 'spherical'
826
                - 'Lorentzian'
827
                - 'Gaussian'
828
                - 'Lorentzian_asymmetric'
829

830
                Defaults to config["energy"]["correction_type"].
831
            amplitude (float, optional): Amplitude of the correction.
832
                Defaults to config["energy"]["correction"]["amplitude"].
833
            center (Tuple[float, float], optional): Center X/Y coordinates for the
834
                correction. Defaults to config["energy"]["correction"]["center"].
835
            apply (bool, optional): Option to directly apply the provided or default
836
                correction parameters. Defaults to False.
837
        """
838
        if self._pre_binned is None:
1✔
839
            print(
1✔
840
                "Pre-binned data not present, binning using defaults from config...",
841
            )
842
            self._pre_binned = self.pre_binning()
1✔
843

844
        self.ec.adjust_energy_correction(
1✔
845
            self._pre_binned,
846
            correction_type=correction_type,
847
            amplitude=amplitude,
848
            center=center,
849
            apply=apply,
850
            **kwds,
851
        )
852

853
    # 1a. Save energy correction parameters to config file.
854
    def save_energy_correction(
1✔
855
        self,
856
        filename: str = None,
857
        overwrite: bool = False,
858
    ):
859
        """Save the generated energy correction parameters to the folder config file.
860

861
        Args:
862
            filename (str, optional): Filename of the config dictionary to save to.
863
                Defaults to "sed_config.yaml" in the current folder.
864
            overwrite (bool, optional): Option to overwrite the present dictionary.
865
                Defaults to False.
866
        """
867
        if filename is None:
1✔
868
            filename = "sed_config.yaml"
1✔
869
        correction = {}
1✔
870
        try:
1✔
871
            for key, val in self.ec.correction.items():
1✔
872
                if key == "correction_type":
1✔
873
                    correction[key] = val
1✔
874
                elif key == "center":
1✔
875
                    correction[key] = [float(i) for i in val]
1✔
876
                else:
877
                    correction[key] = float(val)
1✔
UNCOV
878
        except AttributeError as exc:
×
UNCOV
879
            raise AttributeError(
×
880
                "Energy correction parameters not found, need to generate parameters first!",
881
            ) from exc
882

883
        config = {"energy": {"correction": correction}}
1✔
884
        save_config(config, filename, overwrite)
1✔
885

886
    # 2. Apply energy correction to dataframe
887
    def apply_energy_correction(
1✔
888
        self,
889
        correction: dict = None,
890
        preview: bool = False,
891
        **kwds,
892
    ):
893
        """2. step of the energy correction workflow: Apply the enery correction
894
        parameters stored in the class to the dataframe.
895

896
        Args:
897
            correction (dict, optional): Dictionary containing the correction
898
                parameters. Defaults to config["energy"]["calibration"].
899
            preview (bool): Option to preview the first elements of the data frame.
900
            **kwds:
901
                Keyword args passed to ``EnergyCalibrator.apply_energy_correction``.
902
            preview (bool): Option to preview the first elements of the data frame.
903
            **kwds:
904
                Keyword args passed to ``EnergyCalibrator.apply_energy_correction``.
905
        """
906
        if self._dataframe is not None:
1✔
907
            print("Applying energy correction to dataframe...")
1✔
908
            self._dataframe, metadata = self.ec.apply_energy_correction(
1✔
909
                df=self._dataframe,
910
                correction=correction,
911
                **kwds,
912
            )
913
            if self._timed_dataframe is not None:
1✔
914
                if self._config["dataframe"]["tof_column"] in self._timed_dataframe.columns:
1✔
915
                    self._timed_dataframe, _ = self.ec.apply_energy_correction(
1✔
916
                        df=self._timed_dataframe,
917
                        correction=correction,
918
                        **kwds,
919
                    )
920

921
            # Add Metadata
922
            self._attributes.add(
1✔
923
                metadata,
924
                "energy_correction",
925
            )
926
            if preview:
1✔
UNCOV
927
                print(self._dataframe.head(10))
×
928
            else:
929
                print(self._dataframe)
1✔
930

931
    # Energy calibrator workflow
932
    # 1. Load and normalize data
933
    def load_bias_series(
1✔
934
        self,
935
        binned_data: Union[xr.DataArray, Tuple[np.ndarray, np.ndarray, np.ndarray]] = None,
936
        data_files: List[str] = None,
937
        axes: List[str] = None,
938
        bins: List = None,
939
        ranges: Sequence[Tuple[float, float]] = None,
940
        biases: np.ndarray = None,
941
        bias_key: str = None,
942
        normalize: bool = None,
943
        span: int = None,
944
        order: int = None,
945
    ):
946
        """1. step of the energy calibration workflow: Load and bin data from
947
        single-event files, or load binned bias/TOF traces.
948

949
        Args:
950
            binned_data (Union[xr.DataArray, Tuple[np.ndarray, np.ndarray, np.ndarray]], optional):
951
                Binned data If provided as DataArray, Needs to contain dimensions
952
                config["dataframe"]["tof_column"] and config["dataframe"]["bias_column"]. If
953
                provided as tuple, needs to contain elements tof, biases, traces.
954
            data_files (List[str], optional): list of file paths to bin
955
            axes (List[str], optional): bin axes.
956
                Defaults to config["dataframe"]["tof_column"].
957
            bins (List, optional): number of bins.
958
                Defaults to config["energy"]["bins"].
959
            ranges (Sequence[Tuple[float, float]], optional): bin ranges.
960
                Defaults to config["energy"]["ranges"].
961
            biases (np.ndarray, optional): Bias voltages used. If missing, bias
962
                voltages are extracted from the data files.
963
            bias_key (str, optional): hdf5 path where bias values are stored.
964
                Defaults to config["energy"]["bias_key"].
965
            normalize (bool, optional): Option to normalize traces.
966
                Defaults to config["energy"]["normalize"].
967
            span (int, optional): span smoothing parameters of the LOESS method
968
                (see ``scipy.signal.savgol_filter()``).
969
                Defaults to config["energy"]["normalize_span"].
970
            order (int, optional): order smoothing parameters of the LOESS method
971
                (see ``scipy.signal.savgol_filter()``).
972
                Defaults to config["energy"]["normalize_order"].
973
        """
974
        if binned_data is not None:
1✔
975
            if isinstance(binned_data, xr.DataArray):
1✔
976
                if (
1✔
977
                    self._config["dataframe"]["tof_column"] not in binned_data.dims
978
                    or self._config["dataframe"]["bias_column"] not in binned_data.dims
979
                ):
980
                    raise ValueError(
1✔
981
                        "If binned_data is provided as an xarray, it needs to contain dimensions "
982
                        f"'{self._config['dataframe']['tof_column']}' and "
983
                        f"'{self._config['dataframe']['bias_column']}'!.",
984
                    )
985
                tof = binned_data.coords[self._config["dataframe"]["tof_column"]].values
1✔
986
                biases = binned_data.coords[self._config["dataframe"]["bias_column"]].values
1✔
987
                traces = binned_data.values[:, :]
1✔
988
            else:
989
                try:
1✔
990
                    (tof, biases, traces) = binned_data
1✔
991
                except ValueError as exc:
1✔
992
                    raise ValueError(
1✔
993
                        "If binned_data is provided as tuple, it needs to contain "
994
                        "(tof, biases, traces)!",
995
                    ) from exc
996
            self.ec.load_data(biases=biases, traces=traces, tof=tof)
1✔
997

998
        elif data_files is not None:
1✔
999

1000
            self.ec.bin_data(
1✔
1001
                data_files=cast(List[str], self.cpy(data_files)),
1002
                axes=axes,
1003
                bins=bins,
1004
                ranges=ranges,
1005
                biases=biases,
1006
                bias_key=bias_key,
1007
            )
1008

1009
        else:
1010
            raise ValueError("Either binned_data or data_files needs to be provided!")
1✔
1011

1012
        if (normalize is not None and normalize is True) or (
1✔
1013
            normalize is None and self._config["energy"]["normalize"]
1014
        ):
1015
            if span is None:
1✔
1016
                span = self._config["energy"]["normalize_span"]
1✔
1017
            if order is None:
1✔
1018
                order = self._config["energy"]["normalize_order"]
1✔
1019
            self.ec.normalize(smooth=True, span=span, order=order)
1✔
1020
        self.ec.view(
1✔
1021
            traces=self.ec.traces_normed,
1022
            xaxis=self.ec.tof,
1023
            backend="bokeh",
1024
        )
1025

1026
    # 2. extract ranges and get peak positions
1027
    def find_bias_peaks(
1✔
1028
        self,
1029
        ranges: Union[List[Tuple], Tuple],
1030
        ref_id: int = 0,
1031
        infer_others: bool = True,
1032
        mode: str = "replace",
1033
        radius: int = None,
1034
        peak_window: int = None,
1035
        apply: bool = False,
1036
    ):
1037
        """2. step of the energy calibration workflow: Find a peak within a given range
1038
        for the indicated reference trace, and tries to find the same peak for all
1039
        other traces. Uses fast_dtw to align curves, which might not be too good if the
1040
        shape of curves changes qualitatively. Ideally, choose a reference trace in the
1041
        middle of the set, and don't choose the range too narrow around the peak.
1042
        Alternatively, a list of ranges for all traces can be provided.
1043

1044
        Args:
1045
            ranges (Union[List[Tuple], Tuple]): Tuple of TOF values indicating a range.
1046
                Alternatively, a list of ranges for all traces can be given.
1047
            refid (int, optional): The id of the trace the range refers to.
1048
                Defaults to 0.
1049
            infer_others (bool, optional): Whether to determine the range for the other
1050
                traces. Defaults to True.
1051
            mode (str, optional): Whether to "add" or "replace" existing ranges.
1052
                Defaults to "replace".
1053
            radius (int, optional): Radius parameter for fast_dtw.
1054
                Defaults to config["energy"]["fastdtw_radius"].
1055
            peak_window (int, optional): Peak_window parameter for the peak detection
1056
                algorthm. amount of points that have to have to behave monotoneously
1057
                around a peak. Defaults to config["energy"]["peak_window"].
1058
            apply (bool, optional): Option to directly apply the provided parameters.
1059
                Defaults to False.
1060
        """
1061
        if radius is None:
1✔
1062
            radius = self._config["energy"]["fastdtw_radius"]
1✔
1063
        if peak_window is None:
1✔
1064
            peak_window = self._config["energy"]["peak_window"]
1✔
1065
        if not infer_others:
1✔
1066
            self.ec.add_ranges(
1✔
1067
                ranges=ranges,
1068
                ref_id=ref_id,
1069
                infer_others=infer_others,
1070
                mode=mode,
1071
                radius=radius,
1072
            )
1073
            print(self.ec.featranges)
1✔
1074
            try:
1✔
1075
                self.ec.feature_extract(peak_window=peak_window)
1✔
1076
                self.ec.view(
1✔
1077
                    traces=self.ec.traces_normed,
1078
                    segs=self.ec.featranges,
1079
                    xaxis=self.ec.tof,
1080
                    peaks=self.ec.peaks,
1081
                    backend="bokeh",
1082
                )
UNCOV
1083
            except IndexError:
×
UNCOV
1084
                print("Could not determine all peaks!")
×
UNCOV
1085
                raise
×
1086
        else:
1087
            # New adjustment tool
1088
            assert isinstance(ranges, tuple)
1✔
1089
            self.ec.adjust_ranges(
1✔
1090
                ranges=ranges,
1091
                ref_id=ref_id,
1092
                traces=self.ec.traces_normed,
1093
                infer_others=infer_others,
1094
                radius=radius,
1095
                peak_window=peak_window,
1096
                apply=apply,
1097
            )
1098

1099
    # 3. Fit the energy calibration relation
1100
    def calibrate_energy_axis(
1✔
1101
        self,
1102
        ref_id: int,
1103
        ref_energy: float,
1104
        method: str = None,
1105
        energy_scale: str = None,
1106
        **kwds,
1107
    ):
1108
        """3. Step of the energy calibration workflow: Calculate the calibration
1109
        function for the energy axis, and apply it to the dataframe. Two
1110
        approximations are implemented, a (normally 3rd order) polynomial
1111
        approximation, and a d^2/(t-t0)^2 relation.
1112

1113
        Args:
1114
            ref_id (int): id of the trace at the bias where the reference energy is
1115
                given.
1116
            ref_energy (float): Absolute energy of the detected feature at the bias
1117
                of ref_id
1118
            method (str, optional): Method for determining the energy calibration.
1119

1120
                - **'lmfit'**: Energy calibration using lmfit and 1/t^2 form.
1121
                - **'lstsq'**, **'lsqr'**: Energy calibration using polynomial form.
1122

1123
                Defaults to config["energy"]["calibration_method"]
1124
            energy_scale (str, optional): Direction of increasing energy scale.
1125

1126
                - **'kinetic'**: increasing energy with decreasing TOF.
1127
                - **'binding'**: increasing energy with increasing TOF.
1128

1129
                Defaults to config["energy"]["energy_scale"]
1130
        """
1131
        if method is None:
1✔
1132
            method = self._config["energy"]["calibration_method"]
1✔
1133

1134
        if energy_scale is None:
1✔
1135
            energy_scale = self._config["energy"]["energy_scale"]
1✔
1136

1137
        self.ec.calibrate(
1✔
1138
            ref_id=ref_id,
1139
            ref_energy=ref_energy,
1140
            method=method,
1141
            energy_scale=energy_scale,
1142
            **kwds,
1143
        )
1144
        print("Quality of Calibration:")
1✔
1145
        self.ec.view(
1✔
1146
            traces=self.ec.traces_normed,
1147
            xaxis=self.ec.calibration["axis"],
1148
            align=True,
1149
            energy_scale=energy_scale,
1150
            backend="bokeh",
1151
        )
1152
        print("E/TOF relationship:")
1✔
1153
        self.ec.view(
1✔
1154
            traces=self.ec.calibration["axis"][None, :],
1155
            xaxis=self.ec.tof,
1156
            backend="matplotlib",
1157
            show_legend=False,
1158
        )
1159
        if energy_scale == "kinetic":
1✔
1160
            plt.scatter(
1✔
1161
                self.ec.peaks[:, 0],
1162
                -(self.ec.biases - self.ec.biases[ref_id]) + ref_energy,
1163
                s=50,
1164
                c="k",
1165
            )
1166
        elif energy_scale == "binding":
1✔
1167
            plt.scatter(
1✔
1168
                self.ec.peaks[:, 0],
1169
                self.ec.biases - self.ec.biases[ref_id] + ref_energy,
1170
                s=50,
1171
                c="k",
1172
            )
1173
        else:
UNCOV
1174
            raise ValueError(
×
1175
                'energy_scale needs to be either "binding" or "kinetic"',
1176
                f", got {energy_scale}.",
1177
            )
1178
        plt.xlabel("Time-of-flight", fontsize=15)
1✔
1179
        plt.ylabel("Energy (eV)", fontsize=15)
1✔
1180
        plt.show()
1✔
1181

1182
    # 3a. Save energy calibration parameters to config file.
1183
    def save_energy_calibration(
1✔
1184
        self,
1185
        filename: str = None,
1186
        overwrite: bool = False,
1187
    ):
1188
        """Save the generated energy calibration parameters to the folder config file.
1189

1190
        Args:
1191
            filename (str, optional): Filename of the config dictionary to save to.
1192
                Defaults to "sed_config.yaml" in the current folder.
1193
            overwrite (bool, optional): Option to overwrite the present dictionary.
1194
                Defaults to False.
1195
        """
1196
        if filename is None:
1✔
UNCOV
1197
            filename = "sed_config.yaml"
×
1198
        calibration = {}
1✔
1199
        try:
1✔
1200
            for (key, value) in self.ec.calibration.items():
1✔
1201
                if key in ["axis", "refid", "Tmat", "bvec"]:
1✔
1202
                    continue
1✔
1203
                if key == "energy_scale":
1✔
1204
                    calibration[key] = value
1✔
1205
                elif key == "coeffs":
1✔
1206
                    calibration[key] = [float(i) for i in value]
1✔
1207
                else:
1208
                    calibration[key] = float(value)
1✔
UNCOV
1209
        except AttributeError as exc:
×
UNCOV
1210
            raise AttributeError(
×
1211
                "Energy calibration parameters not found, need to generate parameters first!",
1212
            ) from exc
1213

1214
        config = {"energy": {"calibration": calibration}}
1✔
1215
        if isinstance(self.ec.offset, dict):
1✔
1216
            config["energy"]["offset"] = self.ec.offset
1✔
1217
        save_config(config, filename, overwrite)
1✔
1218

1219
    # 4. Apply energy calibration to the dataframe
1220
    def append_energy_axis(
1✔
1221
        self,
1222
        calibration: dict = None,
1223
        preview: bool = False,
1224
        **kwds,
1225
    ):
1226
        """4. step of the energy calibration workflow: Apply the calibration function
1227
        to to the dataframe. Two approximations are implemented, a (normally 3rd order)
1228
        polynomial approximation, and a d^2/(t-t0)^2 relation. a calibration dictionary
1229
        can be provided.
1230

1231
        Args:
1232
            calibration (dict, optional): Calibration dict containing calibration
1233
                parameters. Overrides calibration from class or config.
1234
                Defaults to None.
1235
            preview (bool): Option to preview the first elements of the data frame.
1236
            **kwds:
1237
                Keyword args passed to ``EnergyCalibrator.append_energy_axis``.
1238
        """
1239
        if self._dataframe is not None:
1✔
1240
            print("Adding energy column to dataframe:")
1✔
1241
            self._dataframe, metadata = self.ec.append_energy_axis(
1✔
1242
                df=self._dataframe,
1243
                calibration=calibration,
1244
                **kwds,
1245
            )
1246
            if self._timed_dataframe is not None:
1✔
1247
                if self._config["dataframe"]["tof_column"] in self._timed_dataframe.columns:
1✔
1248
                    self._timed_dataframe, _ = self.ec.append_energy_axis(
1✔
1249
                        df=self._timed_dataframe,
1250
                        calibration=calibration,
1251
                        **kwds,
1252
                    )
1253

1254
            # Add Metadata
1255
            self._attributes.add(
1✔
1256
                metadata,
1257
                "energy_calibration",
1258
                duplicate_policy="merge",
1259
            )
1260
            if preview:
1✔
1261
                print(self._dataframe.head(10))
1✔
1262
            else:
1263
                print(self._dataframe)
1✔
1264

1265
    def add_energy_offset(
1✔
1266
        self,
1267
        constant: float = None,
1268
        columns: Union[str, Sequence[str]] = None,
1269
        signs: Union[int, Sequence[int]] = None,
1270
        reductions: Union[str, Sequence[str]] = None,
1271
        preserve_mean: Union[bool, Sequence[bool]] = None,
1272
    ) -> None:
1273
        """Shift the energy axis of the dataframe by a given amount.
1274

1275
        Args:
1276
            constant (float, optional): The constant to shift the energy axis by.
1277
            columns (Union[str, Sequence[str]]): Name of the column(s) to apply the shift from.
1278
            signs (Union[int, Sequence[int]]): Sign of the shift to apply. (+1 or -1) A positive
1279
                sign shifts the energy axis to higher kinetic energies. Defaults to +1.
1280
            preserve_mean (bool): Whether to subtract the mean of the column before applying the
1281
                shift. Defaults to False.
1282
            reductions (str): The reduction to apply to the column. Should be an available method
1283
                of dask.dataframe.Series. For example "mean". In this case the function is applied
1284
                to the column to generate a single value for the whole dataset. If None, the shift
1285
                is applied per-dataframe-row. Defaults to None. Currently only "mean" is supported.
1286

1287
        Raises:
1288
            ValueError: If the energy column is not in the dataframe.
1289
        """
1290
        energy_column = self._config["dataframe"]["energy_column"]
1✔
1291
        if self.dataframe is not None:
1✔
1292
            if energy_column not in self._dataframe.columns:
1✔
1293
                raise ValueError(
1✔
1294
                    f"Energy column {energy_column} not found in dataframe! "
1295
                    "Run `append energy axis` first.",
1296
                )
1297
            df, metadata = self.ec.add_offsets(
1✔
1298
                df=self._dataframe,
1299
                constant=constant,
1300
                columns=columns,
1301
                energy_column=energy_column,
1302
                signs=signs,
1303
                reductions=reductions,
1304
                preserve_mean=preserve_mean,
1305
            )
1306
            if self._timed_dataframe is not None:
1✔
1307
                if energy_column in self._timed_dataframe.columns:
1✔
1308
                    self._timed_dataframe, _ = self.ec.add_offsets(
1✔
1309
                        df=self._timed_dataframe,
1310
                        constant=constant,
1311
                        columns=columns,
1312
                        energy_column=energy_column,
1313
                        signs=signs,
1314
                        reductions=reductions,
1315
                        preserve_mean=preserve_mean,
1316
                    )
1317
            self._attributes.add(
1✔
1318
                metadata,
1319
                "add_energy_offset",
1320
                # TODO: allow only appending when no offset along this column(s) was applied
1321
                # TODO: clear memory of modifications if the energy axis is recalculated
1322
                duplicate_policy="append",
1323
            )
1324
            self._dataframe = df
1✔
1325
        else:
UNCOV
1326
            raise ValueError("No dataframe loaded!")
×
1327

1328
    def append_tof_ns_axis(
1✔
1329
        self,
1330
        **kwargs,
1331
    ):
1332
        """Convert time-of-flight channel steps to nanoseconds.
1333

1334
        Args:
1335
            tof_ns_column (str, optional): Name of the generated column containing the
1336
                time-of-flight in nanosecond.
1337
                Defaults to config["dataframe"]["tof_ns_column"].
1338
            kwargs: additional arguments are passed to ``energy.tof_step_to_ns``.
1339

1340
        """
1341
        if self._dataframe is not None:
1✔
1342
            print("Adding time-of-flight column in nanoseconds to dataframe:")
1✔
1343
            # TODO assert order of execution through metadata
1344

1345
            self._dataframe, metadata = self.ec.append_tof_ns_axis(
1✔
1346
                df=self._dataframe,
1347
                **kwargs,
1348
            )
1349
            if self._timed_dataframe is not None:
1✔
1350
                if self._config["dataframe"]["tof_column"] in self._timed_dataframe.columns:
1✔
1351
                    self._timed_dataframe, _ = self.ec.append_tof_ns_axis(
1✔
1352
                        df=self._timed_dataframe,
1353
                        **kwargs,
1354
                    )
1355
            self._attributes.add(
1✔
1356
                metadata,
1357
                "tof_ns_conversion",
1358
                duplicate_policy="append",
1359
            )
1360

1361
    def align_dld_sectors(self, sector_delays: np.ndarray = None, **kwargs):
1✔
1362
        """Align the 8s sectors of the HEXTOF endstation.
1363

1364
        Args:
1365
            sector_delays (np.ndarray, optional): Array containing the sector delays. Defaults to
1366
                config["dataframe"]["sector_delays"].
1367
        """
1368
        if self._dataframe is not None:
1✔
1369
            print("Aligning 8s sectors of dataframe")
1✔
1370
            # TODO assert order of execution through metadata
1371
            self._dataframe, metadata = self.ec.align_dld_sectors(
1✔
1372
                df=self._dataframe,
1373
                sector_delays=sector_delays,
1374
                **kwargs,
1375
            )
1376
            if self._timed_dataframe is not None:
1✔
1377
                if self._config["dataframe"]["tof_column"] in self._timed_dataframe.columns:
1✔
UNCOV
1378
                    self._timed_dataframe, _ = self.ec.align_dld_sectors(
×
1379
                        df=self._timed_dataframe,
1380
                        sector_delays=sector_delays,
1381
                        **kwargs,
1382
                    )
1383
            self._attributes.add(
1✔
1384
                metadata,
1385
                "dld_sector_alignment",
1386
                duplicate_policy="raise",
1387
            )
1388

1389
    # Delay calibration function
1390
    def calibrate_delay_axis(
1✔
1391
        self,
1392
        delay_range: Tuple[float, float] = None,
1393
        datafile: str = None,
1394
        preview: bool = False,
1395
        **kwds,
1396
    ):
1397
        """Append delay column to dataframe. Either provide delay ranges, or read
1398
        them from a file.
1399

1400
        Args:
1401
            delay_range (Tuple[float, float], optional): The scanned delay range in
1402
                picoseconds. Defaults to None.
1403
            datafile (str, optional): The file from which to read the delay ranges.
1404
                Defaults to None.
1405
            preview (bool): Option to preview the first elements of the data frame.
1406
            **kwds: Keyword args passed to ``DelayCalibrator.append_delay_axis``.
1407
        """
1408
        if self._dataframe is not None:
1✔
1409
            print("Adding delay column to dataframe:")
1✔
1410

1411
            if delay_range is not None:
1✔
1412
                self._dataframe, metadata = self.dc.append_delay_axis(
1✔
1413
                    self._dataframe,
1414
                    delay_range=delay_range,
1415
                    **kwds,
1416
                )
1417
                if self._timed_dataframe is not None:
1✔
1418
                    if self._config["dataframe"]["adc_column"] in self._timed_dataframe.columns:
1✔
1419
                        self._timed_dataframe, _ = self.dc.append_delay_axis(
1✔
1420
                            self._timed_dataframe,
1421
                            delay_range=delay_range,
1422
                            **kwds,
1423
                        )
1424
            else:
1425
                if datafile is None:
1✔
1426
                    try:
1✔
1427
                        datafile = self._files[0]
1✔
UNCOV
1428
                    except IndexError:
×
UNCOV
1429
                        print(
×
1430
                            "No datafile available, specify either",
1431
                            " 'datafile' or 'delay_range'",
1432
                        )
UNCOV
1433
                        raise
×
1434

1435
                self._dataframe, metadata = self.dc.append_delay_axis(
1✔
1436
                    self._dataframe,
1437
                    datafile=datafile,
1438
                    **kwds,
1439
                )
1440
                if self._timed_dataframe is not None:
1✔
1441
                    if self._config["dataframe"]["adc_column"] in self._timed_dataframe.columns:
1✔
1442
                        self._timed_dataframe, _ = self.dc.append_delay_axis(
1✔
1443
                            self._timed_dataframe,
1444
                            datafile=datafile,
1445
                            **kwds,
1446
                        )
1447

1448
            # Add Metadata
1449
            self._attributes.add(
1✔
1450
                metadata,
1451
                "delay_calibration",
1452
                duplicate_policy="merge",
1453
            )
1454
            if preview:
1✔
1455
                print(self._dataframe.head(10))
1✔
1456
            else:
1457
                print(self._dataframe)
1✔
1458

1459
    def add_jitter(
1✔
1460
        self,
1461
        cols: List[str] = None,
1462
        amps: Union[float, Sequence[float]] = None,
1463
        **kwds,
1464
    ):
1465
        """Add jitter to the selected dataframe columns.
1466

1467
        Args:
1468
            cols (List[str], optional): The colums onto which to apply jitter.
1469
                Defaults to config["dataframe"]["jitter_cols"].
1470
            amps (Union[float, Sequence[float]], optional): Amplitude scalings for the
1471
                jittering noise. If one number is given, the same is used for all axes.
1472
                For uniform noise (default) it will cover the interval [-amp, +amp].
1473
                Defaults to config["dataframe"]["jitter_amps"].
1474
            **kwds: additional keyword arguments passed to apply_jitter
1475
        """
1476
        if cols is None:
1✔
1477
            cols = self._config["dataframe"]["jitter_cols"]
1✔
1478
        for loc, col in enumerate(cols):
1✔
1479
            if col.startswith("@"):
1✔
1480
                cols[loc] = self._config["dataframe"].get(col.strip("@"))
1✔
1481

1482
        if amps is None:
1✔
1483
            amps = self._config["dataframe"]["jitter_amps"]
1✔
1484

1485
        self._dataframe = self._dataframe.map_partitions(
1✔
1486
            apply_jitter,
1487
            cols=cols,
1488
            cols_jittered=cols,
1489
            amps=amps,
1490
            **kwds,
1491
        )
1492
        if self._timed_dataframe is not None:
1✔
1493
            cols_timed = cols.copy()
1✔
1494
            for col in cols:
1✔
1495
                if col not in self._timed_dataframe.columns:
1✔
UNCOV
1496
                    cols_timed.remove(col)
×
1497

1498
            if cols_timed:
1✔
1499
                self._timed_dataframe = self._timed_dataframe.map_partitions(
1✔
1500
                    apply_jitter,
1501
                    cols=cols_timed,
1502
                    cols_jittered=cols_timed,
1503
                )
1504
        metadata = []
1✔
1505
        for col in cols:
1✔
1506
            metadata.append(col)
1✔
1507
        self._attributes.add(metadata, "jittering", duplicate_policy="append")
1✔
1508

1509
    def add_time_stamped_data(
1✔
1510
        self,
1511
        dest_column: str,
1512
        time_stamps: np.ndarray = None,
1513
        data: np.ndarray = None,
1514
        archiver_channel: str = None,
1515
        **kwds,
1516
    ):
1517
        """Add data in form of timestamp/value pairs to the dataframe using interpolation to the
1518
        timestamps in the dataframe. The time-stamped data can either be provided, or fetched from
1519
        an EPICS archiver instance.
1520

1521
        Args:
1522
            dest_column (str): destination column name
1523
            time_stamps (np.ndarray, optional): Time stamps of the values to add. If omitted,
1524
                time stamps are retrieved from the epics archiver
1525
            data (np.ndarray, optional): Values corresponding at the time stamps in time_stamps.
1526
                If omitted, data are retrieved from the epics archiver.
1527
            archiver_channel (str, optional): EPICS archiver channel from which to retrieve data.
1528
                Either this or data and time_stamps have to be present.
1529
            **kwds: additional keyword arguments passed to add_time_stamped_data
1530
        """
1531
        time_stamp_column = kwds.pop(
1✔
1532
            "time_stamp_column",
1533
            self._config["dataframe"].get("time_stamp_alias", ""),
1534
        )
1535

1536
        if time_stamps is None and data is None:
1✔
NEW
UNCOV
1537
            if archiver_channel is None:
×
NEW
UNCOV
1538
                raise ValueError(
×
1539
                    "Either archiver_channel or both time_stamps and data have to be present!",
1540
                )
NEW
UNCOV
1541
            if self.loader.__name__ != "mpes":
×
NEW
UNCOV
1542
                raise NotImplementedError(
×
1543
                    "This function is currently only implemented for the mpes loader!",
1544
                )
NEW
UNCOV
1545
            ts_from, ts_to = cast(MpesLoader, self.loader).get_start_and_end_time()
×
1546
            # get channel data with +-5 seconds safety margin
NEW
UNCOV
1547
            time_stamps, data = get_archiver_data(
×
1548
                archiver_url=self._config["metadata"].get("archiver_url", ""),
1549
                archiver_channel=archiver_channel,
1550
                ts_from=ts_from - 5,
1551
                ts_to=ts_to + 5,
1552
            )
1553

1554
        self._dataframe = add_time_stamped_data(
1✔
1555
            self._dataframe,
1556
            time_stamps=time_stamps,
1557
            data=data,
1558
            dest_column=dest_column,
1559
            time_stamp_column=time_stamp_column,
1560
            **kwds,
1561
        )
1562
        if self._timed_dataframe is not None:
1✔
1563
            if time_stamp_column in self._timed_dataframe:
1✔
1564
                self._timed_dataframe = add_time_stamped_data(
1✔
1565
                    self._timed_dataframe,
1566
                    time_stamps=time_stamps,
1567
                    data=data,
1568
                    dest_column=dest_column,
1569
                    time_stamp_column=time_stamp_column,
1570
                    **kwds,
1571
                )
1572
        metadata: List[Any] = []
1✔
1573
        metadata.append(dest_column)
1✔
1574
        metadata.append(time_stamps)
1✔
1575
        metadata.append(data)
1✔
1576
        self._attributes.add(metadata, "time_stamped_data", duplicate_policy="append")
1✔
1577

1578
    def pre_binning(
1✔
1579
        self,
1580
        df_partitions: int = 100,
1581
        axes: List[str] = None,
1582
        bins: List[int] = None,
1583
        ranges: Sequence[Tuple[float, float]] = None,
1584
        **kwds,
1585
    ) -> xr.DataArray:
1586
        """Function to do an initial binning of the dataframe loaded to the class.
1587

1588
        Args:
1589
            df_partitions (int, optional): Number of dataframe partitions to use for
1590
                the initial binning. Defaults to 100.
1591
            axes (List[str], optional): Axes to bin.
1592
                Defaults to config["momentum"]["axes"].
1593
            bins (List[int], optional): Bin numbers to use for binning.
1594
                Defaults to config["momentum"]["bins"].
1595
            ranges (List[Tuple], optional): Ranges to use for binning.
1596
                Defaults to config["momentum"]["ranges"].
1597
            **kwds: Keyword argument passed to ``compute``.
1598

1599
        Returns:
1600
            xr.DataArray: pre-binned data-array.
1601
        """
1602
        if axes is None:
1✔
1603
            axes = self._config["momentum"]["axes"]
1✔
1604
        for loc, axis in enumerate(axes):
1✔
1605
            if axis.startswith("@"):
1✔
1606
                axes[loc] = self._config["dataframe"].get(axis.strip("@"))
1✔
1607

1608
        if bins is None:
1✔
1609
            bins = self._config["momentum"]["bins"]
1✔
1610
        if ranges is None:
1✔
1611
            ranges_ = list(self._config["momentum"]["ranges"])
1✔
1612
            ranges_[2] = np.asarray(ranges_[2]) / 2 ** (
1✔
1613
                self._config["dataframe"]["tof_binning"] - 1
1614
            )
1615
            ranges = [cast(Tuple[float, float], tuple(v)) for v in ranges_]
1✔
1616

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

1619
        return self.compute(
1✔
1620
            bins=bins,
1621
            axes=axes,
1622
            ranges=ranges,
1623
            df_partitions=df_partitions,
1624
            **kwds,
1625
        )
1626

1627
    def compute(
1✔
1628
        self,
1629
        bins: Union[
1630
            int,
1631
            dict,
1632
            tuple,
1633
            List[int],
1634
            List[np.ndarray],
1635
            List[tuple],
1636
        ] = 100,
1637
        axes: Union[str, Sequence[str]] = None,
1638
        ranges: Sequence[Tuple[float, float]] = None,
1639
        normalize_to_acquisition_time: Union[bool, str] = False,
1640
        **kwds,
1641
    ) -> xr.DataArray:
1642
        """Compute the histogram along the given dimensions.
1643

1644
        Args:
1645
            bins (int, dict, tuple, List[int], List[np.ndarray], List[tuple], optional):
1646
                Definition of the bins. Can be any of the following cases:
1647

1648
                - an integer describing the number of bins in on all dimensions
1649
                - a tuple of 3 numbers describing start, end and step of the binning
1650
                  range
1651
                - a np.arrays defining the binning edges
1652
                - a list (NOT a tuple) of any of the above (int, tuple or np.ndarray)
1653
                - a dictionary made of the axes as keys and any of the above as values.
1654

1655
                This takes priority over the axes and range arguments. Defaults to 100.
1656
            axes (Union[str, Sequence[str]], optional): The names of the axes (columns)
1657
                on which to calculate the histogram. The order will be the order of the
1658
                dimensions in the resulting array. Defaults to None.
1659
            ranges (Sequence[Tuple[float, float]], optional): list of tuples containing
1660
                the start and end point of the binning range. Defaults to None.
1661
            normalize_to_acquisition_time (Union[bool, str]): Option to normalize the
1662
                result to the acquistion time. If a "slow" axis was scanned, providing
1663
                the name of the scanned axis will compute and apply the corresponding
1664
                normalization histogram. Defaults to False.
1665
            **kwds: Keyword arguments:
1666

1667
                - **hist_mode**: Histogram calculation method. "numpy" or "numba". See
1668
                  ``bin_dataframe`` for details. Defaults to
1669
                  config["binning"]["hist_mode"].
1670
                - **mode**: Defines how the results from each partition are combined.
1671
                  "fast", "lean" or "legacy". See ``bin_dataframe`` for details.
1672
                  Defaults to config["binning"]["mode"].
1673
                - **pbar**: Option to show the tqdm progress bar. Defaults to
1674
                  config["binning"]["pbar"].
1675
                - **n_cores**: Number of CPU cores to use for parallelization.
1676
                  Defaults to config["binning"]["num_cores"] or N_CPU-1.
1677
                - **threads_per_worker**: Limit the number of threads that
1678
                  multiprocessing can spawn per binning thread. Defaults to
1679
                  config["binning"]["threads_per_worker"].
1680
                - **threadpool_api**: The API to use for multiprocessing. "blas",
1681
                  "openmp" or None. See ``threadpool_limit`` for details. Defaults to
1682
                  config["binning"]["threadpool_API"].
1683
                - **df_partitions**: A range or list of dataframe partitions, or the
1684
                  number of the dataframe partitions to use. Defaults to all partitions.
1685

1686
                Additional kwds are passed to ``bin_dataframe``.
1687

1688
        Raises:
1689
            AssertError: Rises when no dataframe has been loaded.
1690

1691
        Returns:
1692
            xr.DataArray: The result of the n-dimensional binning represented in an
1693
            xarray object, combining the data with the axes.
1694
        """
1695
        assert self._dataframe is not None, "dataframe needs to be loaded first!"
1✔
1696

1697
        hist_mode = kwds.pop("hist_mode", self._config["binning"]["hist_mode"])
1✔
1698
        mode = kwds.pop("mode", self._config["binning"]["mode"])
1✔
1699
        pbar = kwds.pop("pbar", self._config["binning"]["pbar"])
1✔
1700
        num_cores = kwds.pop("num_cores", self._config["binning"]["num_cores"])
1✔
1701
        threads_per_worker = kwds.pop(
1✔
1702
            "threads_per_worker",
1703
            self._config["binning"]["threads_per_worker"],
1704
        )
1705
        threadpool_api = kwds.pop(
1✔
1706
            "threadpool_API",
1707
            self._config["binning"]["threadpool_API"],
1708
        )
1709
        df_partitions = kwds.pop("df_partitions", None)
1✔
1710
        if isinstance(df_partitions, int):
1✔
1711
            df_partitions = slice(
1✔
1712
                0,
1713
                min(df_partitions, self._dataframe.npartitions),
1714
            )
1715
        if df_partitions is not None:
1✔
1716
            dataframe = self._dataframe.partitions[df_partitions]
1✔
1717
        else:
1718
            dataframe = self._dataframe
1✔
1719

1720
        self._binned = bin_dataframe(
1✔
1721
            df=dataframe,
1722
            bins=bins,
1723
            axes=axes,
1724
            ranges=ranges,
1725
            hist_mode=hist_mode,
1726
            mode=mode,
1727
            pbar=pbar,
1728
            n_cores=num_cores,
1729
            threads_per_worker=threads_per_worker,
1730
            threadpool_api=threadpool_api,
1731
            **kwds,
1732
        )
1733

1734
        for dim in self._binned.dims:
1✔
1735
            try:
1✔
1736
                self._binned[dim].attrs["unit"] = self._config["dataframe"]["units"][dim]
1✔
1737
            except KeyError:
1✔
1738
                pass
1✔
1739

1740
        self._binned.attrs["units"] = "counts"
1✔
1741
        self._binned.attrs["long_name"] = "photoelectron counts"
1✔
1742
        self._binned.attrs["metadata"] = self._attributes.metadata
1✔
1743

1744
        if normalize_to_acquisition_time:
1✔
1745
            if isinstance(normalize_to_acquisition_time, str):
1✔
1746
                axis = normalize_to_acquisition_time
1✔
1747
                print(
1✔
1748
                    f"Calculate normalization histogram for axis '{axis}'...",
1749
                )
1750
                self._normalization_histogram = self.get_normalization_histogram(
1✔
1751
                    axis=axis,
1752
                    df_partitions=df_partitions,
1753
                )
1754
                # if the axes are named correctly, xarray figures out the normalization correctly
1755
                self._normalized = self._binned / self._normalization_histogram
1✔
1756
                self._attributes.add(
1✔
1757
                    self._normalization_histogram.values,
1758
                    name="normalization_histogram",
1759
                    duplicate_policy="overwrite",
1760
                )
1761
            else:
1762
                acquisition_time = self.loader.get_elapsed_time(
×
1763
                    fids=df_partitions,
1764
                )
1765
                if acquisition_time > 0:
×
1766
                    self._normalized = self._binned / acquisition_time
×
1767
                self._attributes.add(
×
1768
                    acquisition_time,
1769
                    name="normalization_histogram",
1770
                    duplicate_policy="overwrite",
1771
                )
1772

1773
            self._normalized.attrs["units"] = "counts/second"
1✔
1774
            self._normalized.attrs["long_name"] = "photoelectron counts per second"
1✔
1775
            self._normalized.attrs["metadata"] = self._attributes.metadata
1✔
1776

1777
            return self._normalized
1✔
1778

1779
        return self._binned
1✔
1780

1781
    def get_normalization_histogram(
1✔
1782
        self,
1783
        axis: str = "delay",
1784
        use_time_stamps: bool = False,
1785
        **kwds,
1786
    ) -> xr.DataArray:
1787
        """Generates a normalization histogram from the timed dataframe. Optionally,
1788
        use the TimeStamps column instead.
1789

1790
        Args:
1791
            axis (str, optional): The axis for which to compute histogram.
1792
                Defaults to "delay".
1793
            use_time_stamps (bool, optional): Use the TimeStamps column of the
1794
                dataframe, rather than the timed dataframe. Defaults to False.
1795
            **kwds: Keyword arguments:
1796

1797
                -df_partitions (int, optional): Number of dataframe partitions to use.
1798
                  Defaults to all.
1799

1800
        Raises:
1801
            ValueError: Raised if no data are binned.
1802
            ValueError: Raised if 'axis' not in binned coordinates.
1803
            ValueError: Raised if config["dataframe"]["time_stamp_alias"] not found
1804
                in Dataframe.
1805

1806
        Returns:
1807
            xr.DataArray: The computed normalization histogram (in TimeStamp units
1808
            per bin).
1809
        """
1810

1811
        if self._binned is None:
1✔
1812
            raise ValueError("Need to bin data first!")
1✔
1813
        if axis not in self._binned.coords:
1✔
1814
            raise ValueError(f"Axis '{axis}' not found in binned data!")
1✔
1815

1816
        df_partitions: Union[int, slice] = kwds.pop("df_partitions", None)
1✔
1817
        if isinstance(df_partitions, int):
1✔
1818
            df_partitions = slice(
1✔
1819
                0,
1820
                min(df_partitions, self._dataframe.npartitions),
1821
            )
1822

1823
        if use_time_stamps or self._timed_dataframe is None:
1✔
1824
            if df_partitions is not None:
1✔
1825
                self._normalization_histogram = normalization_histogram_from_timestamps(
1✔
1826
                    self._dataframe.partitions[df_partitions],
1827
                    axis,
1828
                    self._binned.coords[axis].values,
1829
                    self._config["dataframe"]["time_stamp_alias"],
1830
                )
1831
            else:
UNCOV
1832
                self._normalization_histogram = normalization_histogram_from_timestamps(
×
1833
                    self._dataframe,
1834
                    axis,
1835
                    self._binned.coords[axis].values,
1836
                    self._config["dataframe"]["time_stamp_alias"],
1837
                )
1838
        else:
1839
            if df_partitions is not None:
1✔
1840
                self._normalization_histogram = normalization_histogram_from_timed_dataframe(
1✔
1841
                    self._timed_dataframe.partitions[df_partitions],
1842
                    axis,
1843
                    self._binned.coords[axis].values,
1844
                    self._config["dataframe"]["timed_dataframe_unit_time"],
1845
                )
1846
            else:
UNCOV
1847
                self._normalization_histogram = normalization_histogram_from_timed_dataframe(
×
1848
                    self._timed_dataframe,
1849
                    axis,
1850
                    self._binned.coords[axis].values,
1851
                    self._config["dataframe"]["timed_dataframe_unit_time"],
1852
                )
1853

1854
        return self._normalization_histogram
1✔
1855

1856
    def view_event_histogram(
1✔
1857
        self,
1858
        dfpid: int,
1859
        ncol: int = 2,
1860
        bins: Sequence[int] = None,
1861
        axes: Sequence[str] = None,
1862
        ranges: Sequence[Tuple[float, float]] = None,
1863
        backend: str = "bokeh",
1864
        legend: bool = True,
1865
        histkwds: dict = None,
1866
        legkwds: dict = None,
1867
        **kwds,
1868
    ):
1869
        """Plot individual histograms of specified dimensions (axes) from a substituent
1870
        dataframe partition.
1871

1872
        Args:
1873
            dfpid (int): Number of the data frame partition to look at.
1874
            ncol (int, optional): Number of columns in the plot grid. Defaults to 2.
1875
            bins (Sequence[int], optional): Number of bins to use for the speicified
1876
                axes. Defaults to config["histogram"]["bins"].
1877
            axes (Sequence[str], optional): Names of the axes to display.
1878
                Defaults to config["histogram"]["axes"].
1879
            ranges (Sequence[Tuple[float, float]], optional): Value ranges of all
1880
                specified axes. Defaults toconfig["histogram"]["ranges"].
1881
            backend (str, optional): Backend of the plotting library
1882
                ('matplotlib' or 'bokeh'). Defaults to "bokeh".
1883
            legend (bool, optional): Option to include a legend in the histogram plots.
1884
                Defaults to True.
1885
            histkwds (dict, optional): Keyword arguments for histograms
1886
                (see ``matplotlib.pyplot.hist()``). Defaults to {}.
1887
            legkwds (dict, optional): Keyword arguments for legend
1888
                (see ``matplotlib.pyplot.legend()``). Defaults to {}.
1889
            **kwds: Extra keyword arguments passed to
1890
                ``sed.diagnostics.grid_histogram()``.
1891

1892
        Raises:
1893
            TypeError: Raises when the input values are not of the correct type.
1894
        """
1895
        if bins is None:
1✔
1896
            bins = self._config["histogram"]["bins"]
1✔
1897
        if axes is None:
1✔
1898
            axes = self._config["histogram"]["axes"]
1✔
1899
        axes = list(axes)
1✔
1900
        for loc, axis in enumerate(axes):
1✔
1901
            if axis.startswith("@"):
1✔
1902
                axes[loc] = self._config["dataframe"].get(axis.strip("@"))
1✔
1903
        if ranges is None:
1✔
1904
            ranges = list(self._config["histogram"]["ranges"])
1✔
1905
            for loc, axis in enumerate(axes):
1✔
1906
                if axis == self._config["dataframe"]["tof_column"]:
1✔
1907
                    ranges[loc] = np.asarray(ranges[loc]) / 2 ** (
1✔
1908
                        self._config["dataframe"]["tof_binning"] - 1
1909
                    )
1910
                elif axis == self._config["dataframe"]["adc_column"]:
1✔
UNCOV
1911
                    ranges[loc] = np.asarray(ranges[loc]) / 2 ** (
×
1912
                        self._config["dataframe"]["adc_binning"] - 1
1913
                    )
1914

1915
        input_types = map(type, [axes, bins, ranges])
1✔
1916
        allowed_types = [list, tuple]
1✔
1917

1918
        df = self._dataframe
1✔
1919

1920
        if not set(input_types).issubset(allowed_types):
1✔
UNCOV
1921
            raise TypeError(
×
1922
                "Inputs of axes, bins, ranges need to be list or tuple!",
1923
            )
1924

1925
        # Read out the values for the specified groups
1926
        group_dict_dd = {}
1✔
1927
        dfpart = df.get_partition(dfpid)
1✔
1928
        cols = dfpart.columns
1✔
1929
        for ax in axes:
1✔
1930
            group_dict_dd[ax] = dfpart.values[:, cols.get_loc(ax)]
1✔
1931
        group_dict = ddf.compute(group_dict_dd)[0]
1✔
1932

1933
        # Plot multiple histograms in a grid
1934
        grid_histogram(
1✔
1935
            group_dict,
1936
            ncol=ncol,
1937
            rvs=axes,
1938
            rvbins=bins,
1939
            rvranges=ranges,
1940
            backend=backend,
1941
            legend=legend,
1942
            histkwds=histkwds,
1943
            legkwds=legkwds,
1944
            **kwds,
1945
        )
1946

1947
    def save(
1✔
1948
        self,
1949
        faddr: str,
1950
        **kwds,
1951
    ):
1952
        """Saves the binned data to the provided path and filename.
1953

1954
        Args:
1955
            faddr (str): Path and name of the file to write. Its extension determines
1956
                the file type to write. Valid file types are:
1957

1958
                - "*.tiff", "*.tif": Saves a TIFF stack.
1959
                - "*.h5", "*.hdf5": Saves an HDF5 file.
1960
                - "*.nxs", "*.nexus": Saves a NeXus file.
1961

1962
            **kwds: Keyword argumens, which are passed to the writer functions:
1963
                For TIFF writing:
1964

1965
                - **alias_dict**: Dictionary of dimension aliases to use.
1966

1967
                For HDF5 writing:
1968

1969
                - **mode**: hdf5 read/write mode. Defaults to "w".
1970

1971
                For NeXus:
1972

1973
                - **reader**: Name of the nexustools reader to use.
1974
                  Defaults to config["nexus"]["reader"]
1975
                - **definiton**: NeXus application definition to use for saving.
1976
                  Must be supported by the used ``reader``. Defaults to
1977
                  config["nexus"]["definition"]
1978
                - **input_files**: A list of input files to pass to the reader.
1979
                  Defaults to config["nexus"]["input_files"]
1980
                - **eln_data**: An electronic-lab-notebook file in '.yaml' format
1981
                  to add to the list of files to pass to the reader.
1982
        """
1983
        if self._binned is None:
1✔
1984
            raise NameError("Need to bin data first!")
1✔
1985

1986
        if self._normalized is not None:
1✔
UNCOV
1987
            data = self._normalized
×
1988
        else:
1989
            data = self._binned
1✔
1990

1991
        extension = pathlib.Path(faddr).suffix
1✔
1992

1993
        if extension in (".tif", ".tiff"):
1✔
1994
            to_tiff(
1✔
1995
                data=data,
1996
                faddr=faddr,
1997
                **kwds,
1998
            )
1999
        elif extension in (".h5", ".hdf5"):
1✔
2000
            to_h5(
1✔
2001
                data=data,
2002
                faddr=faddr,
2003
                **kwds,
2004
            )
2005
        elif extension in (".nxs", ".nexus"):
1✔
2006
            try:
1✔
2007
                reader = kwds.pop("reader", self._config["nexus"]["reader"])
1✔
2008
                definition = kwds.pop(
1✔
2009
                    "definition",
2010
                    self._config["nexus"]["definition"],
2011
                )
2012
                input_files = kwds.pop(
1✔
2013
                    "input_files",
2014
                    self._config["nexus"]["input_files"],
2015
                )
UNCOV
2016
            except KeyError as exc:
×
UNCOV
2017
                raise ValueError(
×
2018
                    "The nexus reader, definition and input files need to be provide!",
2019
                ) from exc
2020

2021
            if isinstance(input_files, str):
1✔
2022
                input_files = [input_files]
1✔
2023

2024
            if "eln_data" in kwds:
1✔
UNCOV
2025
                input_files.append(kwds.pop("eln_data"))
×
2026

2027
            to_nexus(
1✔
2028
                data=data,
2029
                faddr=faddr,
2030
                reader=reader,
2031
                definition=definition,
2032
                input_files=input_files,
2033
                **kwds,
2034
            )
2035

2036
        else:
2037
            raise NotImplementedError(
1✔
2038
                f"Unrecognized file format: {extension}.",
2039
            )
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