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

OpenCOMPES / sed / 9733951548

30 Jun 2024 06:06PM UTC coverage: 92.511% (+0.05%) from 92.462%
9733951548

Pull #411

github

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

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

91 existing lines in 2 files now uncovered.

6905 of 7464 relevant lines covered (92.51%)

0.93 hits per line

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

92.7
/sed/calibrator/energy.py
1
"""sed.calibrator.energy module. Code for energy calibration and
2
correction. Mostly ported from https://github.com/mpes-kit/mpes.
3
"""
4
from __future__ import annotations
1✔
5

6
import itertools as it
1✔
7
from collections.abc import Sequence
1✔
8
from copy import deepcopy
1✔
9
from datetime import datetime
1✔
10
from functools import partial
1✔
11
from typing import Any
1✔
12
from typing import cast
1✔
13
from typing import Literal
1✔
14

15
import bokeh.plotting as pbk
1✔
16
import dask.dataframe
1✔
17
import h5py
1✔
18
import ipywidgets as ipw
1✔
19
import matplotlib
1✔
20
import matplotlib.pyplot as plt
1✔
21
import numpy as np
1✔
22
import pandas as pd
1✔
23
import psutil
1✔
24
import xarray as xr
1✔
25
from bokeh.io import output_notebook
1✔
26
from bokeh.palettes import Category10 as ColorCycle
1✔
27
from fastdtw import fastdtw
1✔
28
from IPython.display import display
1✔
29
from lmfit import Minimizer
1✔
30
from lmfit import Parameters
1✔
31
from lmfit.printfuncs import report_fit
1✔
32
from numpy.linalg import lstsq
1✔
33
from scipy.signal import savgol_filter
1✔
34
from scipy.sparse.linalg import lsqr
1✔
35

36
from sed.binning import bin_dataframe
1✔
37
from sed.core import dfops
1✔
38
from sed.loader.base.loader import BaseLoader
1✔
39

40

41
class EnergyCalibrator:
1✔
42
    """Electron binding energy calibration workflow.
43

44
    For the initialization of the EnergyCalibrator class an instance of a
45
    loader is required. The data can be loaded using the optional arguments,
46
    or using the load_data method or bin_data method.
47

48
    Args:
49
        loader (BaseLoader): Instance of a loader, subclassed from BaseLoader.
50
        biases (np.ndarray, optional): Bias voltages used. Defaults to None.
51
        traces (np.ndarray, optional): TOF-Data traces corresponding to the bias
52
            values. Defaults to None.
53
        tof (np.ndarray, optional): TOF-values for the data traces.
54
            Defaults to None.
55
        config (dict, optional): Config dictionary. Defaults to None.
56
    """
57

58
    def __init__(
1✔
59
        self,
60
        loader: BaseLoader,
61
        biases: np.ndarray = None,
62
        traces: np.ndarray = None,
63
        tof: np.ndarray = None,
64
        config: dict = None,
65
    ):
66
        """For the initialization of the EnergyCalibrator class an instance of a
67
        loader is required. The data can be loaded using the optional arguments,
68
        or using the load_data method or bin_data method.
69

70
        Args:
71
            loader (BaseLoader): Instance of a loader, subclassed from BaseLoader.
72
            biases (np.ndarray, optional): Bias voltages used. Defaults to None.
73
            traces (np.ndarray, optional): TOF-Data traces corresponding to the bias
74
                values. Defaults to None.
75
            tof (np.ndarray, optional): TOF-values for the data traces.
76
                Defaults to None.
77
            config (dict, optional): Config dictionary. Defaults to None.
78
        """
79
        self.loader = loader
1✔
80
        self.biases: np.ndarray = None
1✔
81
        self.traces: np.ndarray = None
1✔
82
        self.traces_normed: np.ndarray = None
1✔
83
        self.tof: np.ndarray = None
1✔
84

85
        if traces is not None and tof is not None and biases is not None:
1✔
86
            self.load_data(biases=biases, traces=traces, tof=tof)
×
87

88
        if config is None:
1✔
89
            config = {}
×
90

91
        self._config = config
1✔
92

93
        self.featranges: list[tuple] = []  # Value ranges for feature detection
1✔
94
        self.peaks: np.ndarray = np.asarray([])
1✔
95
        self.calibration: dict[str, Any] = self._config["energy"].get("calibration", {})
1✔
96

97
        self.tof_column = self._config["dataframe"]["tof_column"]
1✔
98
        self.tof_ns_column = self._config["dataframe"].get("tof_ns_column", None)
1✔
99
        self.corrected_tof_column = self._config["dataframe"]["corrected_tof_column"]
1✔
100
        self.energy_column = self._config["dataframe"]["energy_column"]
1✔
101
        self.x_column = self._config["dataframe"]["x_column"]
1✔
102
        self.y_column = self._config["dataframe"]["y_column"]
1✔
103
        self.binwidth: float = self._config["dataframe"]["tof_binwidth"]
1✔
104
        self.binning: int = self._config["dataframe"]["tof_binning"]
1✔
105
        self.x_width = self._config["energy"]["x_width"]
1✔
106
        self.y_width = self._config["energy"]["y_width"]
1✔
107
        self.tof_width = np.asarray(
1✔
108
            self._config["energy"]["tof_width"],
109
        ) / 2 ** (self.binning - 1)
110
        self.tof_fermi = self._config["energy"]["tof_fermi"] / 2 ** (self.binning - 1)
1✔
111
        self.color_clip = self._config["energy"]["color_clip"]
1✔
112
        self.sector_delays = self._config["dataframe"].get("sector_delays", None)
1✔
113
        self.sector_id_column = self._config["dataframe"].get("sector_id_column", None)
1✔
114
        self.offsets: dict[str, Any] = self._config["energy"].get("offsets", {})
1✔
115
        self.correction: dict[str, Any] = self._config["energy"].get("correction", {})
1✔
116

117
    @property
1✔
118
    def ntraces(self) -> int:
1✔
119
        """Property returning the number of traces.
120

121
        Returns:
122
            int: The number of loaded/calculated traces.
123
        """
124
        return len(self.traces)
1✔
125

126
    @property
1✔
127
    def nranges(self) -> int:
1✔
128
        """Property returning the number of specified feature ranges which Can be a
129
        multiple of ntraces.
130

131
        Returns:
132
            int: The number of specified feature ranges.
133
        """
134
        return len(self.featranges)
1✔
135

136
    @property
1✔
137
    def dup(self) -> int:
1✔
138
        """Property returning the duplication number, i.e. the number of feature
139
        ranges per trace.
140

141
        Returns:
142
            int: The duplication number.
143
        """
144
        return int(np.round(self.nranges / self.ntraces))
1✔
145

146
    def load_data(
1✔
147
        self,
148
        biases: np.ndarray = None,
149
        traces: np.ndarray = None,
150
        tof: np.ndarray = None,
151
    ):
152
        """Load data into the class. Not provided parameters will be overwritten by
153
        empty arrays.
154

155
        Args:
156
            biases (np.ndarray, optional): Bias voltages used. Defaults to None.
157
            traces (np.ndarray, optional): TOF-Data traces corresponding to the bias
158
                values. Defaults to None.
159
            tof (np.ndarray, optional): TOF-values for the data traces.
160
                Defaults to None.
161
        """
162
        if biases is not None:
1✔
163
            self.biases = biases
1✔
164
        else:
165
            self.biases = np.asarray([])
×
166
        if tof is not None:
1✔
167
            self.tof = tof
1✔
168
        else:
169
            self.tof = np.asarray([])
×
170
        if traces is not None:
1✔
171
            self.traces = self.traces_normed = traces
1✔
172
        else:
173
            self.traces = self.traces_normed = np.asarray([])
×
174

175
    def bin_data(
1✔
176
        self,
177
        data_files: list[str],
178
        axes: list[str] = None,
179
        bins: list[int] = None,
180
        ranges: Sequence[tuple[float, float]] = None,
181
        biases: np.ndarray = None,
182
        bias_key: str = None,
183
        **kwds,
184
    ):
185
        """Bin data from single-event files, and load into class.
186

187
        Args:
188
            data_files (list[str]): list of file names to bin
189
            axes (list[str], optional): bin axes. Defaults to
190
                config["dataframe"]["tof_column"].
191
            bins (list[int], optional): number of bins.
192
                Defaults to config["energy"]["bins"].
193
            ranges (Sequence[tuple[float, float]], optional): bin ranges.
194
                Defaults to config["energy"]["ranges"].
195
            biases (np.ndarray, optional): Bias voltages used.
196
                If not provided, biases are extracted from the file meta data.
197
            bias_key (str, optional): hdf5 path where bias values are stored.
198
                Defaults to config["energy"]["bias_key"].
199
            **kwds: Keyword parameters for bin_dataframe
200
        """
201
        if axes is None:
1✔
202
            axes = [self.tof_column]
1✔
203
        if bins is None:
1✔
204
            bins = [self._config["energy"]["bins"]]
1✔
205
        if ranges is None:
1✔
206
            ranges_ = [
1✔
207
                np.array(self._config["energy"]["ranges"]) / 2 ** (self.binning - 1),
208
            ]
209
            ranges = [cast(tuple[float, float], tuple(v)) for v in ranges_]
1✔
210
        # pylint: disable=duplicate-code
211
        hist_mode = kwds.pop("hist_mode", self._config["binning"]["hist_mode"])
1✔
212
        mode = kwds.pop("mode", self._config["binning"]["mode"])
1✔
213
        pbar = kwds.pop("pbar", self._config["binning"]["pbar"])
1✔
214
        try:
1✔
215
            num_cores = kwds.pop("num_cores", self._config["binning"]["num_cores"])
1✔
216
        except KeyError:
1✔
217
            num_cores = psutil.cpu_count() - 1
1✔
218
        threads_per_worker = kwds.pop(
1✔
219
            "threads_per_worker",
220
            self._config["binning"]["threads_per_worker"],
221
        )
222
        threadpool_api = kwds.pop(
1✔
223
            "threadpool_API",
224
            self._config["binning"]["threadpool_API"],
225
        )
226

227
        read_biases = False
1✔
228
        if biases is None:
1✔
229
            read_biases = True
1✔
230
            if bias_key is None:
1✔
231
                try:
1✔
232
                    bias_key = self._config["energy"]["bias_key"]
1✔
233
                except KeyError as exc:
1✔
234
                    raise ValueError(
1✔
235
                        "Either Bias Values or a valid bias_key has to be present!",
236
                    ) from exc
237

238
        dataframe, _, _ = self.loader.read_dataframe(
1✔
239
            files=data_files,
240
            collect_metadata=False,
241
        )
242
        traces = bin_dataframe(
1✔
243
            dataframe,
244
            bins=bins,
245
            axes=axes,
246
            ranges=ranges,
247
            hist_mode=hist_mode,
248
            mode=mode,
249
            pbar=pbar,
250
            n_cores=num_cores,
251
            threads_per_worker=threads_per_worker,
252
            threadpool_api=threadpool_api,
253
            return_partitions=True,
254
            **kwds,
255
        )
256
        if read_biases:
1✔
257
            if bias_key:
1✔
258
                try:
1✔
259
                    biases = extract_bias(data_files, bias_key)
1✔
260
                except KeyError as exc:
1✔
261
                    raise ValueError(
1✔
262
                        "Either Bias Values or a valid bias_key has to be present!",
263
                    ) from exc
264
        tof = traces.coords[(axes[0])]
1✔
265
        self.traces = self.traces_normed = np.asarray(traces.T)
1✔
266
        self.tof = np.asarray(tof)
1✔
267
        self.biases = np.asarray(biases)
1✔
268

269
    def normalize(self, smooth: bool = False, span: int = 7, order: int = 1):
1✔
270
        """Normalize the spectra along an axis.
271

272
        Args:
273
            smooth (bool, optional): Option to smooth the signals before normalization.
274
                Defaults to False.
275
            span (int, optional): span smoothing parameters of the LOESS method
276
                (see ``scipy.signal.savgol_filter()``). Defaults to 7.
277
            order (int, optional): order smoothing parameters of the LOESS method
278
                (see ``scipy.signal.savgol_filter()``). Defaults to 1.
279
        """
280
        self.traces_normed = normspec(
1✔
281
            self.traces,
282
            smooth=smooth,
283
            span=span,
284
            order=order,
285
        )
286

287
    def adjust_ranges(
1✔
288
        self,
289
        ranges: tuple,
290
        ref_id: int = 0,
291
        traces: np.ndarray = None,
292
        peak_window: int = 7,
293
        apply: bool = False,
294
        **kwds,
295
    ):
296
        """Display a tool to select or extract the equivalent feature ranges
297
        (containing the peaks) among all traces.
298

299
        Args:
300
            ranges (tuple):
301
                Collection of feature detection ranges, within which an algorithm
302
                (i.e. 1D peak detector) with look for the feature.
303
            ref_id (int, optional): Index of the reference trace. Defaults to 0.
304
            traces (np.ndarray, optional): Collection of energy dispersion curves.
305
                Defaults to self.traces_normed.
306
            peak_window (int, optional): area around a peak to check for other peaks.
307
                Defaults to 7.
308
            apply (bool, optional): Option to directly apply the provided parameters.
309
                Defaults to False.
310
            **kwds:
311
                keyword arguments for trace alignment (see ``find_correspondence()``).
312
        """
313
        if traces is None:
1✔
314
            traces = self.traces_normed
1✔
315

316
        self.add_ranges(
1✔
317
            ranges=ranges,
318
            ref_id=ref_id,
319
            traces=traces,
320
            infer_others=True,
321
            mode="replace",
322
        )
323
        self.feature_extract(peak_window=peak_window)
1✔
324

325
        # make plot
326
        labels = kwds.pop("labels", [str(b) + " V" for b in self.biases])
1✔
327
        figsize = kwds.pop("figsize", (8, 4))
1✔
328
        plot_segs = []
1✔
329
        plot_peaks = []
1✔
330
        fig, ax = plt.subplots(figsize=figsize)
1✔
331
        colors = plt.get_cmap("rainbow")(np.linspace(0, 1, len(traces)))
1✔
332
        for itr, color in zip(range(len(traces)), colors):
1✔
333
            trace = traces[itr, :]
1✔
334
            # main traces
335
            ax.plot(
1✔
336
                self.tof,
337
                trace,
338
                ls="-",
339
                color=color,
340
                linewidth=1,
341
                label=labels[itr],
342
            )
343
            # segments:
344
            seg = self.featranges[itr]
1✔
345
            cond = (self.tof >= seg[0]) & (self.tof <= seg[1])
1✔
346
            tofseg, traceseg = self.tof[cond], trace[cond]
1✔
347
            (line,) = ax.plot(
1✔
348
                tofseg,
349
                traceseg,
350
                ls="-",
351
                color=color,
352
                linewidth=3,
353
            )
354
            plot_segs.append(line)
1✔
355
            # markers
356
            (scatt,) = ax.plot(
1✔
357
                self.peaks[itr, 0],
358
                self.peaks[itr, 1],
359
                ls="",
360
                marker=".",
361
                color="k",
362
                markersize=10,
363
            )
364
            plot_peaks.append(scatt)
1✔
365
        ax.legend(fontsize=8, loc="upper right")
1✔
366
        ax.set_title("")
1✔
367

368
        def update(refid, ranges):
1✔
369
            self.add_ranges(ranges, refid, traces=traces)
1✔
370
            self.feature_extract(peak_window=7)
1✔
371
            for itr, _ in enumerate(self.traces_normed):
1✔
372
                seg = self.featranges[itr]
1✔
373
                cond = (self.tof >= seg[0]) & (self.tof <= seg[1])
1✔
374
                tofseg, traceseg = (
1✔
375
                    self.tof[cond],
376
                    self.traces_normed[itr][cond],
377
                )
378
                plot_segs[itr].set_ydata(traceseg)
1✔
379
                plot_segs[itr].set_xdata(tofseg)
1✔
380

381
                plot_peaks[itr].set_xdata([self.peaks[itr, 0]])
1✔
382
                plot_peaks[itr].set_ydata([self.peaks[itr, 1]])
1✔
383

384
            fig.canvas.draw_idle()
1✔
385

386
        refid_slider = ipw.IntSlider(
1✔
387
            value=ref_id,
388
            min=0,
389
            max=10,
390
            step=1,
391
        )
392

393
        ranges_slider = ipw.IntRangeSlider(
1✔
394
            value=list(ranges),
395
            min=min(self.tof),
396
            max=max(self.tof),
397
            step=1,
398
        )
399

400
        update(ranges=ranges, refid=ref_id)
1✔
401

402
        ipw.interact(
1✔
403
            update,
404
            refid=refid_slider,
405
            ranges=ranges_slider,
406
        )
407

408
        def apply_func(apply: bool):  # noqa: ARG001
1✔
409
            self.add_ranges(
1✔
410
                ranges_slider.value,
411
                refid_slider.value,
412
                traces=self.traces_normed,
413
            )
414
            self.feature_extract(peak_window=7)
1✔
415
            ranges_slider.close()
1✔
416
            refid_slider.close()
1✔
417
            apply_button.close()
1✔
418

419
        apply_button = ipw.Button(description="apply")
1✔
420
        display(apply_button)  # pylint: disable=duplicate-code
1✔
421
        apply_button.on_click(apply_func)
1✔
422
        plt.show()
1✔
423

424
        if apply:
1✔
425
            apply_func(True)
1✔
426

427
    def add_ranges(
1✔
428
        self,
429
        ranges: list[tuple] | tuple,
430
        ref_id: int = 0,
431
        traces: np.ndarray = None,
432
        infer_others: bool = True,
433
        mode: str = "replace",
434
        **kwds,
435
    ):
436
        """Select or extract the equivalent feature ranges (containing the peaks) among all traces.
437

438
        Args:
439
            ranges (list[tuple] | tuple):
440
                Collection of feature detection ranges, within which an algorithm
441
                (i.e. 1D peak detector) with look for the feature.
442
            ref_id (int, optional): Index of the reference trace. Defaults to 0.
443
            traces (np.ndarray, optional): Collection of energy dispersion curves.
444
                Defaults to self.traces_normed.
445
            infer_others (bool, optional): Option to infer the feature detection range
446
                in other traces from a given one using a time warp algorithm.
447
                Defaults to True.
448
            mode (str, optional): Specification on how to change the feature ranges
449
                ('append' or 'replace'). Defaults to "replace".
450
            **kwds:
451
                keyword arguments for trace alignment (see ``find_correspondence()``).
452
        """
453
        if traces is None:
1✔
454
            traces = self.traces_normed
1✔
455

456
        # Infer the corresponding feature detection range of other traces by alignment
457
        if infer_others:
1✔
458
            assert isinstance(ranges, tuple)
1✔
459
            newranges: list[tuple] = []
1✔
460

461
            for i in range(self.ntraces):
1✔
462
                pathcorr = find_correspondence(
1✔
463
                    traces[ref_id, :],
464
                    traces[i, :],
465
                    **kwds,
466
                )
467
                newranges.append(range_convert(self.tof, ranges, pathcorr))
1✔
468

469
        else:
470
            if isinstance(ranges, list):
1✔
471
                newranges = ranges
1✔
472
            else:
473
                newranges = [ranges]
×
474

475
        if mode == "append":
1✔
476
            self.featranges += newranges
×
477
        elif mode == "replace":
1✔
478
            self.featranges = newranges
1✔
479

480
    def feature_extract(
1✔
481
        self,
482
        ranges: list[tuple] = None,
483
        traces: np.ndarray = None,
484
        peak_window: int = 7,
485
    ):
486
        """Select or extract the equivalent landmarks (e.g. peaks) among all traces.
487

488
        Args:
489
            ranges (list[tuple], optional):  List of ranges in each trace to look for
490
                the peak feature, [start, end]. Defaults to self.featranges.
491
            traces (np.ndarray, optional): Collection of 1D spectra to use for
492
                calibration. Defaults to self.traces_normed.
493
            peak_window (int, optional): area around a peak to check for other peaks.
494
                Defaults to 7.
495
        """
496
        if ranges is None:
1✔
497
            ranges = self.featranges
1✔
498

499
        if traces is None:
1✔
500
            traces = self.traces_normed
1✔
501

502
        # Augment the content of the calibration data
503
        traces_aug = np.tile(traces, (self.dup, 1))
1✔
504
        # Run peak detection for each trace within the specified ranges
505
        self.peaks = peaksearch(
1✔
506
            traces_aug,
507
            self.tof,
508
            ranges=ranges,
509
            pkwindow=peak_window,
510
        )
511

512
    def calibrate(
1✔
513
        self,
514
        ref_energy: float = 0,
515
        method: str = "lmfit",
516
        energy_scale: str = "kinetic",
517
        landmarks: np.ndarray = None,
518
        biases: np.ndarray = None,
519
        t: np.ndarray = None,
520
        verbose: bool = True,
521
        **kwds,
522
    ) -> dict:
523
        """Calculate the functional mapping between time-of-flight and the energy
524
        scale using optimization methods.
525

526
        Args:
527
            ref_energy (float): Binding/kinetic energy of the detected feature.
528
            method (str, optional):  Method for determining the energy calibration.
529

530
                - **'lmfit'**: Energy calibration using lmfit and 1/t^2 form.
531
                - **'lstsq'**, **'lsqr'**: Energy calibration using polynomial form.
532

533
                Defaults to 'lmfit'.
534
            energy_scale (str, optional): Direction of increasing energy scale.
535

536
                - **'kinetic'**: increasing energy with decreasing TOF.
537
                - **'binding'**: increasing energy with increasing TOF.
538

539
                Defaults to "kinetic".
540
            landmarks (np.ndarray, optional): Extracted peak positions (TOF) used for
541
                calibration. Defaults to self.peaks.
542
            biases (np.ndarray, optional): Bias values. Defaults to self.biases.
543
            t (np.ndarray, optional): TOF values. Defaults to self.tof.
544
            verbose (bool, optional): Option to print out diagnostic information.
545
                Defaults to True.
546
            **kwds: keyword arguments.
547
                See available keywords for ``poly_energy_calibration()`` and
548
                ``fit_energy_calibration()``
549

550
        Raises:
551
            ValueError: Raised if invalid 'energy_scale' is passed.
552
            NotImplementedError: Raised if invalid 'method' is passed.
553

554
        Returns:
555
            dict: Calibration dictionary with coefficients.
556
        """
557
        if landmarks is None:
1✔
558
            landmarks = self.peaks[:, 0]
1✔
559
        if biases is None:
1✔
560
            biases = self.biases
1✔
561
        if t is None:
1✔
562
            t = self.tof
1✔
563
        if energy_scale == "kinetic":
1✔
564
            sign = -1
1✔
565
        elif energy_scale == "binding":
1✔
566
            sign = 1
1✔
567
        else:
568
            raise ValueError(
1✔
569
                'energy_scale needs to be either "binding" or "kinetic"',
570
                f", got {energy_scale}.",
571
            )
572

573
        binwidth = kwds.pop("binwidth", self.binwidth)
1✔
574
        binning = kwds.pop("binning", self.binning)
1✔
575

576
        if method == "lmfit":
1✔
577
            self.calibration = fit_energy_calibration(
1✔
578
                landmarks,
579
                sign * biases,
580
                binwidth,
581
                binning,
582
                ref_energy=ref_energy,
583
                t=t,
584
                energy_scale=energy_scale,
585
                verbose=verbose,
586
                **kwds,
587
            )
588
        elif method in ("lstsq", "lsqr"):
1✔
589
            self.calibration = poly_energy_calibration(
1✔
590
                landmarks,
591
                sign * biases,
592
                ref_energy=ref_energy,
593
                aug=self.dup,
594
                method=method,
595
                t=t,
596
                energy_scale=energy_scale,
597
                **kwds,
598
            )
599
        else:
600
            raise NotImplementedError()
1✔
601

602
        self.calibration["creation_date"] = datetime.now().timestamp()
1✔
603
        return self.calibration
1✔
604

605
    def view(  # pylint: disable=dangerous-default-value
1✔
606
        self,
607
        traces: np.ndarray,
608
        segs: list[tuple] = None,
609
        peaks: np.ndarray = None,
610
        show_legend: bool = True,
611
        backend: str = "matplotlib",
612
        linekwds: dict = {},
613
        linesegkwds: dict = {},
614
        scatterkwds: dict = {},
615
        legkwds: dict = {},
616
        **kwds,
617
    ):
618
        """Display a plot showing line traces with annotation.
619

620
        Args:
621
            traces (np.ndarray): Matrix of traces to visualize.
622
            segs (list[tuple], optional): Segments to be highlighted in the
623
                visualization. Defaults to None.
624
            peaks (np.ndarray, optional): Peak positions for labelling the traces.
625
                Defaults to None.
626
            show_legend (bool, optional): Option to display bias voltages as legends.
627
                Defaults to True.
628
            backend (str, optional): Backend specification, choose between 'matplotlib'
629
                (static) or 'bokeh' (interactive). Defaults to "matplotlib".
630
            linekwds (dict, optional): Keyword arguments for line plotting
631
                (see ``matplotlib.pyplot.plot()``). Defaults to {}.
632
            linesegkwds (dict, optional): Keyword arguments for line segments plotting
633
                (see ``matplotlib.pyplot.plot()``). Defaults to {}.
634
            scatterkwds (dict, optional): Keyword arguments for scatter plot
635
                (see ``matplotlib.pyplot.scatter()``). Defaults to {}.
636
            legkwds (dict, optional): Keyword arguments for legend
637
                (see ``matplotlib.pyplot.legend()``). Defaults to {}.
638
            **kwds: keyword arguments:
639

640
                - **labels** (list): Labels for each curve
641
                - **xaxis** (np.ndarray): x (horizontal) axis values
642
                - **title** (str): Title of the plot
643
                - **legend_location** (str): Location of the plot legend
644
                - **align** (bool): Option to shift traces by bias voltage
645
        """
646
        lbs = kwds.pop("labels", [str(b) + " V" for b in self.biases])
1✔
647
        xaxis = kwds.pop("xaxis", self.tof)
1✔
648
        ttl = kwds.pop("title", "")
1✔
649
        align = kwds.pop("align", False)
1✔
650
        energy_scale = kwds.pop("energy_scale", "kinetic")
1✔
651

652
        sign = 1 if energy_scale == "kinetic" else -1
1✔
653

654
        if backend == "matplotlib":
1✔
655
            figsize = kwds.pop("figsize", (12, 4))
1✔
656
            fig_plt, ax = plt.subplots(figsize=figsize)
1✔
657
            for itr, trace in enumerate(traces):
1✔
658
                if align:
1✔
659
                    ax.plot(
×
660
                        xaxis + sign * (self.biases[itr]),
661
                        trace,
662
                        ls="-",
663
                        linewidth=1,
664
                        label=lbs[itr],
665
                        **linekwds,
666
                    )
667
                else:
668
                    ax.plot(
1✔
669
                        xaxis,
670
                        trace,
671
                        ls="-",
672
                        linewidth=1,
673
                        label=lbs[itr],
674
                        **linekwds,
675
                    )
676

677
                # Emphasize selected EDC segments
678
                if segs is not None:
1✔
679
                    seg = segs[itr]
×
680
                    cond = (self.tof >= seg[0]) & (self.tof <= seg[1])
×
681
                    tofseg, traceseg = self.tof[cond], trace[cond]
×
682
                    ax.plot(
×
683
                        tofseg,
684
                        traceseg,
685
                        ls="-",
686
                        linewidth=2,
687
                        **linesegkwds,
688
                    )
689
                # Emphasize extracted local maxima
690
                if peaks is not None:
1✔
691
                    ax.scatter(
×
692
                        peaks[itr, 0],
693
                        peaks[itr, 1],
694
                        s=30,
695
                        **scatterkwds,
696
                    )
697

698
            if show_legend:
1✔
699
                try:
×
700
                    ax.legend(fontsize=12, **legkwds)
×
701
                except TypeError:
×
702
                    pass
×
703

704
            ax.set_title(ttl)
1✔
705

706
        elif backend == "bokeh":
1✔
707
            output_notebook(hide_banner=True)
1✔
708
            colors = it.cycle(ColorCycle[10])
1✔
709
            ttp = [("(x, y)", "($x, $y)")]
1✔
710

711
            figsize = kwds.pop("figsize", (800, 300))
1✔
712
            fig = pbk.figure(
1✔
713
                title=ttl,
714
                width=figsize[0],
715
                height=figsize[1],
716
                tooltips=ttp,
717
            )
718
            # Plotting the main traces
719
            for itr, color in zip(range(len(traces)), colors):
1✔
720
                trace = traces[itr, :]
1✔
721
                if align:
1✔
722
                    fig.line(
1✔
723
                        xaxis + sign * (self.biases[itr]),
724
                        trace,
725
                        color=color,
726
                        line_dash="solid",
727
                        line_width=1,
728
                        line_alpha=1,
729
                        legend_label=lbs[itr],
730
                        **kwds,
731
                    )
732
                else:
733
                    fig.line(
1✔
734
                        xaxis,
735
                        trace,
736
                        color=color,
737
                        line_dash="solid",
738
                        line_width=1,
739
                        line_alpha=1,
740
                        legend_label=lbs[itr],
741
                        **kwds,
742
                    )
743

744
                # Emphasize selected EDC segments
745
                if segs is not None:
1✔
746
                    seg = segs[itr]
1✔
747
                    cond = (self.tof >= seg[0]) & (self.tof <= seg[1])
1✔
748
                    tofseg, traceseg = self.tof[cond], trace[cond]
1✔
749
                    fig.line(
1✔
750
                        tofseg,
751
                        traceseg,
752
                        color=color,
753
                        line_width=3,
754
                        **linekwds,
755
                    )
756

757
                # Plot detected peaks
758
                if peaks is not None:
1✔
759
                    fig.scatter(
1✔
760
                        peaks[itr, 0],
761
                        peaks[itr, 1],
762
                        fill_color=color,
763
                        fill_alpha=0.8,
764
                        line_color=None,
765
                        size=5,
766
                        **scatterkwds,
767
                    )
768

769
            if show_legend:
1✔
770
                fig.legend.location = kwds.pop("legend_location", "top_right")
1✔
771
                fig.legend.spacing = 0
1✔
772
                fig.legend.padding = 2
1✔
773

774
            pbk.show(fig)
1✔
775

776
    def append_energy_axis(
1✔
777
        self,
778
        df: pd.DataFrame | dask.dataframe.DataFrame,
779
        tof_column: str = None,
780
        energy_column: str = None,
781
        calibration: dict = None,
782
        bias_voltage: float = None,
783
        verbose: bool = True,
784
        **kwds,
785
    ) -> tuple[pd.DataFrame | dask.dataframe.DataFrame, dict]:
786
        """Calculate and append the energy axis to the events dataframe.
787

788
        Args:
789
            df (pd.DataFrame | dask.dataframe.DataFrame):
790
                Dataframe to apply the energy axis calibration to.
791
            tof_column (str, optional): Label of the source column.
792
                Defaults to config["dataframe"]["tof_column"].
793
            energy_column (str, optional): Label of the destination column.
794
                Defaults to config["dataframe"]["energy_column"].
795
            calibration (dict, optional): Calibration dictionary. If provided,
796
                overrides calibration from class or config.
797
                Defaults to self.calibration or config["energy"]["calibration"].
798
            bias_voltage (float, optional): Sample bias voltage of the scan data. If omitted,
799
                the bias voltage is being read from the dataframe. If it is not found there,
800
                a warning is printed and the calibrated data might have an offset.
801
            verbose (bool, optional): Option to print out diagnostic information.
802
                Defaults to True.
803
            **kwds: additional keyword arguments for the energy conversion. They are
804
                added to the calibration dictionary.
805

806
        Raises:
807
            ValueError: Raised if expected calibration parameters are missing.
808
            NotImplementedError: Raised if an invalid calib_type is found.
809

810
        Returns:
811
            tuple[pd.DataFrame | dask.dataframe.DataFrame, dict]: dataframe with added column
812
            and energy calibration metadata dictionary.
813
        """
814
        if tof_column is None:
1✔
815
            if self.corrected_tof_column in df.columns:
1✔
816
                tof_column = self.corrected_tof_column
×
817
            else:
818
                tof_column = self.tof_column
1✔
819

820
        if energy_column is None:
1✔
821
            energy_column = self.energy_column
1✔
822

823
        binwidth = kwds.pop("binwidth", self.binwidth)
1✔
824
        binning = kwds.pop("binning", self.binning)
1✔
825

826
        # pylint: disable=duplicate-code
827
        if calibration is None:
1✔
828
            calibration = deepcopy(self.calibration)
1✔
829

830
        if len(kwds) > 0:
1✔
831
            for key, value in kwds.items():
1✔
832
                calibration[key] = value
1✔
833
            calibration["creation_date"] = datetime.now().timestamp()
1✔
834

835
        elif "creation_date" in calibration and verbose:
1✔
836
            datestring = datetime.fromtimestamp(calibration["creation_date"]).strftime(
1✔
837
                "%m/%d/%Y, %H:%M:%S",
838
            )
839
            print(f"Using energy calibration parameters generated on {datestring}")
1✔
840

841
        # try to determine calibration type if not provided
842
        if "calib_type" not in calibration:
1✔
843
            if "t0" in calibration and "d" in calibration and "E0" in calibration:
1✔
844
                calibration["calib_type"] = "fit"
1✔
845
                if "energy_scale" not in calibration:
1✔
846
                    calibration["energy_scale"] = "kinetic"
1✔
847

848
            elif "coeffs" in calibration and "E0" in calibration:
1✔
849
                calibration["calib_type"] = "poly"
1✔
850
                if "energy_scale" not in calibration:
1✔
851
                    calibration["energy_scale"] = "kinetic"
1✔
852
            else:
853
                raise ValueError("No valid calibration parameters provided!")
1✔
854

855
        if calibration["calib_type"] == "fit":
1✔
856
            # Fitting metadata for nexus
857
            calibration["fit_function"] = "(a0/(x0-a1))**2 + a2"
1✔
858
            calibration["coefficients"] = np.array(
1✔
859
                [
860
                    calibration["d"],
861
                    calibration["t0"],
862
                    calibration["E0"],
863
                ],
864
            )
865
            df[energy_column] = tof2ev(
1✔
866
                calibration["d"],
867
                calibration["t0"],
868
                binwidth,
869
                binning,
870
                calibration["energy_scale"],
871
                calibration["E0"],
872
                df[tof_column].astype("float64"),
873
            )
874
        elif calibration["calib_type"] == "poly":
1✔
875
            # Fitting metadata for nexus
876
            fit_function = "a0"
1✔
877
            for term in range(1, len(calibration["coeffs"]) + 1):
1✔
878
                fit_function += f" + a{term}*x0**{term}"
1✔
879
            calibration["fit_function"] = fit_function
1✔
880
            calibration["coefficients"] = np.concatenate(
1✔
881
                (calibration["coeffs"], [calibration["E0"]]),
882
            )[::-1]
883
            df[energy_column] = tof2evpoly(
1✔
884
                calibration["coeffs"],
885
                calibration["E0"],
886
                df[tof_column].astype("float64"),
887
            )
888
        else:
889
            raise NotImplementedError
1✔
890

891
        # apply bias offset
892
        scale_sign: Literal[-1, 1] = -1 if calibration["energy_scale"] == "binding" else 1
1✔
893
        if bias_voltage is not None:
1✔
NEW
894
            df[energy_column] = df[energy_column] + scale_sign * bias_voltage
×
895
        elif self._config["dataframe"]["bias_column"] in df.columns:
1✔
896
            df = dfops.offset_by_other_columns(
1✔
897
                df=df,
898
                target_column=energy_column,
899
                offset_columns=self._config["dataframe"]["bias_column"],
900
                weights=scale_sign,
901
            )
902
        else:
903
            print("Sample bias data not found or provided. Calibrated energy might be incorrect.")
1✔
904

905
        metadata = self.gather_calibration_metadata(calibration)
1✔
906

907
        return df, metadata
1✔
908

909
    def append_tof_ns_axis(
1✔
910
        self,
911
        df: pd.DataFrame | dask.dataframe.DataFrame,
912
        tof_column: str = None,
913
        tof_ns_column: str = None,
914
        **kwds,
915
    ) -> tuple[pd.DataFrame | dask.dataframe.DataFrame, dict]:
916
        """Converts the time-of-flight time from steps to time in ns.
917

918
        Args:
919
            df (pd.DataFrame | dask.dataframe.DataFrame): Dataframe to convert.
920
            tof_column (str, optional): Name of the column containing the
921
                time-of-flight steps. Defaults to config["dataframe"]["tof_column"].
922
            tof_ns_column (str, optional): Name of the column to store the
923
                time-of-flight in nanoseconds. Defaults to config["dataframe"]["tof_ns_column"].
924
            binwidth (float, optional): Time-of-flight binwidth in ns.
925
                Defaults to config["energy"]["tof_binwidth"].
926
            binning (int, optional): Time-of-flight binning factor.
927
                Defaults to config["energy"]["tof_binning"].
928

929
        Returns:
930
            tuple[pd.DataFrame | dask.dataframe.DataFrame, dict]: Dataframe with the new columns
931
            and Metadata dictionary.
932
        """
933
        binwidth = kwds.pop("binwidth", self.binwidth)
1✔
934
        binning = kwds.pop("binning", self.binning)
1✔
935
        if tof_column is None:
1✔
936
            if self.corrected_tof_column in df.columns:
1✔
937
                tof_column = self.corrected_tof_column
×
938
            else:
939
                tof_column = self.tof_column
1✔
940

941
        if tof_ns_column is None:
1✔
942
            tof_ns_column = self.tof_ns_column
1✔
943

944
        df[tof_ns_column] = tof2ns(
1✔
945
            binwidth,
946
            binning,
947
            df[tof_column].astype("float64"),
948
        )
949
        metadata: dict[str, Any] = {
1✔
950
            "applied": True,
951
            "binwidth": binwidth,
952
            "binning": binning,
953
        }
954
        return df, metadata
1✔
955

956
    def gather_calibration_metadata(self, calibration: dict = None) -> dict:
1✔
957
        """Collects metadata from the energy calibration
958

959
        Args:
960
            calibration (dict, optional): Dictionary with energy calibration
961
                parameters. Defaults to None.
962

963
        Returns:
964
            dict: Generated metadata dictionary.
965
        """
966
        if calibration is None:
1✔
967
            calibration = self.calibration
×
968
        metadata: dict[Any, Any] = {}
1✔
969
        metadata["applied"] = True
1✔
970
        metadata["calibration"] = deepcopy(calibration)
1✔
971
        metadata["tof"] = deepcopy(self.tof)
1✔
972
        # create empty calibrated axis entry, if it is not present.
973
        if "axis" not in metadata["calibration"]:
1✔
974
            metadata["calibration"]["axis"] = 0
1✔
975

976
        return metadata
1✔
977

978
    def adjust_energy_correction(
1✔
979
        self,
980
        image: xr.DataArray,
981
        correction_type: str = None,
982
        amplitude: float = None,
983
        center: tuple[float, float] = None,
984
        correction: dict = None,
985
        apply: bool = False,
986
        **kwds,
987
    ):
988
        """Visualize the energy correction function on top of the TOF/X/Y graphs.
989

990
        Args:
991
            image (xr.DataArray): Image data cube (x, y, tof) of binned data to plot.
992
            correction_type (str, optional): Type of correction to apply to the TOF
993
                axis. Valid values are:
994

995
                - 'spherical'
996
                - 'Lorentzian'
997
                - 'Gaussian'
998
                - 'Lorentzian_asymmetric'
999

1000
                Defaults to config["energy"]["correction_type"].
1001
            amplitude (float, optional): Amplitude of the time-of-flight correction
1002
                term. Defaults to config["energy"]["correction"]["correction_type"].
1003
            center (tuple[float, float], optional): Center (x/y) coordinates for the
1004
                correction. Defaults to config["energy"]["correction"]["center"].
1005
            correction (dict, optional): Correction dict. Defaults to the config values
1006
                and is updated from provided and adjusted parameters.
1007
            apply (bool, optional): whether to store the provided parameters within
1008
                the class. Defaults to False.
1009
            **kwds: Additional parameters to use for the adjustment plots:
1010

1011
                - **x_column** (str): Name of the x column.
1012
                - **y_column** (str): Name of the y column.
1013
                - **tof_column** (str): Name of the tog column to convert.
1014
                - **x_width** (int, int): x range to integrate around the center
1015
                - **y_width** (int, int): y range to integrate around the center
1016
                - **tof_fermi** (int): TOF value of the Fermi level
1017
                - **tof_width** (int, int): TOF range to plot around tof_fermi
1018
                - **color_clip** (int): highest value to plot in the color range
1019

1020
                Additional parameters for the correction functions:
1021

1022
                - **d** (float): Field-free drift distance.
1023
                - **gamma** (float): Linewidth value for correction using a 2D
1024
                  Lorentz profile.
1025
                - **sigma** (float): Standard deviation for correction using a 2D
1026
                  Gaussian profile.
1027
                - **gamma2** (float): Linewidth value for correction using an
1028
                  asymmetric 2D Lorentz profile, X-direction.
1029
                - **amplitude2** (float): Amplitude value for correction using an
1030
                  asymmetric 2D Lorentz profile, X-direction.
1031

1032
        Raises:
1033
            NotImplementedError: Raised for invalid correction_type.
1034
        """
1035
        matplotlib.use("module://ipympl.backend_nbagg")
1✔
1036

1037
        if correction is None:
1✔
1038
            correction = deepcopy(self.correction)
1✔
1039

1040
        if correction_type is not None:
1✔
1041
            correction["correction_type"] = correction_type
1✔
1042

1043
        if amplitude is not None:
1✔
1044
            correction["amplitude"] = amplitude
1✔
1045

1046
        if center is not None:
1✔
1047
            correction["center"] = center
1✔
1048

1049
        x_column = kwds.pop("x_column", self.x_column)
1✔
1050
        y_column = kwds.pop("y_column", self.y_column)
1✔
1051
        tof_column = kwds.pop("tof_column", self.tof_column)
1✔
1052
        x_width = kwds.pop("x_width", self.x_width)
1✔
1053
        y_width = kwds.pop("y_width", self.y_width)
1✔
1054
        tof_fermi = kwds.pop("tof_fermi", self.tof_fermi)
1✔
1055
        tof_width = kwds.pop("tof_width", self.tof_width)
1✔
1056
        color_clip = kwds.pop("color_clip", self.color_clip)
1✔
1057

1058
        correction = {**correction, **kwds}
1✔
1059

1060
        if not {"correction_type", "amplitude", "center"}.issubset(set(correction.keys())):
1✔
1061
            raise ValueError(
1✔
1062
                "No valid energy correction found in config and required parameters missing!",
1063
            )
1064

1065
        if isinstance(correction["center"], list):
1✔
1066
            correction["center"] = tuple(correction["center"])
1✔
1067

1068
        x = image.coords[x_column].values
1✔
1069
        y = image.coords[y_column].values
1✔
1070

1071
        x_center = correction["center"][0]
1✔
1072
        y_center = correction["center"][1]
1✔
1073

1074
        correction_x = tof_fermi - correction_function(
1✔
1075
            x=x,
1076
            y=y_center,
1077
            **correction,
1078
        )
1079
        correction_y = tof_fermi - correction_function(
1✔
1080
            x=x_center,
1081
            y=y,
1082
            **correction,
1083
        )
1084
        fig, ax = plt.subplots(2, 1)
1✔
1085
        image.loc[
1✔
1086
            {
1087
                y_column: slice(y_center + y_width[0], y_center + y_width[1]),
1088
                tof_column: slice(
1089
                    tof_fermi + tof_width[0],
1090
                    tof_fermi + tof_width[1],
1091
                ),
1092
            }
1093
        ].sum(dim=y_column).T.plot(
1094
            ax=ax[0],
1095
            cmap="terrain_r",
1096
            vmax=color_clip,
1097
            yincrease=False,
1098
        )
1099
        image.loc[
1✔
1100
            {
1101
                x_column: slice(x_center + x_width[0], x_center + x_width[1]),
1102
                tof_column: slice(
1103
                    tof_fermi + tof_width[0],
1104
                    tof_fermi + tof_width[1],
1105
                ),
1106
            }
1107
        ].sum(dim=x_column).T.plot(
1108
            ax=ax[1],
1109
            cmap="terrain_r",
1110
            vmax=color_clip,
1111
            yincrease=False,
1112
        )
1113
        (trace1,) = ax[0].plot(x, correction_x)
1✔
1114
        line1 = ax[0].axvline(x=x_center)
1✔
1115
        (trace2,) = ax[1].plot(y, correction_y)
1✔
1116
        line2 = ax[1].axvline(x=y_center)
1✔
1117

1118
        amplitude_slider = ipw.FloatSlider(
1✔
1119
            value=correction["amplitude"],
1120
            min=0,
1121
            max=10,
1122
            step=0.1,
1123
        )
1124
        x_center_slider = ipw.FloatSlider(
1✔
1125
            value=x_center,
1126
            min=0,
1127
            max=self._config["momentum"]["detector_ranges"][0][1],
1128
            step=1,
1129
        )
1130
        y_center_slider = ipw.FloatSlider(
1✔
1131
            value=y_center,
1132
            min=0,
1133
            max=self._config["momentum"]["detector_ranges"][1][1],
1134
            step=1,
1135
        )
1136

1137
        def update(amplitude, x_center, y_center, **kwds):
1✔
1138
            nonlocal correction
1139
            correction["amplitude"] = amplitude
1✔
1140
            correction["center"] = (x_center, y_center)
1✔
1141
            correction = {**correction, **kwds}
1✔
1142
            correction_x = tof_fermi - correction_function(
1✔
1143
                x=x,
1144
                y=y_center,
1145
                **correction,
1146
            )
1147
            correction_y = tof_fermi - correction_function(
1✔
1148
                x=x_center,
1149
                y=y,
1150
                **correction,
1151
            )
1152

1153
            trace1.set_ydata(correction_x)
1✔
1154
            line1.set_xdata([x_center])
1✔
1155
            trace2.set_ydata(correction_y)
1✔
1156
            line2.set_xdata([y_center])
1✔
1157

1158
            fig.canvas.draw_idle()
1✔
1159

1160
        def common_apply_func(apply: bool):  # noqa: ARG001
1✔
1161
            self.correction = {}
1✔
1162
            self.correction["amplitude"] = correction["amplitude"]
1✔
1163
            self.correction["center"] = correction["center"]
1✔
1164
            self.correction["correction_type"] = correction["correction_type"]
1✔
1165
            self.correction["creation_date"] = datetime.now().timestamp()
1✔
1166
            amplitude_slider.close()
1✔
1167
            x_center_slider.close()
1✔
1168
            y_center_slider.close()
1✔
1169
            apply_button.close()
1✔
1170

1171
        if correction["correction_type"] == "spherical":
1✔
1172
            try:
1✔
1173
                update(correction["amplitude"], x_center, y_center, diameter=correction["diameter"])
1✔
1174
            except KeyError as exc:
×
1175
                raise ValueError(
×
1176
                    "Parameter 'diameter' required for correction type 'spherical', ",
1177
                    "but not present!",
1178
                ) from exc
1179

1180
            diameter_slider = ipw.FloatSlider(
1✔
1181
                value=correction["diameter"],
1182
                min=0,
1183
                max=10000,
1184
                step=100,
1185
            )
1186

1187
            ipw.interact(
1✔
1188
                update,
1189
                amplitude=amplitude_slider,
1190
                x_center=x_center_slider,
1191
                y_center=y_center_slider,
1192
                diameter=diameter_slider,
1193
            )
1194

1195
            def apply_func(apply: bool):
1✔
1196
                common_apply_func(apply)
1✔
1197
                self.correction["diameter"] = correction["diameter"]
1✔
1198
                diameter_slider.close()
1✔
1199

1200
        elif correction["correction_type"] == "Lorentzian":
1✔
1201
            try:
1✔
1202
                update(correction["amplitude"], x_center, y_center, gamma=correction["gamma"])
1✔
1203
            except KeyError as exc:
×
1204
                raise ValueError(
×
1205
                    "Parameter 'gamma' required for correction type 'Lorentzian', but not present!",
1206
                ) from exc
1207

1208
            gamma_slider = ipw.FloatSlider(
1✔
1209
                value=correction["gamma"],
1210
                min=0,
1211
                max=2000,
1212
                step=1,
1213
            )
1214

1215
            ipw.interact(
1✔
1216
                update,
1217
                amplitude=amplitude_slider,
1218
                x_center=x_center_slider,
1219
                y_center=y_center_slider,
1220
                gamma=gamma_slider,
1221
            )
1222

1223
            def apply_func(apply: bool):
1✔
1224
                common_apply_func(apply)
1✔
1225
                self.correction["gamma"] = correction["gamma"]
1✔
1226
                gamma_slider.close()
1✔
1227

1228
        elif correction["correction_type"] == "Gaussian":
1✔
1229
            try:
1✔
1230
                update(correction["amplitude"], x_center, y_center, sigma=correction["sigma"])
1✔
1231
            except KeyError as exc:
×
1232
                raise ValueError(
×
1233
                    "Parameter 'sigma' required for correction type 'Gaussian', but not present!",
1234
                ) from exc
1235

1236
            sigma_slider = ipw.FloatSlider(
1✔
1237
                value=correction["sigma"],
1238
                min=0,
1239
                max=1000,
1240
                step=1,
1241
            )
1242

1243
            ipw.interact(
1✔
1244
                update,
1245
                amplitude=amplitude_slider,
1246
                x_center=x_center_slider,
1247
                y_center=y_center_slider,
1248
                sigma=sigma_slider,
1249
            )
1250

1251
            def apply_func(apply: bool):
1✔
1252
                common_apply_func(apply)
1✔
1253
                self.correction["sigma"] = correction["sigma"]
1✔
1254
                sigma_slider.close()
1✔
1255

1256
        elif correction["correction_type"] == "Lorentzian_asymmetric":
1✔
1257
            try:
1✔
1258
                if "amplitude2" not in correction:
1✔
1259
                    correction["amplitude2"] = correction["amplitude"]
1✔
1260
                if "sigma2" not in correction:
1✔
1261
                    correction["gamma2"] = correction["gamma"]
1✔
1262
                update(
1✔
1263
                    correction["amplitude"],
1264
                    x_center,
1265
                    y_center,
1266
                    gamma=correction["gamma"],
1267
                    amplitude2=correction["amplitude2"],
1268
                    gamma2=correction["gamma2"],
1269
                )
1270
            except KeyError as exc:
×
1271
                raise ValueError(
×
1272
                    "Parameter 'gamma' required for correction type 'Lorentzian_asymmetric', ",
1273
                    "but not present!",
1274
                ) from exc
1275

1276
            gamma_slider = ipw.FloatSlider(
1✔
1277
                value=correction["gamma"],
1278
                min=0,
1279
                max=2000,
1280
                step=1,
1281
            )
1282

1283
            amplitude2_slider = ipw.FloatSlider(
1✔
1284
                value=correction["amplitude2"],
1285
                min=0,
1286
                max=10,
1287
                step=0.1,
1288
            )
1289

1290
            gamma2_slider = ipw.FloatSlider(
1✔
1291
                value=correction["gamma2"],
1292
                min=0,
1293
                max=2000,
1294
                step=1,
1295
            )
1296

1297
            ipw.interact(
1✔
1298
                update,
1299
                amplitude=amplitude_slider,
1300
                x_center=x_center_slider,
1301
                y_center=y_center_slider,
1302
                gamma=gamma_slider,
1303
                amplitude2=amplitude2_slider,
1304
                gamma2=gamma2_slider,
1305
            )
1306

1307
            def apply_func(apply: bool):
1✔
1308
                common_apply_func(apply)
1✔
1309
                self.correction["gamma"] = correction["gamma"]
1✔
1310
                self.correction["amplitude2"] = correction["amplitude2"]
1✔
1311
                self.correction["gamma2"] = correction["gamma2"]
1✔
1312
                gamma_slider.close()
1✔
1313
                amplitude2_slider.close()
1✔
1314
                gamma2_slider.close()
1✔
1315

1316
        else:
1317
            raise NotImplementedError
×
1318
        # pylint: disable=duplicate-code
1319
        apply_button = ipw.Button(description="apply")
1✔
1320
        display(apply_button)
1✔
1321
        apply_button.on_click(apply_func)
1✔
1322
        plt.show()
1✔
1323

1324
        if apply:
1✔
1325
            apply_func(True)
1✔
1326

1327
    def apply_energy_correction(
1✔
1328
        self,
1329
        df: pd.DataFrame | dask.dataframe.DataFrame,
1330
        tof_column: str = None,
1331
        new_tof_column: str = None,
1332
        correction_type: str = None,
1333
        amplitude: float = None,
1334
        correction: dict = None,
1335
        verbose: bool = True,
1336
        **kwds,
1337
    ) -> tuple[pd.DataFrame | dask.dataframe.DataFrame, dict]:
1338
        """Apply correction to the time-of-flight (TOF) axis of single-event data.
1339

1340
        Args:
1341
            df (pd.DataFrame | dask.dataframe.DataFrame): The dataframe where
1342
                to apply the energy correction to.
1343
            tof_column (str, optional): Name of the source column to convert.
1344
                Defaults to config["dataframe"]["tof_column"].
1345
            new_tof_column (str, optional): Name of the destination column to convert.
1346
                Defaults to config["dataframe"]["corrected_tof_column"].
1347
            correction_type (str, optional): Type of correction to apply to the TOF
1348
                axis. Valid values are:
1349

1350
                - 'spherical'
1351
                - 'Lorentzian'
1352
                - 'Gaussian'
1353
                - 'Lorentzian_asymmetric'
1354

1355
                Defaults to config["energy"]["correction_type"].
1356
            amplitude (float, optional): Amplitude of the time-of-flight correction
1357
                term. Defaults to config["energy"]["correction"]["correction_type"].
1358
            correction (dict, optional): Correction dictionary containing parameters
1359
                for the correction. Defaults to self.correction or
1360
                config["energy"]["correction"].
1361
            verbose (bool, optional): Option to print out diagnostic information.
1362
                Defaults to True.
1363
            **kwds: Additional parameters to use for the correction:
1364

1365
                - **x_column** (str): Name of the x column.
1366
                - **y_column** (str): Name of the y column.
1367
                - **d** (float): Field-free drift distance.
1368
                - **gamma** (float): Linewidth value for correction using a 2D
1369
                  Lorentz profile.
1370
                - **sigma** (float): Standard deviation for correction using a 2D
1371
                  Gaussian profile.
1372
                - **gamma2** (float): Linewidth value for correction using an
1373
                  asymmetric 2D Lorentz profile, X-direction.
1374
                - **amplitude2** (float): Amplitude value for correction using an
1375
                  asymmetric 2D Lorentz profile, X-direction.
1376

1377
        Returns:
1378
            tuple[pd.DataFrame | dask.dataframe.DataFrame, dict]: dataframe with added column
1379
            and Energy correction metadata dictionary.
1380
        """
1381
        if correction is None:
1✔
1382
            correction = deepcopy(self.correction)
1✔
1383

1384
        x_column = kwds.pop("x_column", self.x_column)
1✔
1385
        y_column = kwds.pop("y_column", self.y_column)
1✔
1386

1387
        if tof_column is None:
1✔
1388
            tof_column = self.tof_column
1✔
1389

1390
        if new_tof_column is None:
1✔
1391
            new_tof_column = self.corrected_tof_column
1✔
1392

1393
        if correction_type is not None or amplitude is not None or len(kwds) > 0:
1✔
1394
            if correction_type is not None:
1✔
1395
                correction["correction_type"] = correction_type
1✔
1396

1397
            if amplitude is not None:
1✔
1398
                correction["amplitude"] = amplitude
1✔
1399

1400
            for key, value in kwds.items():
1✔
1401
                correction[key] = value
1✔
1402

1403
            correction["creation_date"] = datetime.now().timestamp()
1✔
1404

1405
        elif "creation_date" in correction and verbose:
1✔
1406
            datestring = datetime.fromtimestamp(correction["creation_date"]).strftime(
1✔
1407
                "%m/%d/%Y, %H:%M:%S",
1408
            )
1409
            print(f"Using energy correction parameters generated on {datestring}")
1✔
1410

1411
        missing_keys = {"correction_type", "center", "amplitude"} - set(correction.keys())
1✔
1412
        if missing_keys:
1✔
1413
            raise ValueError(f"Required correction parameters '{missing_keys}' missing!")
1✔
1414

1415
        df[new_tof_column] = df[tof_column] + correction_function(
1✔
1416
            x=df[x_column],
1417
            y=df[y_column],
1418
            **correction,
1419
        )
1420
        metadata = self.gather_correction_metadata(correction=correction)
1✔
1421

1422
        return df, metadata
1✔
1423

1424
    def gather_correction_metadata(self, correction: dict = None) -> dict:
1✔
1425
        """Collect meta data for energy correction
1426

1427
        Args:
1428
            correction (dict, optional): Dictionary with energy correction parameters.
1429
                Defaults to None.
1430

1431
        Returns:
1432
            dict: Generated metadata dictionary.
1433
        """
1434
        if correction is None:
1✔
1435
            correction = self.correction
×
1436
        metadata: dict[Any, Any] = {}
1✔
1437
        metadata["applied"] = True
1✔
1438
        metadata["correction"] = deepcopy(correction)
1✔
1439

1440
        return metadata
1✔
1441

1442
    def align_dld_sectors(
1✔
1443
        self,
1444
        df: dask.dataframe.DataFrame,
1445
        tof_column: str = None,
1446
        sector_id_column: str = None,
1447
        sector_delays: np.ndarray = None,
1448
    ) -> tuple[dask.dataframe.DataFrame, dict]:
1449
        """Aligns the time-of-flight axis of the different sections of a detector.
1450

1451
        Args:
1452
            df (dask.dataframe.DataFrame): Dataframe to use.
1453
            tof_column (str, optional): Name of the column containing the time-of-flight values.
1454
                Defaults to config["dataframe"]["tof_column"].
1455
            sector_id_column (str, optional): Name of the column containing the sector id values.
1456
                Defaults to config["dataframe"]["sector_id_column"].
1457
            sector_delays (np.ndarray, optional): Array containing the sector delays. Defaults to
1458
                config["dataframe"]["sector_delays"].
1459

1460
        Returns:
1461
            tuple[dask.dataframe.DataFrame, dict]: Dataframe with the new columns and Metadata
1462
            dictionary.
1463
        """
1464
        if sector_delays is None:
1✔
1465
            sector_delays = self.sector_delays
1✔
1466
        if sector_id_column is None:
1✔
1467
            sector_id_column = self.sector_id_column
1✔
1468

1469
        if sector_delays is None or sector_id_column is None:
1✔
1470
            raise ValueError(
1✔
1471
                "No value for sector_delays or sector_id_column found in config."
1472
                "Config file is not properly configured for dld sector correction.",
1473
            )
1474
        tof_column = tof_column or self.tof_column
1✔
1475

1476
        # align the 8s sectors
1477
        sector_delays_arr = dask.array.from_array(sector_delays)
1✔
1478

1479
        def align_sector(x):
1✔
1480
            val = x[tof_column] - sector_delays_arr[x[sector_id_column].values.astype(int)]
1✔
1481
            return val.astype(np.float32)
1✔
1482

1483
        df[tof_column] = df.map_partitions(align_sector, meta=(tof_column, np.float32))
1✔
1484
        metadata: dict[str, Any] = {
1✔
1485
            "applied": True,
1486
            "sector_delays": sector_delays,
1487
        }
1488
        return df, metadata
1✔
1489

1490
    def add_offsets(
1✔
1491
        self,
1492
        df: pd.DataFrame | dask.dataframe.DataFrame = None,
1493
        offsets: dict[str, Any] = None,
1494
        constant: float = None,
1495
        columns: str | Sequence[str] = None,
1496
        weights: float | Sequence[float] = None,
1497
        preserve_mean: bool | Sequence[bool] = False,
1498
        reductions: str | Sequence[str] = None,
1499
        energy_column: str = None,
1500
        verbose: bool = True,
1501
    ) -> tuple[pd.DataFrame | dask.dataframe.DataFrame, dict]:
1502
        """Apply an offset to the energy column by the values of the provided columns.
1503

1504
        If no parameter is passed to this function, the offset is applied as defined in the
1505
        config file. If parameters are passed, they are used to generate a new offset dictionary
1506
        and the offset is applied using the ``dfops.apply_offset_from_columns()`` function.
1507

1508
        Args:
1509
            df (pd.DataFrame | dask.dataframe.DataFrame): Dataframe to use.
1510
            offsets (Dict, optional): Dictionary of energy offset parameters.
1511
            constant (float, optional): The constant to shift the energy axis by.
1512
            columns (str | Sequence[str]): Name of the column(s) to apply the shift from.
1513
            weights (float | Sequence[float]): weights to apply to the columns.
1514
                Can also be used to flip the sign (e.g. -1). Defaults to 1.
1515
            preserve_mean (bool | Sequence[bool]): Whether to subtract the mean of the column
1516
                before applying the shift. Defaults to False.
1517
            reductions (str | Sequence[str]): The reduction to apply to the column. Should be an
1518
                available method of dask.dataframe.Series. For example "mean". In this case the
1519
                function is applied to the column to generate a single value for the whole dataset.
1520
                If None, the shift is applied per-dataframe-row. Defaults to None. Currently only
1521
                "mean" is supported.
1522
            energy_column (str, optional): Name of the column containing the energy values.
1523
            verbose (bool, optional): Option to print out diagnostic information.
1524
                Defaults to True.
1525

1526
        Returns:
1527
            tuple[pd.DataFrame | dask.dataframe.DataFrame, dict]: Dataframe with the new columns
1528
            and Metadata dictionary.
1529
        """
1530
        if offsets is None:
1✔
1531
            offsets = deepcopy(self.offsets)
1✔
1532

1533
        if energy_column is None:
1✔
1534
            energy_column = self.energy_column
1✔
1535

1536
        metadata: dict[str, Any] = {
1✔
1537
            "applied": True,
1538
        }
1539

1540
        # flip sign for binding energy scale
1541
        energy_scale = self.calibration.get("energy_scale", None)
1✔
1542
        if energy_scale is None:
1✔
1543
            raise ValueError("Energy scale not set. Cannot interpret the sign of the offset.")
1✔
1544
        if energy_scale not in ["binding", "kinetic"]:
1✔
1545
            raise ValueError(f"Invalid energy scale: {energy_scale}")
1✔
1546
        scale_sign: Literal[-1, 1] = -1 if energy_scale == "binding" else 1
1✔
1547

1548
        if columns is not None or constant is not None:
1✔
1549
            # pylint:disable=duplicate-code
1550
            # use passed parameters, overwrite config
1551
            offsets = {}
1✔
1552
            offsets["creation_date"] = datetime.now().timestamp()
1✔
1553
            # column-based offsets
1554
            if columns is not None:
1✔
1555
                if isinstance(columns, str):
1✔
1556
                    columns = [columns]
1✔
1557

1558
                if weights is None:
1✔
1559
                    weights = 1
1✔
1560
                if isinstance(weights, (int, float, np.integer, np.floating)):
1✔
1561
                    weights = [weights]
1✔
1562
                if len(weights) == 1:
1✔
1563
                    weights = [weights[0]] * len(columns)
1✔
1564
                if not isinstance(weights, Sequence):
1✔
1565
                    raise TypeError(f"Invalid type for weights: {type(weights)}")
×
1566
                if not all(isinstance(s, (int, float, np.integer, np.floating)) for s in weights):
1✔
1567
                    raise TypeError(f"Invalid type for weights: {type(weights)}")
×
1568

1569
                if preserve_mean is None:
1✔
NEW
1570
                    preserve_mean = False
×
1571
                if not isinstance(preserve_mean, Sequence):
1✔
1572
                    preserve_mean = [preserve_mean]
1✔
1573
                if len(preserve_mean) == 1:
1✔
1574
                    preserve_mean = [preserve_mean[0]] * len(columns)
1✔
1575

1576
                if not isinstance(reductions, Sequence):
1✔
1577
                    reductions = [reductions]
1✔
1578
                if len(reductions) == 1:
1✔
1579
                    reductions = [reductions[0]] * len(columns)
1✔
1580

1581
                # store in offsets dictionary
1582
                for col, weight, pmean, red in zip(columns, weights, preserve_mean, reductions):
1✔
1583
                    offsets[col] = {
1✔
1584
                        "weight": weight,
1585
                        "preserve_mean": pmean,
1586
                        "reduction": red,
1587
                    }
1588

1589
            # constant offset
1590
            if isinstance(constant, (int, float, np.integer, np.floating)):
1✔
1591
                offsets["constant"] = constant
1✔
1592
            elif constant is not None:
1✔
1593
                raise TypeError(f"Invalid type for constant: {type(constant)}")
×
1594

1595
        elif "creation_date" in offsets and verbose:
1✔
1596
            datestring = datetime.fromtimestamp(offsets["creation_date"]).strftime(
×
1597
                "%m/%d/%Y, %H:%M:%S",
1598
            )
1599
            print(f"Using energy offset parameters generated on {datestring}")
×
1600

1601
        if len(offsets) > 0:
1✔
1602
            # unpack dictionary
1603
            # pylint: disable=duplicate-code
1604
            columns = []
1✔
1605
            weights = []
1✔
1606
            preserve_mean = []
1✔
1607
            reductions = []
1✔
1608
            if verbose:
1✔
1609
                print("Energy offset parameters:")
1✔
1610
            for k, v in offsets.items():
1✔
1611
                if k == "creation_date":
1✔
1612
                    continue
1✔
1613
                if k == "constant":
1✔
1614
                    # flip sign if binding energy scale
1615
                    constant = v * scale_sign
1✔
1616
                    if verbose:
1✔
1617
                        print(f"   Constant: {constant} ")
1✔
1618
                else:
1619
                    columns.append(k)
1✔
1620
                    try:
1✔
1621
                        weight = v["weight"]
1✔
1622
                    except KeyError:
×
1623
                        weight = 1
×
1624
                    if not isinstance(weight, (int, float, np.integer, np.floating)):
1✔
1625
                        raise TypeError(f"Invalid type for weight of column {k}: {type(weight)}")
1✔
1626
                    # flip sign if binding energy scale
1627
                    weight = weight * scale_sign
1✔
1628
                    weights.append(weight)
1✔
1629
                    pm = v.get("preserve_mean", False)
1✔
1630
                    if str(pm).lower() in ["false", "0", "no"]:
1✔
1631
                        pm = False
1✔
1632
                    elif str(pm).lower() in ["true", "1", "yes"]:
1✔
1633
                        pm = True
1✔
1634
                    preserve_mean.append(pm)
1✔
1635
                    red = v.get("reduction", None)
1✔
1636
                    if str(red).lower() in ["none", "null"]:
1✔
1637
                        red = None
1✔
1638
                    reductions.append(red)
1✔
1639
                    if verbose:
1✔
1640
                        print(
1✔
1641
                            f"   Column[{k}]: Weight={weight}, Preserve Mean: {pm}, ",
1642
                            f"Reductions: {red}.",
1643
                        )
1644

1645
            if len(columns) > 0:
1✔
1646
                df = dfops.offset_by_other_columns(
1✔
1647
                    df=df,
1648
                    target_column=energy_column,
1649
                    offset_columns=columns,
1650
                    weights=weights,
1651
                    preserve_mean=preserve_mean,
1652
                    reductions=reductions,
1653
                )
1654

1655
        # apply constant
1656
        if constant:
1✔
1657
            if not isinstance(constant, (int, float, np.integer, np.floating)):
1✔
1658
                raise TypeError(f"Invalid type for constant: {type(constant)}")
1✔
1659
            df[energy_column] = df[energy_column] + constant
1✔
1660

1661
        self.offsets = offsets
1✔
1662
        metadata["offsets"] = offsets
1✔
1663

1664
        return df, metadata
1✔
1665

1666

1667
def extract_bias(files: list[str], bias_key: str) -> np.ndarray:
1✔
1668
    """Read bias values from hdf5 files
1669

1670
    Args:
1671
        files (list[str]): List of filenames
1672
        bias_key (str): hdf5 path to the bias value
1673

1674
    Returns:
1675
        np.ndarray: Array of bias values.
1676
    """
1677
    bias_list: list[float] = []
1✔
1678
    for file in files:
1✔
1679
        with h5py.File(file, "r") as file_handle:
1✔
1680
            if bias_key[0] == "@":
1✔
1681
                bias_list.append(round(file_handle.attrs[bias_key[1:]], 2))
1✔
1682
            else:
1683
                bias_list.append(round(file_handle[bias_key], 2))
1✔
1684

1685
    return np.asarray(bias_list)
1✔
1686

1687

1688
def correction_function(
1✔
1689
    x: float | np.ndarray,
1690
    y: float | np.ndarray,
1691
    correction_type: str,
1692
    center: tuple[float, float],
1693
    amplitude: float,
1694
    **kwds,
1695
) -> float | np.ndarray:
1696
    """Calculate the TOF correction based on the given X/Y coordinates and a model.
1697

1698
    Args:
1699
        x (float | np.ndarray): x coordinate
1700
        y (float | np.ndarray): y coordinate
1701
        correction_type (str): type of correction. One of
1702
            "spherical", "Lorentzian", "Gaussian", or "Lorentzian_asymmetric"
1703
        center (tuple[int, int]): center position of the distribution (x,y)
1704
        amplitude (float): Amplitude of the correction
1705
        **kwds: Keyword arguments:
1706

1707
            - **diameter** (float): Field-free drift distance.
1708
            - **gamma** (float): Linewidth value for correction using a 2D
1709
              Lorentz profile.
1710
            - **sigma** (float): Standard deviation for correction using a 2D
1711
              Gaussian profile.
1712
            - **gamma2** (float): Linewidth value for correction using an
1713
              asymmetric 2D Lorentz profile, X-direction.
1714
            - **amplitude2** (float): Amplitude value for correction using an
1715
              asymmetric 2D Lorentz profile, X-direction.
1716

1717
    Returns:
1718
        float | np.ndarray: calculated correction value
1719
    """
1720
    if correction_type == "spherical":
1✔
1721
        try:
1✔
1722
            diameter = kwds.pop("diameter")
1✔
1723
        except KeyError as exc:
1✔
1724
            raise ValueError(
1✔
1725
                f"Parameter 'diameter' required for correction type '{correction_type}' "
1726
                "but not provided!",
1727
            ) from exc
1728
        correction = -(
1✔
1729
            (
1730
                1
1731
                - np.sqrt(
1732
                    1 - ((x - center[0]) ** 2 + (y - center[1]) ** 2) / diameter**2,
1733
                )
1734
            )
1735
            * 100
1736
            * amplitude
1737
        )
1738

1739
    elif correction_type == "Lorentzian":
1✔
1740
        try:
1✔
1741
            gamma = kwds.pop("gamma")
1✔
1742
        except KeyError as exc:
1✔
1743
            raise ValueError(
1✔
1744
                f"Parameter 'gamma' required for correction type '{correction_type}' "
1745
                "but not provided!",
1746
            ) from exc
1747
        correction = (
1✔
1748
            100000
1749
            * amplitude
1750
            / (gamma * np.pi)
1751
            * (gamma**2 / ((x - center[0]) ** 2 + (y - center[1]) ** 2 + gamma**2) - 1)
1752
        )
1753

1754
    elif correction_type == "Gaussian":
1✔
1755
        try:
1✔
1756
            sigma = kwds.pop("sigma")
1✔
1757
        except KeyError as exc:
1✔
1758
            raise ValueError(
1✔
1759
                f"Parameter 'sigma' required for correction type '{correction_type}' "
1760
                "but not provided!",
1761
            ) from exc
1762
        correction = (
1✔
1763
            20000
1764
            * amplitude
1765
            / np.sqrt(2 * np.pi * sigma**2)
1766
            * (
1767
                np.exp(
1768
                    -((x - center[0]) ** 2 + (y - center[1]) ** 2) / (2 * sigma**2),
1769
                )
1770
                - 1
1771
            )
1772
        )
1773

1774
    elif correction_type == "Lorentzian_asymmetric":
1✔
1775
        try:
1✔
1776
            gamma = kwds.pop("gamma")
1✔
1777
        except KeyError as exc:
1✔
1778
            raise ValueError(
1✔
1779
                f"Parameter 'gamma' required for correction type '{correction_type}' "
1780
                "but not provided!",
1781
            ) from exc
1782
        gamma2 = kwds.pop("gamma2", gamma)
1✔
1783
        amplitude2 = kwds.pop("amplitude2", amplitude)
1✔
1784
        correction = (
1✔
1785
            100000
1786
            * amplitude
1787
            / (gamma * np.pi)
1788
            * (gamma**2 / ((y - center[1]) ** 2 + gamma**2) - 1)
1789
        )
1790
        correction += (
1✔
1791
            100000
1792
            * amplitude2
1793
            / (gamma2 * np.pi)
1794
            * (gamma2**2 / ((x - center[0]) ** 2 + gamma2**2) - 1)
1795
        )
1796

1797
    else:
1798
        raise NotImplementedError
×
1799

1800
    return correction
1✔
1801

1802

1803
def normspec(
1✔
1804
    specs: np.ndarray,
1805
    smooth: bool = False,
1806
    span: int = 7,
1807
    order: int = 1,
1808
) -> np.ndarray:
1809
    """Normalize a series of 1D signals.
1810

1811
    Args:
1812
        specs (np.ndarray): Collection of 1D signals.
1813
        smooth (bool, optional): Option to smooth the signals before normalization.
1814
            Defaults to False.
1815
        span (int, optional): Smoothing span parameters of the LOESS method
1816
            (see ``scipy.signal.savgol_filter()``). Defaults to 7.
1817
        order (int, optional): Smoothing order parameters of the LOESS method
1818
            (see ``scipy.signal.savgol_filter()``).. Defaults to 1.
1819

1820
    Returns:
1821
        np.ndarray: The matrix assembled from a list of maximum-normalized signals.
1822
    """
1823
    nspec = len(specs)
1✔
1824
    specnorm = []
1✔
1825

1826
    for i in range(nspec):
1✔
1827
        spec = specs[i]
1✔
1828

1829
        if smooth:
1✔
1830
            spec = savgol_filter(spec, span, order)
1✔
1831

1832
        if type(spec) in (list, tuple):
1✔
1833
            nsp = spec / max(spec)
×
1834
        else:
1835
            nsp = spec / spec.max()
1✔
1836
        specnorm.append(nsp)
1✔
1837

1838
        # Align 1D spectrum
1839
        normalized_specs = np.asarray(specnorm)
1✔
1840

1841
    return normalized_specs
1✔
1842

1843

1844
def find_correspondence(
1✔
1845
    sig_still: np.ndarray,
1846
    sig_mov: np.ndarray,
1847
    **kwds,
1848
) -> np.ndarray:
1849
    """Determine the correspondence between two 1D traces by alignment using a
1850
    time-warp algorithm.
1851

1852
    Args:
1853
        sig_still (np.ndarray): Reference 1D signals.
1854
        sig_mov (np.ndarray): 1D signal to be aligned.
1855
        **kwds: keyword arguments for ``fastdtw.fastdtw()``
1856

1857
    Returns:
1858
        np.ndarray: Pixel-wise path correspondences between two input 1D arrays
1859
        (sig_still, sig_mov).
1860
    """
1861
    dist = kwds.pop("dist_metric", None)
1✔
1862
    rad = kwds.pop("radius", 1)
1✔
1863
    _, pathcorr = fastdtw(sig_still, sig_mov, dist=dist, radius=rad)
1✔
1864
    return np.asarray(pathcorr)
1✔
1865

1866

1867
def range_convert(
1✔
1868
    x: np.ndarray,
1869
    xrng: tuple,
1870
    pathcorr: np.ndarray,
1871
) -> tuple:
1872
    """Convert value range using a pairwise path correspondence (e.g. obtained
1873
    from time warping algorithm).
1874

1875
    Args:
1876
        x (np.ndarray): Values of the x axis (e.g. time-of-flight values).
1877
        xrng (tuple): Boundary value range on the x axis.
1878
        pathcorr (np.ndarray): Path correspondence between two 1D arrays in the
1879
            following form,
1880
            [(id_1_trace_1, id_1_trace_2), (id_2_trace_1, id_2_trace_2), ...]
1881

1882
    Returns:
1883
        tuple: Transformed range according to the path correspondence.
1884
    """
1885
    pathcorr = np.asarray(pathcorr)
1✔
1886
    xrange_trans = []
1✔
1887

1888
    for xval in xrng:  # Transform each value in the range
1✔
1889
        xind = find_nearest(xval, x)
1✔
1890
        xind_alt = find_nearest(xind, pathcorr[:, 0])
1✔
1891
        xind_trans = pathcorr[xind_alt, 1]
1✔
1892
        xrange_trans.append(x[xind_trans])
1✔
1893

1894
    return tuple(xrange_trans)
1✔
1895

1896

1897
def find_nearest(val: float, narray: np.ndarray) -> int:
1✔
1898
    """Find the value closest to a given one in a 1D array.
1899

1900
    Args:
1901
        val (float): Value of interest.
1902
        narray (np.ndarray):  The array to look for the nearest value.
1903

1904
    Returns:
1905
        int: Array index of the value nearest to the given one.
1906
    """
1907
    return int(np.argmin(np.abs(narray - val)))
1✔
1908

1909

1910
def peaksearch(
1✔
1911
    traces: np.ndarray,
1912
    tof: np.ndarray,
1913
    ranges: list[tuple] = None,
1914
    pkwindow: int = 3,
1915
    plot: bool = False,
1916
) -> np.ndarray:
1917
    """Detect a list of peaks in the corresponding regions of multiple spectra.
1918

1919
    Args:
1920
        traces (np.ndarray): Collection of 1D spectra.
1921
        tof (np.ndarray): Time-of-flight values.
1922
        ranges (list[tuple], optional): List of ranges for peak detection in the format
1923
        [(LowerBound1, UpperBound1), (LowerBound2, UpperBound2), ....].
1924
            Defaults to None.
1925
        pkwindow (int, optional): Window width of a peak (amounts to lookahead in
1926
            ``peakdetect1d``). Defaults to 3.
1927
        plot (bool, optional): Specify whether to display a custom plot of the peak
1928
            search results. Defaults to False.
1929

1930
    Returns:
1931
        np.ndarray: Collection of peak positions.
1932
    """
1933
    pkmaxs = []
1✔
1934
    if plot:
1✔
1935
        plt.figure(figsize=(10, 4))
×
1936

1937
    for rng, trace in zip(ranges, traces.tolist()):
1✔
1938
        cond = (tof >= rng[0]) & (tof <= rng[1])
1✔
1939
        trace = np.array(trace).ravel()
1✔
1940
        tofseg, trseg = tof[cond], trace[cond]
1✔
1941
        maxs, _ = peakdetect1d(trseg, tofseg, lookahead=pkwindow)
1✔
1942
        try:
1✔
1943
            pkmaxs.append(maxs[0, :])
1✔
1944
        except IndexError:  # No peak found for this range
×
1945
            print(f"No peak detected in range {rng}.")
×
1946
            raise
×
1947

1948
        if plot:
1✔
1949
            plt.plot(tof, trace, "--k", linewidth=1)
×
1950
            plt.plot(tofseg, trseg, linewidth=2)
×
1951
            plt.scatter(maxs[0, 0], maxs[0, 1], s=30)
×
1952

1953
    return np.asarray(pkmaxs)
1✔
1954

1955

1956
# 1D peak detection algorithm adapted from Sixten Bergman
1957
# https://gist.github.com/sixtenbe/1178136#file-peakdetect-py
1958
def _datacheck_peakdetect(
1✔
1959
    x_axis: np.ndarray,
1960
    y_axis: np.ndarray,
1961
) -> tuple[np.ndarray, np.ndarray]:
1962
    """Input format checking for 1D peakdetect algorithm
1963

1964
    Args:
1965
        x_axis (np.ndarray): x-axis array
1966
        y_axis (np.ndarray): y-axis array
1967

1968
    Raises:
1969
        ValueError: Raised if x and y values don't have the same length.
1970

1971
    Returns:
1972
        tuple[np.ndarray, np.ndarray]: Tuple of checked (x/y) arrays.
1973
    """
1974

1975
    if x_axis is None:
1✔
1976
        x_axis = np.arange(len(y_axis))
×
1977

1978
    if len(y_axis) != len(x_axis):
1✔
1979
        raise ValueError(
×
1980
            "Input vectors y_axis and x_axis must have same length",
1981
        )
1982

1983
    # Needs to be a numpy array
1984
    y_axis = np.asarray(y_axis)
1✔
1985
    x_axis = np.asarray(x_axis)
1✔
1986

1987
    return x_axis, y_axis
1✔
1988

1989

1990
def peakdetect1d(
1✔
1991
    y_axis: np.ndarray,
1992
    x_axis: np.ndarray = None,
1993
    lookahead: int = 200,
1994
    delta: int = 0,
1995
) -> tuple[np.ndarray, np.ndarray]:
1996
    """Function for detecting local maxima and minima in a signal.
1997
    Discovers peaks by searching for values which are surrounded by lower
1998
    or larger values for maxima and minima respectively
1999

2000
    Converted from/based on a MATLAB script at:
2001
    http://billauer.co.il/peakdet.html
2002

2003
    Args:
2004
        y_axis (np.ndarray): A list containing the signal over which to find peaks.
2005
        x_axis (np.ndarray, optional): A x-axis whose values correspond to the y_axis
2006
            list and is used in the return to specify the position of the peaks. If
2007
            omitted an index of the y_axis is used.
2008
        lookahead (int, optional): distance to look ahead from a peak candidate to
2009
            determine if it is the actual peak
2010
            '(samples / period) / f' where '4 >= f >= 1.25' might be a good value.
2011
            Defaults to 200.
2012
        delta (int, optional): this specifies a minimum difference between a peak and
2013
            the following points, before a peak may be considered a peak. Useful
2014
            to hinder the function from picking up false peaks towards to end of
2015
            the signal. To work well delta should be set to delta >= RMSnoise * 5.
2016
            Defaults to 0.
2017

2018
    Raises:
2019
        ValueError: Raised if lookahead and delta are out of range.
2020

2021
    Returns:
2022
        tuple[np.ndarray, np.ndarray]: Tuple of positions of the positive peaks,
2023
        positions of the negative peaks
2024
    """
2025
    max_peaks = []
1✔
2026
    min_peaks = []
1✔
2027
    dump = []  # Used to pop the first hit which almost always is false
1✔
2028

2029
    # Check input data
2030
    x_axis, y_axis = _datacheck_peakdetect(x_axis, y_axis)
1✔
2031
    # Store data length for later use
2032
    length = len(y_axis)
1✔
2033

2034
    # Perform some checks
2035
    if lookahead < 1:
1✔
2036
        raise ValueError("Lookahead must be '1' or above in value")
×
2037

2038
    if not (np.ndim(delta) == 0 and delta >= 0):
1✔
2039
        raise ValueError("delta must be a positive number")
×
2040

2041
    # maxima and minima candidates are temporarily stored in
2042
    # mx and mn respectively
2043
    _min, _max = np.inf, -np.inf
1✔
2044

2045
    # Only detect peak if there is 'lookahead' amount of points after it
2046
    for index, (x, y) in enumerate(
1✔
2047
        zip(x_axis[:-lookahead], y_axis[:-lookahead]),
2048
    ):
2049
        if y > _max:
1✔
2050
            _max = y
1✔
2051
            _max_pos = x
1✔
2052

2053
        if y < _min:
1✔
2054
            _min = y
1✔
2055
            _min_pos = x
1✔
2056

2057
        # Find local maxima
2058
        if y < _max - delta and _max != np.inf:
1✔
2059
            # Maxima peak candidate found
2060
            # look ahead in signal to ensure that this is a peak and not jitter
2061
            if y_axis[index : index + lookahead].max() < _max:
1✔
2062
                max_peaks.append([_max_pos, _max])
1✔
2063
                dump.append(True)
1✔
2064
                # Set algorithm to only find minima now
2065
                _max = np.inf
1✔
2066
                _min = np.inf
1✔
2067

2068
                if index + lookahead >= length:
1✔
2069
                    # The end is within lookahead no more peaks can be found
2070
                    break
×
2071
                continue
×
2072
            # else:
2073
            #    mx = ahead
2074
            #    mxpos = x_axis[np.where(y_axis[index:index+lookahead]==mx)]
2075

2076
        # Find local minima
2077
        if y > _min + delta and _min != -np.inf:
1✔
2078
            # Minima peak candidate found
2079
            # look ahead in signal to ensure that this is a peak and not jitter
2080
            if y_axis[index : index + lookahead].min() > _min:
1✔
2081
                min_peaks.append([_min_pos, _min])
1✔
2082
                dump.append(False)
1✔
2083
                # Set algorithm to only find maxima now
2084
                _min = -np.inf
1✔
2085
                _max = -np.inf
1✔
2086

2087
                if index + lookahead >= length:
1✔
2088
                    # The end is within lookahead no more peaks can be found
2089
                    break
×
2090
            # else:
2091
            #    mn = ahead
2092
            #    mnpos = x_axis[np.where(y_axis[index:index+lookahead]==mn)]
2093

2094
    # Remove the false hit on the first value of the y_axis
2095
    try:
1✔
2096
        if dump[0]:
1✔
2097
            max_peaks.pop(0)
×
2098
        else:
2099
            min_peaks.pop(0)
1✔
2100
        del dump
1✔
2101

2102
    except IndexError:  # When no peaks have been found
×
2103
        pass
×
2104

2105
    return (np.asarray(max_peaks), np.asarray(min_peaks))
1✔
2106

2107

2108
def fit_energy_calibration(
1✔
2109
    pos: list[float] | np.ndarray,
2110
    vals: list[float] | np.ndarray,
2111
    binwidth: float,
2112
    binning: int,
2113
    ref_energy: float,
2114
    t: list[float] | np.ndarray = None,
2115
    energy_scale: str = "kinetic",
2116
    verbose: bool = True,
2117
    **kwds,
2118
) -> dict:
2119
    """Energy calibration by nonlinear least squares fitting of spectral landmarks on
2120
    a set of (energy dispersion curves (EDCs). This is done here by fitting to the
2121
    function d/(t-t0)**2.
2122

2123
    Args:
2124
        pos (list[float] | np.ndarray): Positions of the spectral landmarks
2125
            (e.g. peaks) in the EDCs.
2126
        vals (list[float] | np.ndarray): Bias voltage value associated with
2127
            each EDC.
2128
        binwidth (float): Time width of each original TOF bin in ns.
2129
        binning (int): Binning factor of the TOF values.
2130
        ref_energy (float): Energy value of the feature in the reference
2131
            trace (eV).
2132
        t (list[float] | np.ndarray, optional): Array of TOF values. Required
2133
            to calculate calibration trace. Defaults to None.
2134
        energy_scale (str, optional): Direction of increasing energy scale.
2135

2136
            - **'kinetic'**: increasing energy with decreasing TOF.
2137
            - **'binding'**: increasing energy with increasing TOF.
2138
        verbose (bool, optional): Option to print out diagnostic information.
2139
            Defaults to True.
2140
        **kwds: keyword arguments:
2141

2142
            - **t0** (float): constrains and initial values for the fit parameter t0,
2143
              corresponding to the time of flight offset. Defaults to 1e-6.
2144
            - **E0** (float): constrains and initial values for the fit parameter E0,
2145
              corresponding to the energy offset. Defaults to min(vals).
2146
            - **d** (float): constrains and initial values for the fit parameter d,
2147
              corresponding to the drift distance. Defaults to 1.
2148

2149
    Returns:
2150
        dict: A dictionary of fitting parameters including the following,
2151

2152
        - "coeffs": Fitted function coefficients.
2153
        - "axis": Fitted energy axis.
2154
    """
2155
    vals = np.asarray(vals)
1✔
2156

2157
    def residual(pars, time, data, binwidth, binning, energy_scale):
1✔
2158
        model = tof2ev(
1✔
2159
            pars["d"],
2160
            pars["t0"],
2161
            binwidth,
2162
            binning,
2163
            energy_scale,
2164
            pars["E0"],
2165
            time,
2166
        )
2167
        if data is None:
1✔
2168
            return model
×
2169
        return model - data
1✔
2170

2171
    pars = Parameters()
1✔
2172
    d_pars = kwds.pop("d", {})
1✔
2173
    pars.add(
1✔
2174
        name="d",
2175
        value=d_pars.get("value", 1),
2176
        min=d_pars.get("min", -np.inf),
2177
        max=d_pars.get("max", np.inf),
2178
        vary=d_pars.get("vary", True),
2179
    )
2180
    t0_pars = kwds.pop("t0", {})
1✔
2181
    pars.add(
1✔
2182
        name="t0",
2183
        value=t0_pars.get("value", 1e-6),
2184
        min=t0_pars.get("min", -np.inf),
2185
        max=t0_pars.get(
2186
            "max",
2187
            (min(pos) - 1) * binwidth * 2**binning,
2188
        ),
2189
        vary=t0_pars.get("vary", True),
2190
    )
2191
    E0_pars = kwds.pop("E0", {})  # pylint: disable=invalid-name
1✔
2192
    pars.add(
1✔
2193
        name="E0",
2194
        value=E0_pars.get("value", min(vals)),
2195
        min=E0_pars.get("min", -np.inf),
2196
        max=E0_pars.get("max", np.inf),
2197
        vary=E0_pars.get("vary", True),
2198
    )
2199
    fit = Minimizer(
1✔
2200
        residual,
2201
        pars,
2202
        fcn_args=(pos, vals, binwidth, binning, energy_scale),
2203
    )
2204
    result = fit.leastsq()
1✔
2205
    if verbose:
1✔
2206
        report_fit(result)
1✔
2207

2208
    # Construct the calibrating function
2209
    pfunc = partial(
1✔
2210
        tof2ev,
2211
        result.params["d"].value,
2212
        result.params["t0"].value,
2213
        binwidth,
2214
        binning,
2215
        energy_scale,
2216
    )
2217

2218
    # Return results according to specification
2219
    ecalibdict = {}
1✔
2220
    ecalibdict["d"] = result.params["d"].value
1✔
2221
    ecalibdict["t0"] = result.params["t0"].value
1✔
2222
    ecalibdict["E0"] = result.params["E0"].value
1✔
2223
    ecalibdict["energy_scale"] = energy_scale
1✔
2224
    energy_offset = pfunc(-1 * ref_energy, pos[0])
1✔
2225
    ecalibdict["E0"] = -(energy_offset - vals[0])
1✔
2226

2227
    if t is not None:
1✔
2228
        ecalibdict["axis"] = pfunc(ecalibdict["E0"], t)
1✔
2229

2230
    return ecalibdict
1✔
2231

2232

2233
def poly_energy_calibration(
1✔
2234
    pos: list[float] | np.ndarray,
2235
    vals: list[float] | np.ndarray,
2236
    ref_energy: float,
2237
    order: int = 3,
2238
    t: list[float] | np.ndarray = None,
2239
    aug: int = 1,
2240
    method: str = "lstsq",
2241
    energy_scale: str = "kinetic",
2242
    **kwds,
2243
) -> dict:
2244
    """Energy calibration by nonlinear least squares fitting of spectral landmarks on
2245
    a set of (energy dispersion curves (EDCs). This amounts to solving for the
2246
    coefficient vector, a, in the system of equations T.a = b. Here T is the
2247
    differential drift time matrix and b the differential bias vector, and
2248
    assuming that the energy-drift-time relationship can be written in the form,
2249
    E = sum_n (a_n * t**n) + E0
2250

2251

2252
    Args:
2253
        pos (list[float] | np.ndarray): Positions of the spectral landmarks
2254
            (e.g. peaks) in the EDCs.
2255
        vals (list[float] | np.ndarray): Bias voltage value associated with
2256
            each EDC.
2257
        ref_energy (float): Energy value of the feature in the reference
2258
            trace (eV).
2259
        order (int, optional): Polynomial order of the fitting function. Defaults to 3.
2260
        t (list[float] | np.ndarray, optional): Array of TOF values. Required
2261
            to calculate calibration trace. Defaults to None.
2262
        aug (int, optional): Fitting dimension augmentation
2263
            (1=no change, 2=double, etc). Defaults to 1.
2264
        method (str, optional): Method for determining the energy calibration.
2265

2266
            - **'lmfit'**: Energy calibration using lmfit and 1/t^2 form.
2267
            - **'lstsq'**, **'lsqr'**: Energy calibration using polynomial form..
2268

2269
            Defaults to "lstsq".
2270
        energy_scale (str, optional): Direction of increasing energy scale.
2271

2272
            - **'kinetic'**: increasing energy with decreasing TOF.
2273
            - **'binding'**: increasing energy with increasing TOF.
2274

2275
    Returns:
2276
        dict: A dictionary of fitting parameters including the following,
2277

2278
        - "coeffs": Fitted polynomial coefficients (the a's).
2279
        - "offset": Minimum time-of-flight corresponding to a peak.
2280
        - "Tmat": the T matrix (differential time-of-flight) in the equation Ta=b.
2281
        - "bvec": the b vector (differential bias) in the fitting Ta=b.
2282
        - "axis": Fitted energy axis.
2283
    """
2284
    vals = np.asarray(vals)
1✔
2285
    nvals = vals.size
1✔
2286

2287
    # Top-to-bottom ordering of terms in the T matrix
2288
    termorder = np.delete(range(0, nvals, 1), 0)
1✔
2289
    termorder = np.tile(termorder, aug)
1✔
2290
    # Left-to-right ordering of polynomials in the T matrix
2291
    polyorder = np.linspace(order, 1, order, dtype="int")
1✔
2292

2293
    # Construct the T (differential drift time) matrix, Tmat = Tmain - Tsec
2294
    t_main = np.array([pos[0] ** p for p in polyorder])
1✔
2295
    # Duplicate to the same order as the polynomials
2296
    t_main = np.tile(t_main, (aug * (nvals - 1), 1))
1✔
2297

2298
    t_sec = []
1✔
2299

2300
    for term in termorder:
1✔
2301
        t_sec.append([pos[term] ** p for p in polyorder])
1✔
2302

2303
    t_mat = t_main - np.asarray(t_sec)
1✔
2304

2305
    # Construct the b vector (differential bias)
2306
    bvec = vals[0] - np.delete(vals, 0)
1✔
2307
    bvec = np.tile(bvec, aug)
1✔
2308

2309
    # Solve for the a vector (polynomial coefficients) using least squares
2310
    if method == "lstsq":
1✔
2311
        sol = lstsq(t_mat, bvec, rcond=None)
1✔
2312
    elif method == "lsqr":
1✔
2313
        sol = lsqr(t_mat, bvec, **kwds)
1✔
2314
    poly_a = sol[0]
1✔
2315

2316
    # Construct the calibrating function
2317
    pfunc = partial(tof2evpoly, poly_a)
1✔
2318

2319
    # Return results according to specification
2320
    ecalibdict = {}
1✔
2321
    ecalibdict["offset"] = np.asarray(pos).min()
1✔
2322
    ecalibdict["coeffs"] = poly_a
1✔
2323
    ecalibdict["Tmat"] = t_mat
1✔
2324
    ecalibdict["bvec"] = bvec
1✔
2325
    ecalibdict["energy_scale"] = energy_scale
1✔
2326
    ecalibdict["E0"] = -(pfunc(-1 * ref_energy, pos[0]) + vals[0])
1✔
2327

2328
    if t is not None:
1✔
2329
        ecalibdict["axis"] = pfunc(-ecalibdict["E0"], t)
1✔
2330

2331
    return ecalibdict
1✔
2332

2333

2334
def tof2ev(
1✔
2335
    tof_distance: float,
2336
    time_offset: float,
2337
    binwidth: float,
2338
    binning: int,
2339
    energy_scale: str,
2340
    energy_offset: float,
2341
    t: float,
2342
) -> float:
2343
    """(d/(t-t0))**2 expression of the time-of-flight to electron volt
2344
    conversion formula.
2345

2346
    Args:
2347
        tof_distance (float): Drift distance in meter.
2348
        time_offset (float): time offset in ns.
2349
        binwidth (float): Time width of each original TOF bin in ns.
2350
        binning (int): Binning factor of the TOF values.
2351
        energy_scale (str, optional): Direction of increasing energy scale.
2352

2353
            - **'kinetic'**: increasing energy with decreasing TOF.
2354
            - **'binding'**: increasing energy with increasing TOF.
2355

2356
        energy_offset (float): Energy offset in eV.
2357
        t (float): TOF value in bin number.
2358

2359
    Returns:
2360
        float: Converted energy in eV
2361
    """
2362
    sign = 1 if energy_scale == "kinetic" else -1
1✔
2363

2364
    #         m_e/2 [eV]                      bin width [s]
2365
    energy = (
1✔
2366
        2.84281e-12 * sign * (tof_distance / (t * binwidth * 2**binning - time_offset)) ** 2
2367
        + energy_offset
2368
    )
2369

2370
    return energy
1✔
2371

2372

2373
def tof2evpoly(
1✔
2374
    poly_a: list[float] | np.ndarray,
2375
    energy_offset: float,
2376
    t: float,
2377
) -> float:
2378
    """Polynomial approximation of the time-of-flight to electron volt
2379
    conversion formula.
2380

2381
    Args:
2382
        poly_a (list[float] | np.ndarray): Polynomial coefficients.
2383
        energy_offset (float): Energy offset in eV.
2384
        t (float): TOF value in bin number.
2385

2386
    Returns:
2387
        float: Converted energy.
2388
    """
2389
    odr = len(poly_a)  # Polynomial order
1✔
2390
    poly_a = poly_a[::-1]
1✔
2391
    energy = 0.0
1✔
2392

2393
    for i, order in enumerate(range(1, odr + 1)):
1✔
2394
        energy += poly_a[i] * t**order
1✔
2395
    energy += energy_offset
1✔
2396

2397
    return energy
1✔
2398

2399

2400
def tof2ns(
1✔
2401
    binwidth: float,
2402
    binning: int,
2403
    t: float,
2404
) -> float:
2405
    """Converts the time-of-flight steps to time-of-flight in nanoseconds.
2406

2407
    designed for use with dask.dataframe.DataFrame.map_partitions.
2408

2409
    Args:
2410
        binwidth (float): Time step size in seconds.
2411
        binning (int): Binning of the time-of-flight steps.
2412
        t (float): TOF value in bin number.
2413
    Returns:
2414
        float: Converted time in nanoseconds.
2415
    """
2416
    val = t * 1e9 * binwidth * 2.0**binning
1✔
2417
    return val
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc