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

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`. 

4 

5.. note:: 

6 

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. 

10 

11The code snippet below shows how to add this functionality to a signal: 

12 

13.. code-block:: python 

14 

15 import signal_edges.signal as ses 

16 

17 class ExampleSignal(ses.state_levels.StateLevelsMixin, ses.Signal): 

18 pass 

19 

20An example of its usage using :class:`.VoltageSignal` is described below: 

21 

22.. code-block:: python 

23 

24 import numpy as np 

25 import signal_edges.signal as ses 

26 

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() 

36  

37 # Plot state levels and histogram. 

38 signal.state_levels_plot("signal.png", state_levels, histogram=histogram) 

39 

40This code snippet generates the following plot: 

41 

42.. figure:: ../../.assets/img/004_example_state_levels.png 

43 :width: 600 

44 :align: center 

45  

46 The generated signal with the state levels calculated and the histogram.""" 

47 

48try: 

49 from typing import Literal, Self 

50except ImportError: 

51 from typing_extensions import Self, Literal 

52 

53import logging 

54from collections.abc import Sequence 

55 

56import numpy as np 

57import numpy.typing as npt 

58 

59from ... import plotter as sep 

60from ...exceptions import StateLevelsError 

61from .definitions import Mode, StateLevels 

62 

63 

64class StateLevelsMixin: 

65 """State levels mixin :class:`.Signal` that implements calculation of state levels based on histograms.""" 

66 

67 ## Private API ##################################################################################################### 

68 def __init__(self, *args, **kwargs) -> None: 

69 """Class constructor.""" 

70 super().__init__(*args, **kwargs) 

71 

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 

78 

79 ## Protected API ################################################################################################### 

80 

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. 

94 

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 

110 

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.") 

123 

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) 

131 

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)) 

135 

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 

140 

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 

145 

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] 

150 

151 # Calculate amplitude to ratio. 

152 amp_ratio = (upper_bound - lower_bound) / len(hist_y) 

153 

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) 

167 

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 

175 

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 ) 

188 

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. 

196 

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 

202 

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)) 

217 

218 raise StateLevelsError(f"State level array identifier '{array_id}' is invalid.") 

219 

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. 

233 

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 

245 

246 # Create plotter. 

247 plotter = sep.Plotter(sep.Mode.LINEAR, rows=2 if histogram is not None else 1, columns=1) 

248 

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]) 

252 

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) 

256 

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 } 

268 

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) 

285 

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) 

302 

303 # Create plot. 

304 plotter.plot(path, *args, **kwargs) 

305 

306 return self