Coverage for src/signal_edges/signal/state_levels/state_levels.py: 48%
92 statements
« prev ^ index » next coverage.py v7.4.3, created at 2024-04-21 11:16 +0000
« prev ^ index » next coverage.py v7.4.3, created at 2024-04-21 11:16 +0000
1"""The state levels mixin, :class:`.StateLevelsMixin`, can be added to :class:`.Signal` to obtain the levels for
2the logical states of a signal using different modes, :class:`~.state_levels.definitions.Mode`, based on histograms.
3The resulting values for the state levels are returned as :class:`.StateLevels`.
5.. note::
7 This implementation mimics `Mathworks statelevels <https://mathworks.com/help/signal/ref/statelevels.html>`_
8 or `Octave statelevels <https://octave.sourceforge.io/signal/function/statelevels.html>`_, these links provide
9 more information about the inner details.
11The code snippet below shows how to add this functionality to a signal:
13.. code-block:: python
15 import signal_edges.signal as ses
17 class ExampleSignal(ses.state_levels.StateLevelsMixin, ses.Signal):
18 pass
20An example of its usage using :class:`.VoltageSignal` is described below:
22.. code-block:: python
24 import numpy as np
25 import signal_edges.signal as ses
27 # Create timestamps for the signal.
28 signal_timestamps = np.linspace(start=0, stop=160, num=160, endpoint=False)
29 # Create voltages for the signal, and add some noise to them.
30 signal_voltages = np.asarray([0, 0, 0, 0, 5, 5, 5, 5, 5, 5] * (160 // 10)) + \\
31 np.random.normal(0, 0.1, 160)
32 # Create signal.
33 signal = ses.VoltageSignal(signal_timestamps, signal_voltages, "s", "V")
34 # Obtain state levels.
35 (state_levels, histogram) = signal.state_levels()
37 # Plot state levels and histogram.
38 signal.state_levels_plot("signal.png", state_levels, histogram=histogram)
40This code snippet generates the following plot:
42.. figure:: ../../.assets/img/004_example_state_levels.png
43 :width: 600
44 :align: center
46 The generated signal with the state levels calculated and the histogram."""
48try:
49 from typing import Literal, Self
50except ImportError:
51 from typing_extensions import Self, Literal
53import logging
54from collections.abc import Sequence
56import numpy as np
57import numpy.typing as npt
59from ... import plotter as sep
60from ...exceptions import StateLevelsError
61from .definitions import Mode, StateLevels
64class StateLevelsMixin:
65 """State levels mixin :class:`.Signal` that implements calculation of state levels based on histograms."""
67 ## Private API #####################################################################################################
68 def __init__(self, *args, **kwargs) -> None:
69 """Class constructor."""
70 super().__init__(*args, **kwargs)
72 # Relevant members of Signal class, make them available here for type checks and the like.
73 self._logger: logging.Logger
74 self._hv: npt.NDArray[np.float_]
75 self._vv: npt.NDArray[np.float_]
76 self._hunits: sep.Units
77 self._vunits: sep.Units
79 ## Protected API ###################################################################################################
81 ## Public API ######################################################################################################
82 def state_levels(
83 self,
84 mode: Mode = Mode.HISTOGRAM_MODE,
85 nbins: int = 100,
86 bounds: tuple[float, float] | None = None,
87 high_ref: float = 90.0,
88 high_runt_ref: float = 70.0,
89 intermediate_ref: float = 50.0,
90 low_runt_ref: float = 30.0,
91 low_ref: float = 10.0,
92 ) -> tuple[StateLevels, tuple[npt.NDArray[np.float_], npt.NDArray[np.float_]]]:
93 """Finds the state levels of a signal using histograms.
95 :param mode: The histogram mode used to calculate the state levels.
96 :param nbins: Number of bins to use in the histogram.
97 :param bounds: The lower and upper bounds of the signal, defaults to minimum and maximum peak values.
98 :param high_ref: A percentage reference value of the full range for the ``high`` level.
99 :param high_runt_ref: A percentage reference value of the full range for the ``low runt`` level.
100 :param intermediate_ref: A percentage reference value of the full range for the ``intermediate`` level.
101 :param low_runt_ref: A percentage reference value of the full range for the ``low runt`` level.
102 :param low_ref: A percentage reference value of the full range for the ``low`` level.
103 :raise StateLevelsError: The reference values must be in the range `0 <= x <= 100`.
104 :raise StateLevelsError: The reference values must satisfy
105 `low_ref < low_runt_ref < intermediate_ref < high_runt_ref < high_ref`.
106 :raise StateLevelsError: The bounds provided must satisfy `bounds[0] <= bounds[1]`.
107 :raise StateLevelsError: The minimum number of bins is two.
108 :return: The state levels and the values for the horizontal and vertical axes of the histogram."""
109 # pylint: disable=too-many-arguments,too-many-locals
111 # Verify the levels are in the range 0 to 100.
112 if not all(0 <= i <= 100 for i in (high_ref, high_runt_ref, intermediate_ref, low_runt_ref, low_ref)): 112 ↛ 113line 112 didn't jump to line 113, because the condition on line 112 was never true
113 raise StateLevelsError("Reference values must be in the range 0 <= ref <= 100.")
114 # Verify the levels are ordered properly.
115 if not low_ref < low_runt_ref < intermediate_ref < high_runt_ref < high_ref: 115 ↛ 116line 115 didn't jump to line 116, because the condition on line 115 was never true
116 raise StateLevelsError("Reference values must satisfy low < low_runt < intermediate < high_runt < high.")
117 # If the bounds were provided, ensure they are consistent.
118 if bounds is not None and bounds[1] < bounds[0]: 118 ↛ 119line 118 didn't jump to line 119, because the condition on line 118 was never true
119 raise StateLevelsError("Bounds when user provided must satisfy upper bound < lower bound.")
120 # Verify the number of bins.
121 if nbins <= 1: 121 ↛ 122line 121 didn't jump to line 122, because the condition on line 121 was never true
122 raise StateLevelsError("The number of bins when user provided must be greater than one.")
124 # Obtain the maximum and minimum amplitudes, either user provided or from the data.
125 if bounds is not None: 125 ↛ 126line 125 didn't jump to line 126, because the condition on line 125 was never true
126 lower_bound = bounds[0]
127 upper_bound = bounds[1]
128 else:
129 lower_bound = np.subtract(np.min(self._vv), np.finfo(np.float_).eps)
130 upper_bound = np.add(np.max(self._vv), np.finfo(np.float_).eps)
132 # Compute histogram.
133 hist_x = lower_bound + (np.arange(1, nbins + 1) - 0.5) * (upper_bound - lower_bound) / nbins
134 (hist_y, _) = np.histogram(self._vv, nbins, (lower_bound, upper_bound))
136 # Get the lowest-indexed histogram bin with non-zero count.
137 idx_lowest = np.where(hist_y > 0)[0][0] + 1
138 # Get the highest-indexed histogram bin with non-zero count.
139 idx_highest = np.where(hist_y > 0)[0][-1] + 1
141 idx_low_low = idx_lowest
142 idx_low_high = idx_lowest + np.int_(np.floor((idx_highest - idx_lowest) / 2))
143 idx_upper_low = idx_low_high
144 idx_upper_high = idx_highest
146 # Calculate lower histogram.
147 low_hist = hist_y[idx_low_low - 1 : idx_low_high]
148 # Calculate upper histogram.
149 upper_hist = hist_y[idx_upper_low - 1 : idx_upper_high]
151 # Calculate amplitude to ratio.
152 amp_ratio = (upper_bound - lower_bound) / len(hist_y)
154 # Calculate low and high values, using the mode specified.
155 if mode is Mode.HISTOGRAM_MODE:
156 idx_max = np.add(np.argmax(low_hist), 1)
157 idx_min = np.add(np.argmax(upper_hist), 1)
158 lowest_value = lower_bound + amp_ratio * (idx_low_low + idx_max - 1.5)
159 highest_value = lower_bound + amp_ratio * (idx_upper_low + idx_min - 1.5)
160 else:
161 lowest_value = lower_bound + amp_ratio * np.dot(
162 np.arange(idx_low_low, idx_low_high + 1) - 0.5, low_hist
163 ) / np.sum(low_hist)
164 highest_value = lower_bound + amp_ratio * np.dot(
165 np.arange(idx_upper_low, idx_upper_high + 1) - 0.5, upper_hist
166 ) / np.sum(upper_hist)
168 # Calculate full range, and from it, the remaining values based on the percentages.
169 full_range = np.abs(highest_value - lowest_value)
170 high_value = lowest_value + (high_ref / 100) * full_range
171 high_runt_value = lowest_value + (high_runt_ref / 100) * full_range
172 intermediate_value = lowest_value + (intermediate_ref / 100) * full_range
173 low_runt_value = lowest_value + (low_runt_ref / 100) * full_range
174 low_value = lowest_value + (low_ref / 100) * full_range
176 return (
177 StateLevels(
178 highest=highest_value,
179 high=high_value,
180 high_runt=high_runt_value,
181 intermediate=intermediate_value,
182 low_runt=low_runt_value,
183 low=low_value,
184 lowest=lowest_value,
185 ),
186 (hist_x, hist_y),
187 )
189 def state_levels_to_array(
190 self,
191 levels: StateLevels,
192 array_id: Literal["highest", "high", "high_runt", "intermediate", "low_runt", "low", "lowest"],
193 ) -> tuple[npt.NDArray[np.float_], npt.NDArray[np.float_]]:
194 """Convert the specified level from the state levels provided to an array of the same length as the number
195 of values in the signal.
197 :param levels: State levels with the values to convert to arrays.
198 :param array_id: The array identifier used to identify the state level to convert.
199 :raise StateLevelsError: The array identifier provided is not valid.
200 :return: The values of the horizontal axis and the values on the vertical axis for the level specified."""
201 # pylint: disable=too-many-return-statements
203 if array_id == "highest":
204 return (np.copy(self._hv), np.full_like(self._vv, levels.highest))
205 if array_id == "high":
206 return (np.copy(self._hv), np.full_like(self._vv, levels.high))
207 if array_id == "high_runt":
208 return (np.copy(self._hv), np.full_like(self._vv, levels.high_runt))
209 if array_id == "intermediate":
210 return (np.copy(self._hv), np.full_like(self._vv, levels.intermediate))
211 if array_id == "low_runt":
212 return (np.copy(self._hv), np.full_like(self._vv, levels.low_runt))
213 if array_id == "low":
214 return (np.copy(self._hv), np.full_like(self._vv, levels.low))
215 if array_id == "lowest":
216 return (np.copy(self._hv), np.full_like(self._vv, levels.lowest))
218 raise StateLevelsError(f"State level array identifier '{array_id}' is invalid.")
220 def state_levels_plot(
221 self,
222 path: str,
223 state_levels: StateLevels,
224 *args,
225 begin: float | None = None,
226 end: float | None = None,
227 munits: float = 0,
228 levels: Sequence[Literal["highest", "high", "high_runt", "intermediate", "low_runt", "low", "lowest"]] = (),
229 histogram: tuple[npt.NDArray[np.float_], npt.NDArray[np.float_]] | None = None,
230 **kwargs,
231 ) -> Self:
232 """Performs a plot of the signal with the specified levels and optionally the histogram.
234 :param path: The path where to store the plot, see :meth:`.Plotter.plot`.
235 :param state_levels: The state levels to plot.
236 :param args: Additional arguments to pass to the plotting function, see :meth:`.Plotter.plot`.
237 :param begin: The begin value of the horizontal axis where the plot starts, see :meth:`.Plotter.plot`.
238 :param end: The end value of the horizontal axis where the plot ends, see :meth:`.Plotter.plot`.
239 :param munits: Margin units for the plot, see :meth:`.Plotter.plot`.
240 :param levels: The levels to plot, defaults to all levels.
241 :param histogram: The horizontal and vertical axes values of the histogram, defaults to no histogram.
242 :param kwargs: Additional keyword arguments to pass to the plotting function, see :meth:`.Plotter.plot`.
243 :return: Instance of the class."""
244 # pylint: disable=too-many-locals
246 # Create plotter.
247 plotter = sep.Plotter(sep.Mode.LINEAR, rows=2 if histogram is not None else 1, columns=1)
249 # Adjust begin and end values if not provided.
250 begin = begin if begin is not None else float(self._hv[0])
251 end = end if end is not None else float(self._hv[-1])
253 # Create plot for the signal.
254 spl = sep.Subplot("Signal", self._hv, self._hunits, self._vv, self._vunits, begin, end, munits, "red")
255 plotter.add_plot(0, 0, spl)
257 # Add 'highest' state level.
258 for level in ("highest", "high", "high_runt", "intermediate", "low_runt", "low", "lowest"):
259 levels_dict = {
260 "highest": "Highest State Level",
261 "high": "High State Level",
262 "high_runt": "High State Level (Runt)",
263 "intermediate": "Intermediate State Level",
264 "low_runt": "Low State Level (Runt)",
265 "low": "Low State Level",
266 "lowest": "Lowest State Level",
267 }
269 if len(levels) == 0 or level in levels:
270 (level_x, level_y) = self.state_levels_to_array(state_levels, level)
271 subplot = sep.Subplot(
272 levels_dict[level],
273 level_x,
274 self._hunits,
275 level_y,
276 self._vunits,
277 begin,
278 end,
279 munits,
280 "#7F7F7F",
281 linestyle="dotted",
282 marker="none",
283 )
284 plotter.add_plot(0, 0, subplot)
286 # Plot histogram if provided.
287 if histogram is not None:
288 (hist_x, hist_y) = histogram
289 spl = sep.Subplot(
290 "Histogram",
291 hist_x,
292 self._vunits,
293 hist_y,
294 sep.Units("N/A", "N/A", "Frequency"),
295 hist_x[0],
296 hist_x[-1],
297 0,
298 "red",
299 linestyle="dotted",
300 )
301 plotter.add_plot(1, 0, spl)
303 # Create plot.
304 plotter.plot(path, *args, **kwargs)
306 return self