Repository: PlasmaPy/PlasmaPy

Issue:
Excess statistics for time series
### Feature description

For a given time series and given threshold values, find the excess statistics of the time series (total time above threshold, number of upwards crossings, average time above threshold, etc.).

### Motivation

This functionality has been developed and applied for time series analysis of intermittent fluctuations in the SOL region of fusion experiments. Some examples are listed below:
https://doi.org/10.1016/j.nme.2016.11.008
https://doi.org/10.1063/1.4947235
https://doi.org/10.1103/PhysRevE.97.012110

### Implementation strategy

Implementing this feature should be straight forward since the method is already developed and available in: https://github.com/uit-cosmo/fpp-analysis-tools/blob/main/fppanalysis/excess_statistics.py

The code still needs refactoring and appropriate tests and documentation needs to be added. I am planning to take a look at this in the upcoming days (could I be assigned to this issue?).

### Additional context

_No response_


Discussion:

Resolving patch:
diff --git a/plasmapy/analysis/time_series/__init__.py b/plasmapy/analysis/time_series/__init__.py
--- a/plasmapy/analysis/time_series/__init__.py
+++ b/plasmapy/analysis/time_series/__init__.py
@@ -1,3 +1,4 @@
 """Subpackage containing routines for analyzing time series."""
 
+from plasmapy.analysis.time_series.excess_statistics import ExcessStatistics
 from plasmapy.analysis.time_series.running_moments import running_mean, running_moment
diff --git a/plasmapy/analysis/time_series/excess_statistics.py b/plasmapy/analysis/time_series/excess_statistics.py
new file mode 100644
--- /dev/null
+++ b/plasmapy/analysis/time_series/excess_statistics.py
@@ -0,0 +1,209 @@
+"""Functionality to calculate excess statistics of time series."""
+
+__all__ = ["ExcessStatistics"]
+
+
+import numbers
+import numpy as np
+
+from collections.abc import Iterable
+
+
+class ExcessStatistics:
+    """
+    Calculate total time, number of upwards crossings, average time and
+    root-mean-square time above given thresholds of a sequence.
+
+    Parameters
+    ----------
+    signal : 1D |array_like|
+        Signal to be analyzed.
+
+    thresholds : 1D |array_like|
+        Threshold values.
+
+    time_step : int
+        Time step of ``signal``.
+
+    Raises
+    ------
+    `ValueError`
+        If ``time_step`` ≤ 0.
+
+    Example
+    -------
+    >>> from plasmapy.analysis.time_series.excess_statistics import ExcessStatistics
+    >>> signal = [0, 0, 2, 2, 0, 4]
+    >>> thresholds = [1, 3, 5]
+    >>> time_step = 1
+    >>> excess_statistics = ExcessStatistics(signal, thresholds, time_step)
+    >>> excess_statistics.total_time_above_threshold
+    [3, 1, 0]
+    >>> excess_statistics.number_of_crossings
+    [2, 1, 0]
+    >>> excess_statistics.average_times
+    [1.5, 1.0, 0]
+    >>> excess_statistics.rms_times
+    [0.5, 0.0, 0]
+    """
+
+    def __init__(self, signal, thresholds, time_step):
+        if time_step <= 0:
+            raise ValueError("time_step must be positive")
+
+        # make sure thresholds is an iterable
+        if not isinstance(thresholds, Iterable):
+            thresholds = [thresholds]
+
+        self._total_time_above_threshold = []
+        self._number_of_crossings = []
+        self._average_times = []
+        self._rms_times = []
+        self.events_per_threshold = {}
+
+        self._calculate_excess_statistics(signal, thresholds, time_step)
+
+    def _calculate_excess_statistics(self, signal, thresholds, time_step):
+        for threshold in thresholds:
+            indices_above_threshold = np.where(np.array(signal) > threshold)[0]
+
+            if len(indices_above_threshold) == 0:
+                self._times_above_threshold = []
+                self._total_time_above_threshold.append(0)
+                self._number_of_crossings.append(0)
+                self._average_times.append(0)
+                self._rms_times.append(0)
+
+            else:
+                self._total_time_above_threshold.append(
+                    time_step * len(indices_above_threshold)
+                )
+
+                distances_to_next_index = (
+                    indices_above_threshold[1:] - indices_above_threshold[:-1]
+                )
+                split_indices = np.where(distances_to_next_index != 1)[0]
+                event_lengths = np.split(distances_to_next_index, split_indices)
+
+                # set correct length for first event
+                event_lengths[0] = np.append(event_lengths[0], 1)
+
+                self._times_above_threshold = [
+                    time_step * len(event_lengths[i])
+                    for i in range(0, len(event_lengths))
+                ]
+
+                self._number_of_crossings.append(len(event_lengths))
+                if indices_above_threshold[0] == 0:
+                    # Don't count the first event if there is no crossing.
+                    self._number_of_crossings[-1] -= 1
+
+                self._average_times.append(np.mean(self._times_above_threshold))
+                self._rms_times.append(np.std(self._times_above_threshold))
+
+            self.events_per_threshold.update({threshold: self._times_above_threshold})
+
+    def hist(self, bins=32):
+        """
+        Computes the probability density function of the time above each value
+        in ``thresholds``.
+
+        Parameters
+        ----------
+        bins : int, default: 32
+            The number of bins in the estimation of the PDF above ``thresholds``.
+
+        Returns
+        -------
+        hist: 2D `~numpy.ndarray`, shape (``thresholds.size``, ``bins`` )
+            For each value in ``thresholds``, returns the estimated PDF of time
+            above threshold.
+
+        bin_centers: 2D `~numpy.ndarray`, shape (``thresholds.size``, ``bins`` )
+            Bin centers for ``hist``.
+
+        Raises
+        ------
+        `TypeError`
+            If ``bins`` is not a positive integer.
+
+        Examples
+        --------
+        >>> from plasmapy.analysis.time_series.excess_statistics import ExcessStatistics
+        >>> signal = [0, 0, 2, 0, 4]
+        >>> thresholds = [1, 3, 5]
+        >>> time_step = 1
+        >>> excess_statistics = ExcessStatistics(signal, thresholds, time_step)
+        >>> excess_statistics.hist(2)
+        (array([[0., 2.],
+           [0., 2.],
+           [0., 0.]]), array([[0.75, 1.25],
+           [0.75, 1.25],
+           [0.  , 0.  ]]))
+        """
+
+        if not isinstance(bins, numbers.Integral):
+            raise TypeError("bins must be an integer")
+
+        hist = np.zeros((len(self.events_per_threshold), bins))
+        bin_centers = np.zeros((len(self.events_per_threshold), bins))
+
+        for i, threshold in enumerate(self.events_per_threshold.keys()):
+            if len(self.events_per_threshold[threshold]) >= 1:
+                hist[i, :], bin_edges = np.histogram(
+                    self.events_per_threshold[threshold], bins=bins, density=True
+                )
+                bin_centers[i, :] = (bin_edges[1:] + bin_edges[:-1]) / 2
+        return hist, bin_centers
+
+    @property
+    def total_time_above_threshold(self):
+        """
+        Total time above threshold(s).
+
+        Returns
+        -------
+        total_time_above_threshold: 1D |array_like|
+            Total time above threshold for each value in ``thresholds``.
+        """
+
+        return self._total_time_above_threshold
+
+    @property
+    def number_of_crossings(self):
+        """
+        Total number of upwards crossings for threshold(s).
+
+        Returns
+        -------
+        number_of_crossings: 1D |array_like|
+            Total number of upwards crossings for each value in ``thresholds``.
+        """
+
+        return self._number_of_crossings
+
+    @property
+    def average_times(self):
+        """
+        Average time above threshold(s).
+
+        Returns
+        -------
+        average_times: 1D |array_like|
+            Average time above each value in ``thresholds``.
+        """
+
+        return self._average_times
+
+    @property
+    def rms_times(self):
+        """
+        Root-mean-square values of time above threshold(s).
+
+        Returns
+        -------
+        rms_times: 1D |array_like|
+            Root-mean-square values of time above each value in ``thresholds``.
+        """
+
+        return self._rms_times


Test patch:
diff --git a/plasmapy/analysis/time_series/tests/test_excess_statistics.py b/plasmapy/analysis/time_series/tests/test_excess_statistics.py
new file mode 100644
--- /dev/null
+++ b/plasmapy/analysis/time_series/tests/test_excess_statistics.py
@@ -0,0 +1,99 @@
+"""Tests for excess_statistics.py"""
+
+import astropy.units as u
+import numpy as np
+import pytest
+
+from plasmapy.analysis.time_series.excess_statistics import ExcessStatistics
+
+
+@pytest.mark.parametrize(
+    "signal, thresholds, time_step, pdf, bins, expected",
+    [
+        (
+            [0, 2, 0],
+            1,
+            1,
+            False,
+            32,
+            ([1], [1], [1], [0]),
+        ),
+        (
+            [0, 2, 0],
+            1,
+            0.5,
+            False,
+            32,
+            ([0.5], [1], [0.5], [0]),
+        ),
+        (
+            [0, 2, 0],
+            1.5,
+            1,
+            False,
+            32,
+            ([1], [1], [1], [0]),
+        ),
+        (
+            [0, 2, 2, 0, 4, 0],
+            [1, 3, 5],
+            1,
+            False,
+            32,
+            ([3, 1, 0], [2, 1, 0], [1.5, 1, 0], [0.5, 0, 0]),
+        ),
+        (
+            np.array([2, 2, 0, 4, 0]),
+            [1, 3, 5],
+            1,
+            False,
+            32,
+            ([3, 1, 0], [1, 1, 0], [1.5, 1, 0], [0.5, 0, 0]),
+        ),
+        (
+            [0, 2, 0, 4, 0] * u.eV,
+            [1, 3, 5],
+            1,
+            False,
+            32,
+            ([2, 1, 0], [2, 1, 0], [1, 1, 0], [0, 0, 0]),
+        ),
+        (
+            [0, 2, 0, 4, 0],
+            [1, 3, 5],
+            1,
+            True,
+            2,
+            (
+                [2, 1, 0],
+                [2, 1, 0],
+                [1, 1, 0],
+                [0, 0, 0],
+                [[0, 2], [0, 2], [0, 0]],
+                [[0.75, 1.25], [0.75, 1.25], [0, 0]],
+            ),
+        ),
+    ],
+)
+def test_ExcessStatistics(signal, thresholds, time_step, pdf, bins, expected):
+    """test excess_stat function"""
+    excess_stats = ExcessStatistics(signal, thresholds, time_step)
+    assert excess_stats.total_time_above_threshold == expected[0]
+    assert excess_stats.number_of_crossings == expected[1]
+    assert excess_stats.average_times == expected[2]
+    assert excess_stats.rms_times == expected[3]
+    if pdf:
+        hist, bin_centers = excess_stats.hist(bins)
+        assert np.allclose(hist, expected[4])
+        assert np.allclose(bin_centers, expected[5])
+
+
+@pytest.mark.parametrize(
+    "signal, thresholds, time_step, bins, exception",
+    [([1, 2], 1, -1, 32, ValueError), ([1, 2], 1, 1, 1.5, TypeError)],
+)
+def test_ExcessStatistics_exception(signal, thresholds, time_step, bins, exception):
+    """test whether exception is risen"""
+    with pytest.raises(exception):
+        tmp = ExcessStatistics(signal, thresholds, time_step)
+        tmp.hist(bins)