Coverage for src/signal_edges/signal/edges/edges.py: 76%

260 statements  

« prev     ^ index     » next       coverage.py v7.4.3, created at 2024-04-21 11:16 +0000

1"""The edges mixin, :class:`.EdgesMixin`, can be added to :class:`.Signal` to obtain different types, 

2:class:`~.edges.definitions.Type`, of edges of a signal. Each of the resulting edges are returned as a :class:`.Edge`. 

3 

4To configure how to calculate the intermediate point of the edge, refer to :class:`.IntPointPolicy`. 

5 

6The edges mixin requires the :class:`.StateLevelsMixin` to also be added to the signal, the code snippet below shows 

7how to add the edges functionality to a signal: 

8 

9.. code-block:: python 

10 

11 import signal_edges.signal as ses 

12 

13 class ExampleSignal(ses.state_levels.StateLevelsMixin, ses.edges.EdgesMixin, ses.Signal): 

14 pass 

15 

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

17 

18.. code-block:: python 

19 

20 import numpy as np 

21 import signal_edges.signal as ses 

22 

23 # Create timestamps for the signal. 

24 signal_timestamps = np.linspace(start=0, stop=112, num=112, endpoint=False) 

25 # Create voltages for the signal, and add some noise to them. 

26 pattern = [0, 0, 1.5, 2.5, 3.5, 5, 5, 5, 5, 3.5, 2.5, 1.5, 0, 0] 

27 signal_voltages = np.asarray(pattern * (112 // len(pattern))) + \\ 

28 np.random.normal(0, 0.1, 112) 

29 # Create signal. 

30 signal = ses.VoltageSignal(signal_timestamps, signal_voltages, "s", "V") 

31 # Obtain state levels. 

32 (state_levels, _) = signal.state_levels() 

33 # Obtain edges. 

34 edges = signal.edges(state_levels) 

35  

36 # Plot edges. 

37 signal.edges_plot("signal.png", edges) 

38 

39This code snippet generates the following plot: 

40 

41.. figure:: ../../.assets/img/005_example_edges.png 

42 :width: 600 

43 :align: center 

44  

45 The generated signal with the edges begin, intermediate and end points marked.""" 

46 

47try: 

48 from typing import Literal, Self 

49except ImportError: 

50 from typing_extensions import Self, Literal 

51 

52import logging 

53from collections.abc import Sequence 

54 

55import numpy as np 

56import numpy.typing as npt 

57 

58from ... import plotter as sep 

59from ...exceptions import EdgesError 

60from ..state_levels import StateLevels 

61from .definitions import AreaSignal, Edge, IntPointPolicy, Type 

62 

63 

64class EdgesMixin: 

65 """Edges mixin for :class:`.Signal` derived classes that implements the calculation of edges in a signal. 

66 

67 .. caution:: 

68 

69 This mixin requires the :class:`.StateLevelsMixin` in the signal derived from :class:`.Signal`.""" 

70 

71 # pylint: disable=too-many-instance-attributes,consider-using-assignment-expr,else-if-used 

72 

73 # TODO: Decide whether to reduce duplication of code this class, right now it is mostly separated for fallign and 

74 # rising edges but code can be common to a degree for both and separate comments help understand workflow. 

75 

76 # pylint: disable=invalid-name 

77 

78 #: High area identifier. 

79 __HIGH = 0 

80 #: Intermediate high area identifier. 

81 __INT_HIGH = 1 

82 #: Intermediate low area identifier. 

83 __INT_LOW = 2 

84 #: Low area identifier. 

85 __LOW = 3 

86 #: Runt high area identifier. 

87 __RUNT_HIGH = 4 

88 #: Runt low area identifier. 

89 __RUNT_LOW = 5 

90 

91 # pylint: enable=invalid-name 

92 

93 ## Private API ##################################################################################################### 

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

95 """Class constructor.""" 

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

97 

98 #: Area with indices of the values that satisfy `x > high`. 

99 self.__areas: list[npt.NDArray[np.int_]] = [np.empty(shape=(1, 1), dtype=np.int_) for _ in range(0, 6)] 

100 #: The state levels. 

101 self.__state_levels: StateLevels 

102 #: The intermediate point policy to apply. 

103 self.__int_policy: IntPointPolicy 

104 #: Temporary area for state levels in runt edges. 

105 self.__area_signal: AreaSignal = AreaSignal(hvalues=[1, 2], vvalues=[1, 2]) 

106 

107 # Relevant members of Signal class, make them available here for type checks and the like. 

108 self._logger: logging.Logger 

109 self._hv: npt.NDArray[np.float_] 

110 self._vv: npt.NDArray[np.float_] 

111 self._hunits: sep.Units 

112 self._vunits: sep.Units 

113 

114 def __area_update(self, levels: StateLevels) -> Self: 

115 """Updates the internal areas from the state levels provided. 

116 

117 The ``high``, ``int_high``, ``int_low`` and ``low`` areas have all values unique between each other. 

118 

119 The ``runt_high`` and ``runt_low`` areas share values with the ``int_high`` and ``int_low``. 

120 

121 The values in each area are unique and sorted in ascending order, which suits well for fast analysis using 

122 binary search. 

123 

124 :param levels: The state levels for the signal. 

125 :raise EdgesError: The state levels do not satisfy `low < low_runt < intermediate < high_runt < high`. 

126 :return: Instance of the class.""" 

127 # Sanity check on the levels. 

128 if not levels.low < levels.low_runt < levels.intermediate < levels.high_runt < levels.high: 128 ↛ 129line 128 didn't jump to line 129, because the condition on line 128 was never true

129 raise EdgesError("The state levels do not satisfy low < low_runt < intermediate < high_runt < high.") 

130 

131 # Get values. 

132 high = np.float_(levels.high) 

133 high_runt = np.float_(levels.high_runt) 

134 intermediate = np.float_(levels.intermediate) 

135 low_runt = np.float_(levels.low_runt) 

136 low = np.float_(levels.low) 

137 

138 # Normal areas. 

139 self.__areas[self.__HIGH] = np.where(self._vv > high)[0] 

140 self.__areas[self.__INT_HIGH] = np.where((self._vv <= high) & (self._vv > intermediate))[0] 

141 self.__areas[self.__INT_LOW] = np.where((self._vv <= intermediate) & (self._vv >= low))[0] 

142 self.__areas[self.__LOW] = np.where(self._vv < low)[0] 

143 

144 # Runt areas. 

145 self.__areas[self.__RUNT_LOW] = np.where((self._vv <= high_runt) & (self._vv >= low))[0] 

146 self.__areas[self.__RUNT_HIGH] = np.where((self._vv >= low_runt) & (self._vv <= high))[0] 

147 

148 # Keep track of state levels calculated. 

149 self.__state_levels = levels 

150 

151 return self 

152 

153 def __area_first(self, area_id: int, begin: np.int_, end: np.int_ | None = None) -> np.int_ | None: 

154 """Obtains the first value in the area specified between the given ``begin`` and ``end`` values. 

155 

156 :param area_id: The area identifier. 

157 :param begin: The value to use as reference for the beginning of the search. 

158 :param end: The value to use as reference for the end of the search, or ``None`` to use no reference. 

159 :raise EdgesError: The ``begin`` reference value is not in the range `0 <= begin < len(values)`. 

160 :raise EdgesError: The ``end`` reference value is not in the range `0 <= end < len(values)`. 

161 :return: A value ``begin <= value < end`` if ``end`` was specified, otherwise `begin <= value`. 

162 If no value exists for the specified ``begin`` and ``end`` values, then ``None`` is returned.""" 

163 # Ensure the begin value is in the range 0 <= begin < len(values). 

164 if begin < 0 or begin >= len(self._vv): 164 ↛ 165line 164 didn't jump to line 165, because the condition on line 164 was never true

165 raise EdgesError(f"The begin, {begin}, reference value is not in the range 0 <= begin < len(values).") 

166 # If an end value was provided, ensure it is in the range 0 <= end < len(values). 

167 if end is not None and (end < 0 or end >= len(self._vv)): 167 ↛ 168line 167 didn't jump to line 168, because the condition on line 167 was never true

168 raise EdgesError(f"The end, {end}, reference value is not in the range 0 <= end < len(values).") 

169 

170 # Get relevant area from area identifier, and check if empty. 

171 area = self.__areas[area_id] 

172 # Obtain index that satisfies area[bindex-1] < begin <= area[bindex]; 

173 bindex = np.searchsorted(area, begin, "left") 

174 # If the index is zero or greater, it is the desired value, if it matches the length, then no value exists. 

175 bvalue = None if bindex >= len(area) else area[bindex] 

176 

177 # Check if a end value was specified, and delimit the calculated value further. 

178 if bvalue is not None and end is not None: 

179 # An end value was provided, if value found is greater or equal than end value the invalidate it. 

180 bvalue = None if bvalue >= end else bvalue 

181 

182 return bvalue 

183 

184 def __area_last(self, area_id: int, end: np.int_, begin: np.int_ | None = None) -> np.int_ | None: 

185 """Obtains the last value in the area specified between the given ``begin`` and ``end`` values. 

186 

187 :param area_id: The area identifier. 

188 :param end: The value to use as reference for the end of the search. 

189 :param begin: The value to use as reference for the begin of the search, or ``None`` to use no reference. 

190 :raise EdgesError: The ``end`` reference value is not in the range `0 <= end < len(values)`. 

191 :raise EdgesError: The ``begin`` reference value is not in the range `0 <= begin < len(values)`. 

192 :return: A value ``begin <= value < end`` if ``begin`` was specified, otherwise `value < end`. 

193 If no value exists for the specified ``begin`` and ``end`` values, then ``None`` is returned.""" 

194 # pylint: disable=too-many-locals 

195 

196 # Ensure the end value is in the range 0 <= end < len(values). 

197 if end < 0 or end >= len(self._vv): 197 ↛ 198line 197 didn't jump to line 198, because the condition on line 197 was never true

198 raise EdgesError(f"The end, {end}, reference value is not in the range 0 <= end < len(values).") 

199 # If an begin value was provided, ensure it is in the range 0 <= begin < len(values). 

200 if begin is not None and (begin < 0 or begin >= len(self._vv)): 200 ↛ 201line 200 didn't jump to line 201, because the condition on line 200 was never true

201 raise EdgesError(f"The begin, {begin}, reference value is not in the range 0 <= begin < len(values).") 

202 

203 # Get relevant area from area identifier. 

204 area = self.__areas[area_id] 

205 # Obtain index that satisfies area[eindex-1] < end <= area[eindex], and fetch the previous index. 

206 eindex = np.searchsorted(area, end, "left") 

207 eindex = eindex - 1 if eindex > 0 else eindex 

208 # If the index is zero or greater, it is the desired value, if it matches the length, then no value exists. 

209 evalue = None if eindex >= len(area) else area[eindex] 

210 

211 # Check if a begin value was specified, and double check the value calculated. 

212 if evalue is not None and begin is not None: 

213 # A begin value exists, if value found is less than end value the invalidate it. 

214 evalue = None if evalue < begin else evalue 

215 

216 return evalue 

217 

218 def __extract_edge(self, edge_type: Type, begin: np.int_, end: np.int_) -> Edge: 

219 """Extracts an single edge from its ``begin`` and ``end`` values. 

220 

221 :param edge_type: The type of edge to extract. 

222 :param begin: The value for the beginning of the edge. 

223 :param end: The value for the end of the edge. 

224 :raise EdgesError: The ``end`` reference value is not in the range `0 <= end < len(values)`. 

225 :raise EdgesError: The ``begin`` reference value is not in the range `0 <= begin < len(values)`. 

226 :raise EdgesError: The ``begin`` and end reference values is do not satisfy `begin < end`. 

227 :return: The extracted edge.""" 

228 # pylint: disable=too-complex,too-many-branches,too-many-statements,too-many-locals 

229 

230 # Ensure the begin value is in the range 0 <= begin < len(values). 

231 if begin < 0 or begin >= len(self._vv): 231 ↛ 232line 231 didn't jump to line 232, because the condition on line 231 was never true

232 raise EdgesError(f"The begin, {begin}, reference value is not in the range 0 <= begin < len(values).") 

233 # Ensure the end value is in the range 0 <= end < len(values). 

234 if end < 0 or end >= len(self._vv): 234 ↛ 235line 234 didn't jump to line 235, because the condition on line 234 was never true

235 raise EdgesError(f"The end, {end}, reference value is not in the range 0 <= end < len(values).") 

236 # Ensure the begin occurs before the end. 

237 if begin >= end: 237 ↛ 238line 237 didn't jump to line 238, because the condition on line 237 was never true

238 raise EdgesError(f"The begin, {begin}, and end, {end}, reference values do not satisfy begin < end.") 

239 

240 # Handle beginning of the edge, this is common for all edge types. 

241 ibegin = begin 

242 hbegin = self._hv[ibegin] 

243 vbegin = self._vv[ibegin] 

244 

245 # Handle end of the edge, this is common for all edge types. 

246 iend = end 

247 hend = self._hv[iend] 

248 vend = self._vv[iend] 

249 

250 # Handle intermediate of the edge, depending on the type of edge and the policies. 

251 iint = None 

252 vint = np.float_(self.__state_levels.intermediate) 

253 vmax = np.float_(self.__state_levels.highest - self.__state_levels.lowest) 

254 ################################################################################################################ 

255 if edge_type in (Type.FALLING, Type.FALLING_RUNT): 

256 # Check intermediate point policy for forced values, otherwise proceed with calculation. 

257 if self.__int_policy in (IntPointPolicy.POLICY_1, IntPointPolicy.POLICY_2): 

258 iint = ibegin if self.__int_policy is IntPointPolicy.POLICY_1 else iend 

259 else: 

260 # Calculate intermediate of the falling edge, which can be one of the following: 

261 # - The last value in 'int_high' area. 

262 # - The first value in 'int_low' area after last value of 'int_high'. 

263 # - The start of the edge. 

264 # - The end of the edge. 

265 

266 # Get the last value in 'int_high' using the end of the edge as reference. 

267 int_high_v = self.__area_last(self.__INT_HIGH, iend, ibegin) 

268 

269 # Get the first value in 'int_low' from the last value in 'int_high' if it exists. 

270 if int_high_v is not None: 

271 int_low_v = self.__area_first(self.__INT_LOW, int_high_v, iend) 

272 # If the last value in 'int_high' does not exist, then use start of the edge. 

273 else: 

274 int_low_v = self.__area_first(self.__INT_LOW, ibegin, iend) 

275 

276 # Calculate distance to the intermediate points from all candidates that exist. 

277 indices = np.array( 

278 ( 

279 ibegin, 

280 iend, 

281 0 if int_high_v is None else int_high_v, 

282 0 if int_low_v is None else int_low_v, 

283 ) 

284 ) 

285 diffs = np.abs( 

286 np.array( 

287 ( 

288 self._vv[ibegin] - vint, 

289 vint - self._vv[iend], 

290 vmax if int_high_v is None else self._vv[int_high_v] - vint, 

291 vmax if int_low_v is None else vint - self._vv[int_low_v], 

292 ) 

293 ) 

294 ) 

295 

296 # Take the index of the point that is the nearest to the intermediate level. 

297 iint = indices[np.argmin(diffs)] 

298 ################################################################################################################ 

299 else: 

300 # Check intermediate point policy for forced values, otherwise proceed with calculation. 

301 if self.__int_policy in (IntPointPolicy.POLICY_1, IntPointPolicy.POLICY_2): 

302 iint = iend if self.__int_policy is IntPointPolicy.POLICY_1 else ibegin 

303 else: 

304 # Calculate intermediate of the rising edge, which can be one of the following: 

305 # - The last value in 'int_low' area. 

306 # - The first value in 'int_high' area after last value of 'int_low'. 

307 # - The start of the edge. 

308 # - The end of the edge. 

309 

310 # Get the last value in 'int_low' using the end of the edge as reference. 

311 int_low_v = self.__area_last(self.__INT_LOW, iend, ibegin) 

312 

313 # Get the first value in 'int_high' from the last value in 'int_low' if it exists. 

314 if int_low_v is not None: 

315 int_high_v = self.__area_first(self.__INT_HIGH, int_low_v, iend) 

316 # If the last value in 'int_low' does not exist, then use start of the edge. 

317 else: 

318 int_high_v = self.__area_first(self.__INT_HIGH, ibegin, iend) 

319 

320 # Calculate distance to the intermediate points from all candidates that exist. 

321 indices = np.array( 

322 ( 

323 ibegin, 

324 iend, 

325 0 if int_high_v is None else int_high_v, 

326 0 if int_low_v is None else int_low_v, 

327 ) 

328 ) 

329 diffs = np.abs( 

330 np.array( 

331 ( 

332 vint - self._vv[ibegin], 

333 self._vv[iend] - vint, 

334 vmax if int_high_v is None else vint - self._vv[int_high_v], 

335 vmax if int_low_v is None else self._vv[int_low_v] - vint, 

336 ) 

337 ) 

338 ) 

339 

340 # Take the index of the point that is the nearest to the intermediate level. 

341 iint = indices[np.argmin(diffs)] 

342 

343 # Build edge and return it. 

344 return { 

345 "edge_type": edge_type, 

346 "ibegin": int(ibegin), 

347 "hbegin": float(hbegin), 

348 "vbegin": float(vbegin), 

349 "iintermediate": int(iint), 

350 "hintermediate": float(self._hv[iint]), 

351 "vintermediate": float(self._vv[iint]), 

352 "iend": int(iend), 

353 "hend": float(hend), 

354 "vend": float(vend), 

355 } 

356 

357 def __extract_runt_edges(self, edge_type: Type, begin: np.int_, end: np.int_) -> tuple[Edge, Edge]: 

358 """Extracts a combination of runt edges from the ``begin`` of the first edge and the ``end`` of the 

359 last edge. 

360 

361 :param edge_type: The type of the first edge to extract. 

362 :param begin: The value for the beginning of the edge. 

363 :param end: The value for the end of the edge. 

364 :raise EdgesError: The type of edge to extract must be a runt type of edge. 

365 :raise EdgesError: The ``end`` reference value is not in the range `0 <= end < len(values)`. 

366 :raise EdgesError: The ``begin`` reference value is not in the range `0 <= begin < len(values)`. 

367 :raise EdgesError: The ``begin`` and end reference values is do not satisfy `begin < end`. 

368 :return: The two runt edges extracted.""" 

369 # Ensure the edge type is a runt edge. 

370 if edge_type not in (Type.FALLING_RUNT, Type.RISING_RUNT): 370 ↛ 371line 370 didn't jump to line 371, because the condition on line 370 was never true

371 raise EdgesError("The type of edge to extract must be of runt type.") 

372 # Ensure the begin value is in the range 0 <= begin < len(values). 

373 if begin < 0 or begin >= len(self._vv): 373 ↛ 374line 373 didn't jump to line 374, because the condition on line 373 was never true

374 raise EdgesError(f"The begin, {begin}, reference value is not in the range 0 <= begin < len(values).") 

375 # Ensure the end value is in the range 0 <= end < len(values). 

376 if end < 0 or end >= len(self._vv): 376 ↛ 377line 376 didn't jump to line 377, because the condition on line 376 was never true

377 raise EdgesError(f"The end, {end}, reference value is not in the range 0 <= end < len(values).") 

378 # Ensure the begin occurs before the end. 

379 if begin >= end: 379 ↛ 380line 379 didn't jump to line 380, because the condition on line 379 was never true

380 raise EdgesError(f"The begin, {begin}, and end, {end}, reference values do not satisfy begin < end.") 

381 

382 # Build temporary signal from the portion of the signal with the runt edges, and calculate the state 

383 # levels for that area. TODO: Allow to get these from outside, for now use defaults. 

384 hvalues = self._hv[begin : end + 1] 

385 vvalues = self._vv[begin : end + 1] 

386 self.__area_signal.load(hvalues, vvalues) 

387 (state_levels, _) = self.__area_signal.state_levels() 

388 edges: list[Edge] = [] 

389 

390 if edge_type is Type.FALLING_RUNT: 

391 # Extract 'low' area of the area signal. 

392 low_area = np.where(vvalues < np.float_(state_levels.low))[0] 

393 

394 # Calculate end of the falling edge as the first point, not 'begin', in the 'low' area. 

395 edge_begin = begin 

396 edge_end = begin + low_area[0] if low_area[0] > 0 else begin + low_area[1] 

397 edges.append(self.__extract_edge(Type.FALLING_RUNT, edge_begin, edge_end)) 

398 

399 # Calculate begin of the rising edge as the last point, not 'end', in the 'low' area. 

400 edge_begin = begin + low_area[-1] if low_area[-1] < (len(vvalues) - 1) else begin + low_area[-2] 

401 edge_end = end 

402 edges.append(self.__extract_edge(Type.RISING_RUNT, edge_begin, edge_end)) 

403 else: 

404 # Extract 'high' area of the area signal. 

405 high_area = np.where(vvalues > np.float_(state_levels.high))[0] 

406 

407 # Calculate end of the rising edge as the first point, not 'begin', in the 'high' area. 

408 edge_begin = begin 

409 edge_end = begin + high_area[0] if high_area[0] > 0 else begin + high_area[1] 

410 edges.append(self.__extract_edge(Type.RISING_RUNT, edge_begin, edge_end)) 

411 

412 # Calculate begin of the falling edge as the last point, not 'end', in the 'high' area. 

413 edge_begin = begin + high_area[-1] if high_area[-1] < (len(vvalues) - 1) else begin + high_area[-2] 

414 edge_end = end 

415 edges.append(self.__extract_edge(Type.FALLING_RUNT, edge_begin, edge_end)) 

416 

417 return (edges[0], edges[1]) 

418 

419 ## Protected API ################################################################################################### 

420 

421 ## Public API ###################################################################################################### 

422 def edges(self, levels: StateLevels, int_policy: IntPointPolicy = IntPointPolicy.POLICY_0) -> tuple[Edge, ...]: 

423 """Extracts the edges in the signal from the state levels given. 

424 

425 :param levels: State levels. 

426 :param int_policy: The policy to use for intermediate point calculation. 

427 :raise EdgesError: Invalid state levels. 

428 :raise EdgesError: Assertion error in the algorithm for the signal provided. 

429 :return: The edges found in order of appearance in the signal.""" 

430 # pylint: disable=too-complex,too-many-branches,too-many-statements,redefined-variable-type 

431 

432 # Edges collected. 

433 edges = [] 

434 

435 # Update thresholds from the state levels provided. 

436 self.__area_update(levels) 

437 # Store intermediate point policy. 

438 self.__int_policy = int_policy 

439 

440 # Set the initial type of edge to look for based on which logical state the signal enters first. 

441 high_value = self.__area_first(self.__HIGH, np.int_(0)) 

442 low_value = self.__area_first(self.__LOW, np.int_(0)) 

443 edge_search: Type 

444 curr_value: np.int_ 

445 # There are both 'low' and 'high' values, check which one occurs first. 

446 if low_value is not None and high_value is not None: 

447 if high_value < low_value: 

448 # A 'high' occurs first, thus we are in 'high' looking for a falling edge. 

449 curr_value = high_value 

450 edge_search = Type.FALLING 

451 else: 

452 # A 'low' occurs first, thus we are in 'low' looking for a rising edge. 

453 curr_value = low_value 

454 edge_search = Type.RISING 

455 # No 'high' exists, thus we are in 'low' looking for a rising edge. 

456 elif low_value is not None and high_value is None: 

457 curr_value = low_value 

458 edge_search = Type.RISING 

459 # No 'low' exists, thus we are in 'high' looking for a falling edge. 

460 elif low_value is None and high_value is not None: 

461 curr_value = high_value 

462 edge_search = Type.FALLING 

463 # No 'low' nor 'high' exists, which implies there are no edges at all in the signal. 

464 else: 

465 return tuple(edges) 

466 

467 # Run indefinitely until an exit condition is reached while searching for edges. 

468 while True: # pylint: disable=while-used 

469 # In the first phase of the edge search, the type of edges to look for is determined, this can be one of: 

470 # - A rising edge. 

471 # - A falling edge. 

472 # - A runt rising edge followed by a runt falling edge. 

473 # - A runt falling edge followed by a runt rising edge. 

474 

475 ############################################################################################################ 

476 if edge_search is Type.FALLING: 

477 # The next edge, if any, is a falling edge and we are currently in 'high', it can be one of: 

478 # - One falling edge, which transitions from 'high' to 'low'. 

479 # - Two runt edges, one falling from 'high' to 'runt_low', one rising from 'runt_low' to 'high'. 

480 

481 # Get the first value in 'low' from the current index. 

482 low_value = self.__area_first(self.__LOW, curr_value) 

483 # Get the first value in 'runt_low' from the current index. 

484 runt_low_value = self.__area_first(self.__RUNT_LOW, curr_value) 

485 # Get the first value in 'high' after 'runt_low' current index, if it exists. 

486 high_value = None if runt_low_value is None else self.__area_first(self.__HIGH, runt_low_value) 

487 

488 # If there is a value in both 'low' and 'runt_low', then 'high' and ordering needs to be checked. 

489 if low_value is not None and runt_low_value is not None: 

490 # If 'low' occurs before 'runt_low' then it is a single edge. 

491 if low_value < runt_low_value: 

492 edge_search = Type.FALLING 

493 # 'runt_low' occurs before 'low', if no 'high' value then it is a single edge. 

494 elif high_value is None: 

495 edge_search = Type.FALLING 

496 # 'runt_low' occurs before 'low', if 'high' occurs before 'low' it is runt edges. 

497 elif high_value < low_value: 

498 edge_search = Type.FALLING_RUNT 

499 # 'runt_low' occurs before 'low', and 'low' occurs before 'high', thus it is a single edge. 

500 else: 

501 edge_search = Type.FALLING 

502 # If there is a value in 'low' and no value in 'runt_low' then it is a single edge. 

503 elif low_value is not None and runt_low_value is None: 

504 edge_search = Type.FALLING 

505 # If there no value in 'low' and there is a value in 'runt_low', 'high' needs to be checked. 

506 elif low_value is None and runt_low_value is not None: 

507 # If there is a value in 'high' it is a pair of runt edges, otherwise it is end of processing. 

508 if high_value is not None: 

509 edge_search = Type.FALLING_RUNT 

510 else: 

511 break 

512 # If there are no values in 'low' or 'runt_low', then it is the end of the processing. 

513 else: 

514 break 

515 

516 # Extract falling edge. 

517 if edge_search is Type.FALLING: 

518 # Ensure 'low' exists at this point. 

519 if low_value is None: 519 ↛ 520line 519 didn't jump to line 520, because the condition on line 519 was never true

520 raise EdgesError("No 'low' exists for falling edge extraction.") 

521 

522 # Find the last value in 'high' before 'low', which will be the begin of the edge. 

523 # This value is guaranteed to exist, as the current index is in 'high'. 

524 edge_begin_value = self.__area_last(self.__HIGH, low_value, curr_value) 

525 if edge_begin_value is None: 525 ↛ 526line 525 didn't jump to line 526, because the condition on line 525 was never true

526 raise EdgesError("No edge begin obtained for falling edge.") 

527 

528 # The end of edge is the calculated 'low'. 

529 edge_end_value = low_value 

530 

531 # Extract single edge. 

532 edges.append(self.__extract_edge(Type.FALLING, edge_begin_value, edge_end_value)) 

533 

534 # Move current index to the end of the edge, in 'low'. 

535 curr_value = edge_end_value 

536 # Continue looking for rising edges. 

537 edge_search = Type.RISING 

538 # Extract runt falling edge and runt rising edge. 

539 else: 

540 # Ensure 'runt_low' and 'high' exist at this point. 

541 if runt_low_value is None or high_value is None: 541 ↛ 542line 541 didn't jump to line 542, because the condition on line 541 was never true

542 raise EdgesError("No 'runt_low' or 'high' exists for runt edges extraction.") 

543 

544 # Find the last value in 'high' before 'runt_low', which will be the begin of the runt area. 

545 # This value is guaranteed to exist, as the current index is in 'high'. 

546 edge_begin_value = self.__area_last(self.__HIGH, runt_low_value, curr_value) 

547 if edge_begin_value is None: 547 ↛ 548line 547 didn't jump to line 548, because the condition on line 547 was never true

548 raise EdgesError("No edge begin obtained for runt falling edge before runt rising edge.") 

549 # The end of the runt area is 'high'. 

550 edge_end_value = high_value 

551 

552 # Extract runt edges. 

553 edges.extend(self.__extract_runt_edges(Type.FALLING_RUNT, edge_begin_value, edge_end_value)) 

554 

555 # Move current index to the end of the edge, in 'high'. 

556 curr_value = edge_end_value 

557 # Continue looking for falling edges. 

558 edge_search = Type.FALLING 

559 ############################################################################################################ 

560 else: 

561 # The next edge, if any, is a rising edge and we are currently in 'low', it can be one of: 

562 # - One rising edge, which transitions from 'low' to 'high'. 

563 # - Two runt edges, one rising from 'low' to 'runt_high', one falling from 'runt_high' to 'low'. 

564 

565 # Get the first value in 'high' from the current index. 

566 high_value = self.__area_first(self.__HIGH, curr_value) 

567 # Get the first value in 'runt_high' from the current index. 

568 runt_high_value = self.__area_first(self.__RUNT_HIGH, curr_value) 

569 # Get the first value in 'low' after 'runt_high' current index, if it exists. 

570 low_value = None if runt_high_value is None else self.__area_first(self.__LOW, runt_high_value) 

571 

572 # If there is a value in both 'high' and 'runt_high', then 'low' and ordering needs to be checked. 

573 if high_value is not None and runt_high_value is not None: 

574 # If 'high' occurs before 'runt_high' then it is a single edge. 

575 if high_value < runt_high_value: 

576 edge_search = Type.RISING 

577 # 'runt_high' occurs before 'high', if no 'low' value then it is a single edge. 

578 elif low_value is None: 

579 edge_search = Type.RISING 

580 # 'runt_high' occurs before 'high', if 'low' occurs before 'high' it is runt edges. 

581 elif low_value < high_value: 

582 edge_search = Type.RISING_RUNT 

583 # 'runt_high' occurs before 'high', and 'high' occurs before 'low', thus it is a single edge. 

584 else: 

585 edge_search = Type.RISING 

586 # If there is a value in 'high' and no value in 'runt_high' then it is a single edge. 

587 elif high_value is not None and runt_high_value is None: 

588 edge_search = Type.RISING 

589 # If there no value in 'high' and there is a value in 'runt_high', 'low' needs to be checked. 

590 elif high_value is None and runt_high_value is not None: 

591 # If there is a value in 'low' it is a pair of runt edges, otherwise it is end of processing. 

592 if low_value is not None: 

593 edge_search = Type.RISING_RUNT 

594 else: 

595 break 

596 # If there are no values in 'high' or 'runt_high', then it is the end of the processing. 

597 else: 

598 break 

599 

600 # Extract rising edge. 

601 if edge_search is Type.RISING: 

602 # Ensure 'high' exists at this point. 

603 if high_value is None: 603 ↛ 604line 603 didn't jump to line 604, because the condition on line 603 was never true

604 raise EdgesError("No 'high' exists for rising edge extraction.") 

605 

606 # Find the last value in 'low' before 'high', which will be the begin of the edge. 

607 # This value is guaranteed to exist, as the current index is in 'low'. 

608 edge_begin_value = self.__area_last(self.__LOW, high_value, curr_value) 

609 if edge_begin_value is None: 609 ↛ 610line 609 didn't jump to line 610, because the condition on line 609 was never true

610 raise EdgesError("No edge begin obtained for rising edge.") 

611 

612 # The end of edge is the calculated 'high'. 

613 edge_end_value = high_value 

614 

615 # Extract single edge. 

616 edges.append(self.__extract_edge(Type.RISING, edge_begin_value, edge_end_value)) 

617 

618 # Move current index to the end of the edge, in 'high'. 

619 curr_value = edge_end_value 

620 # Continue looking for falling edges. 

621 edge_search = Type.FALLING 

622 # Extract runt rising edge and runt falling edge. 

623 else: 

624 # Ensure 'runt_high' and 'low' exist at this point. 

625 if runt_high_value is None or low_value is None: 625 ↛ 626line 625 didn't jump to line 626, because the condition on line 625 was never true

626 raise EdgesError("No 'runt_high' or 'low' exists for runt edges extraction.") 

627 

628 # Find the last value in 'low' before 'runt_high', which will be the begin of the edge. 

629 # This value is guaranteed to exist, as the current index is in 'low'. 

630 edge_begin_value = self.__area_last(self.__LOW, runt_high_value, curr_value) 

631 if edge_begin_value is None: 631 ↛ 632line 631 didn't jump to line 632, because the condition on line 631 was never true

632 raise EdgesError("No edge begin obtained for runt rising edge before runt falling edge.") 

633 

634 # The end of the edge is 'low'. 

635 edge_end_value = low_value 

636 

637 # Extract runt edges. 

638 edges.extend(self.__extract_runt_edges(Type.RISING_RUNT, edge_begin_value, edge_end_value)) 

639 

640 # Move current index to the end of the edge, in 'low'. 

641 curr_value = edge_end_value 

642 # Continue looking for rising edges. 

643 edge_search = Type.RISING 

644 

645 return tuple(edges) 

646 

647 def edges_to_array( 

648 self, 

649 edges: Sequence[Edge], 

650 array_id: Literal["begin", "intermediate", "end"], 

651 ) -> tuple[npt.NDArray[np.float_], npt.NDArray[np.float_]]: 

652 """Converts values in a sequence of edges to relevant arrays. 

653 

654 Note that for ``intermediate``, if two edges share the same intermediate point, two times that 

655 value will be included in the array to keep the length of the arrays consistent. 

656 

657 :param edges: The sequence of edges. 

658 :param array_id: The array identifier, which defines the type of values to take. 

659 :raise EdgesError: The sequence of edges given has no edges. 

660 :raise EdgesError: The array identifier provided is not valid. 

661 :return: The values of the horizontal axis and the values of the vertical axis for the sequence of edges.""" 

662 # Ensure the length of the edges passed is not zero. 

663 if len(edges) == 0: 

664 raise EdgesError(f"Unable to get array '{array_id}' from empty sequence of edges.") 

665 

666 # Get relevant indices. 

667 if array_id == "begin": 

668 indices = np.asarray([i["ibegin"] for i in edges]) 

669 elif array_id == "intermediate": 

670 indices = np.asarray([i["iintermediate"] for i in edges]) 

671 elif array_id == "end": 

672 indices = np.asarray([i["iend"] for i in edges]) 

673 else: 

674 raise EdgesError(f"The array identifier '{array_id}' provided is invalid.") 

675 # Return relevant arrays with the values. 

676 return (np.copy(self._hv[indices]), np.copy(self._vv[indices])) 

677 

678 def edges_plot( 

679 self, 

680 path: str, 

681 edges: Sequence[Edge], 

682 *args, 

683 begin: float | None = None, 

684 end: float | None = None, 

685 munits: float = 0, 

686 points: Sequence[Literal["begin", "intermediate", "end"]] = (), 

687 **kwargs, 

688 ) -> Self: 

689 """Performs a plot of the edges of the signal. 

690 

691 :param path: The path where to store the plot, see :meth:`.Plotter.plot`. 

692 :param edges: The edges to plot, if there are no edges, then no points will be plotted for edges. 

693 :param args: Additional arguments to pass to the plotting function, see :meth:`.Plotter.plot`. 

694 :param begin: The begin value of the horizontal axis where the plot starts, see :meth:`.Plotter.plot`. 

695 :param end: The end value of the horizontal axis where the plot ends, see :meth:`.Plotter.plot`. 

696 :param munits: Margin units for the plot, see :meth:`.Plotter.plot`. 

697 :param points: The type of edge points to plot, defaults to all edge points. 

698 :param kwargs: Additional keyword arguments to pass to the plotting function, see :meth:`.Plotter.plot`. 

699 :return: Instance of the class.""" 

700 # pylint: disable=too-many-locals 

701 

702 # Create plotter. 

703 plotter = sep.Plotter(sep.Mode.LINEAR, rows=1, columns=1) 

704 

705 # Adjust begin and end values if not provided. 

706 begin = begin if begin is not None else float(self._hv[0]) 

707 end = end if end is not None else float(self._hv[-1]) 

708 

709 # Create plot for the signal. 

710 spl = sep.Subplot("Signal", self._hv, self._hunits, self._vv, self._vunits, begin, end, munits, "red") 

711 plotter.add_plot(0, 0, spl) 

712 

713 # Add specified points for edges, if there are any edges. 

714 if len(edges) > 0: 

715 for point in ("begin", "intermediate", "end"): 

716 points_dict = { 

717 "begin": ("Begin Edge Point", ">"), 

718 "intermediate": ("Intermediate Edge Point", "8"), 

719 "end": ("End Edge Point", "<"), 

720 } 

721 

722 if len(points) == 0 or point in points: 

723 (edges_x, edges_y) = self.edges_to_array(edges, point) 

724 

725 # For intermediate points, the values can be repeated for edges that share the same point, 

726 # fetch unique values for plotting. 

727 if point == "intermediate": 

728 unique_indices = np.unique(edges_x, return_index=True)[1] 

729 (edges_x, edges_y) = (edges_x[unique_indices], edges_y[unique_indices]) 

730 

731 subplot = sep.Subplot( 

732 points_dict[point][0], 

733 edges_x, 

734 self._hunits, 

735 edges_y, 

736 self._vunits, 

737 begin, 

738 end, 

739 munits, 

740 "white", 

741 linestyle="none", 

742 marker=points_dict[point][1], 

743 ) 

744 plotter.add_plot(0, 0, subplot) 

745 

746 # Create plot. 

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

748 

749 return self