/ CLUE_Sensor_Plotter / test_Plotter.py
test_Plotter.py
1 # SPDX-FileCopyrightText: 2020 Kevin J Walters for Adafruit Industries 2 # 3 # SPDX-License-Identifier: MIT 4 5 # The MIT License (MIT) 6 # 7 # Copyright (c) 2020 Kevin J. Walters 8 # 9 # Permission is hereby granted, free of charge, to any person obtaining a copy 10 # of this software and associated documentation files (the "Software"), to deal 11 # in the Software without restriction, including without limitation the rights 12 # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 # copies of the Software, and to permit persons to whom the Software is 14 # furnished to do so, subject to the following conditions: 15 # 16 # The above copyright notice and this permission notice shall be included in 17 # all copies or substantial portions of the Software. 18 # 19 # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 25 # THE SOFTWARE. 26 27 import sys 28 import time 29 import array 30 import os 31 32 import unittest 33 from unittest.mock import Mock, MagicMock, patch 34 35 import numpy 36 37 verbose = int(os.getenv('TESTVERBOSE', '2')) 38 39 # Mocking libraries which are about to be import'd by Plotter 40 sys.modules['board'] = MagicMock() 41 sys.modules['displayio'] = MagicMock() 42 sys.modules['terminalio'] = MagicMock() 43 sys.modules['adafruit_display_text.label'] = MagicMock() 44 45 # Replicate CircuitPython's time.monotonic_ns() pre 3.5 46 if not hasattr(time, "monotonic_ns"): 47 time.monotonic_ns = lambda: int(time.monotonic() * 1e9) 48 49 50 # Borrowing the dhalbert/tannewt technique from adafruit/Adafruit_CircuitPython_Motor 51 sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) 52 53 # pylint: disable=wrong-import-position 54 # import what we are testing 55 from plotter import Plotter 56 57 import terminalio # mocked 58 terminalio.FONT = Mock() 59 terminalio.FONT.get_bounding_box = Mock(return_value=(6, 14)) 60 61 62 # TODO use setup() and tearDown() 63 # - https://docs.python.org/3/library/unittest.html#unittest.TestCase.tearDown 64 65 66 # pylint: disable=protected-access, no-self-use, too-many-locals 67 class Test_Plotter(unittest.TestCase): 68 """Tests for Plotter. 69 Very useful but code needs a good tidy particulary around widths, 70 lots of 200 hard-coded numbers. 71 Would benefit from testing different widths too.""" 72 # These were the original dimensions of the Bitmap 73 # Current clue-plotter uses 192 for width and 74 # scrolling is set to 50 75 _PLOT_WIDTH = 200 76 _PLOT_HEIGHT = 201 77 _SCROLL_PX = 25 78 79 def count_nz_rows(self, bitmap): 80 nz_rows = [] 81 for y_pos in range(self._PLOT_HEIGHT): 82 count = 0 83 for x_pos in range(self._PLOT_WIDTH): 84 if bitmap[x_pos, y_pos] != 0: 85 count += 1 86 if count > 0: 87 nz_rows.append(y_pos) 88 return nz_rows 89 90 def aprint_plot(self, bitmap): 91 for y in range(self._PLOT_HEIGHT): 92 for x in range(self._PLOT_WIDTH): 93 print("X" if bitmap[x][y] else " ", end="") 94 print() 95 96 def make_a_Plotter(self, style, mode, scale_mode=None): 97 mocked_display = Mock() 98 99 plotter = Plotter(mocked_display, 100 style=style, 101 mode=mode, 102 scale_mode=scale_mode, 103 scroll_px=self._SCROLL_PX, 104 plot_width=self._PLOT_WIDTH, 105 plot_height=self._PLOT_HEIGHT, 106 title="Debugging", 107 max_title_len=99, 108 mu_output=False, 109 debug=0) 110 111 return plotter 112 113 def ready_plot_source(self, plttr, source): 114 #source_name = str(source) 115 116 plttr.clear_all() 117 #plttr.title = source_name 118 #plttr.y_axis_lab = source.units() 119 plttr.y_range = (source.initial_min(), source.initial_max()) 120 plttr.y_full_range = (source.min(), source.max()) 121 plttr.y_min_range = source.range_min() 122 channels_from_source = source.values() 123 plttr.channels = channels_from_source 124 plttr.channel_colidx = (1, 2, 3) 125 source.start() 126 return (source, channels_from_source) 127 128 def make_a_PlotSource(self, channels = 1): 129 ps = Mock() 130 ps.initial_min = Mock(return_value=-100.0) 131 ps.initial_max = Mock(return_value=100.0) 132 ps.min = Mock(return_value=-100.0) 133 ps.max = Mock(return_value=100.0) 134 ps.range_min = Mock(return_value=5.0) 135 if channels == 1: 136 ps.values = Mock(return_value=channels) 137 ps.data = Mock(side_effect=list(range(10,90)) * 100) 138 elif channels == 3: 139 ps.values = Mock(return_value=channels) 140 ps.data = Mock(side_effect=list(zip(list(range(10,90)), 141 list(range(15,95)), 142 list(range(40,60)) * 4)) * 100) 143 return ps 144 145 146 def make_a_PlotSource_narrowrange(self): 147 ps = Mock() 148 ps.initial_min = Mock(return_value=0.0) 149 ps.initial_max = Mock(return_value=500.0) 150 ps.min = Mock(return_value=0.0) 151 ps.max = Mock(return_value=500.0) 152 ps.range_min = Mock(return_value=5.0) 153 154 ps.values = Mock(return_value=1) 155 # 24 elements repeated 13 times ranging between 237 and 253 156 # 5 elements repeated 6000 times 157 ps.data = Mock(side_effect=(list(range(237, 260 + 1)) * 13 158 + list(range(100, 400 + 1, 75)) * 6000)) 159 return ps 160 161 162 def make_a_PlotSource_onespike(self): 163 ps = Mock() 164 ps.initial_min = Mock(return_value=-100.0) 165 ps.initial_max = Mock(return_value=100.0) 166 ps.min = Mock(return_value=-100.0) 167 ps.max = Mock(return_value=100.0) 168 ps.range_min = Mock(return_value=5.0) 169 170 ps.values = Mock(return_value=1) 171 ps.data = Mock(side_effect=([0]*95 + [5,10,20,50,80,90,70,30,20,10] 172 + [0] * 95 + [1] * 1000)) 173 174 return ps 175 176 def make_a_PlotSource_bilevel(self, first_v=60, second_v=700): 177 ps = Mock() 178 ps.initial_min = Mock(return_value=-100.0) 179 ps.initial_max = Mock(return_value=100.0) 180 ps.min = Mock(return_value=-1000.0) 181 ps.max = Mock(return_value=1000.0) 182 ps.range_min = Mock(return_value=10.0) 183 184 ps.values = Mock(return_value=1) 185 ps.data = Mock(side_effect=[first_v] * 199 + [second_v] * 1001) 186 187 return ps 188 189 190 def test_spike_after_wrap_and_overwrite_one_channel(self): 191 """A specific test to check that a spike that appears in wrap mode is 192 correctly cleared by subsequent flat data.""" 193 plotter = self.make_a_Plotter("lines", "wrap") 194 (tg, plot) = (Mock(), numpy.zeros((self._PLOT_WIDTH, self._PLOT_HEIGHT), numpy.uint8)) 195 plotter.display_on(tg_and_plot=(tg, plot)) 196 test_source1 = self.make_a_PlotSource_onespike() 197 self.ready_plot_source(plotter, test_source1) 198 199 unique1, _ = numpy.unique(plot, return_counts=True) 200 self.assertTrue(numpy.alltrue(unique1 == [0]), 201 "Checking all pixels start as 0") 202 203 # Fill screen 204 for _ in range(200): 205 plotter.data_add((test_source1.data(),)) 206 207 unique2, _ = numpy.unique(plot, return_counts=True) 208 self.assertTrue(numpy.alltrue(unique2 == [0, 1]), 209 "Checking pixels are now a mix of 0 and 1") 210 211 # Rewrite whole screen with new data as we are in wrap mode 212 for _ in range(190): 213 plotter.data_add((test_source1.data(),)) 214 215 non_zero_rows = self.count_nz_rows(plot) 216 217 if verbose >= 4: 218 print("y=99", plot[:, 99]) 219 print("y=100", plot[:, 100]) 220 221 self.assertTrue(9 not in non_zero_rows, 222 "Check nothing is just above 90 which plots at 10") 223 self.assertEqual(non_zero_rows, [99, 100], 224 "Only pixels left plotted should be from" 225 + "values 0 and 1 being plotted at 99 and 100") 226 self.assertTrue(numpy.alltrue(plot[:, 99] == [1] * 190 + [0] * 10), 227 "Checking row 99 precisely") 228 self.assertTrue(numpy.alltrue(plot[:, 100] == [0] * 190 + [1] * 10), 229 "Checking row 100 precisely") 230 231 plotter.display_off() 232 233 234 def test_clearmode_from_lines_wrap_to_dots_scroll(self): 235 """A specific test to check that a spike that appears in lines wrap mode is 236 correctly cleared by a change to dots scroll.""" 237 plotter = self.make_a_Plotter("lines", "wrap") 238 (tg, plot) = (Mock(), numpy.zeros((self._PLOT_WIDTH, self._PLOT_HEIGHT), numpy.uint8)) 239 plotter.display_on(tg_and_plot=(tg, plot)) 240 test_source1 = self.make_a_PlotSource_onespike() 241 self.ready_plot_source(plotter, test_source1) 242 243 unique1, _ = numpy.unique(plot, return_counts=True) 244 self.assertTrue(numpy.alltrue(unique1 == [0]), 245 "Checking all pixels start as 0") 246 247 # Fill screen then wrap to write another 20 values 248 for _ in range(200 + 20): 249 plotter.data_add((test_source1.data(),)) 250 251 unique2, _ = numpy.unique(plot, return_counts=True) 252 self.assertTrue(numpy.alltrue(unique2 == [0, 1]), 253 "Checking pixels are now a mix of 0 and 1") 254 255 plotter.change_stylemode("dots", "scroll") 256 unique3, _ = numpy.unique(plot, return_counts=True) 257 self.assertTrue(numpy.alltrue(unique3 == [0]), 258 "Checking all pixels are now 0 after change_stylemode") 259 260 plotter.display_off() 261 262 263 def test_clear_after_scrolling_one_channel(self): 264 """A specific test to check screen clears after a scroll to help 265 investigate a bug with that failing to happen in most cases.""" 266 plotter = self.make_a_Plotter("lines", "scroll") 267 (tg, plot) = (Mock(), numpy.zeros((self._PLOT_WIDTH, self._PLOT_HEIGHT), numpy.uint8)) 268 plotter.display_on(tg_and_plot=(tg, plot)) 269 test_source1 = self.make_a_PlotSource() 270 self.ready_plot_source(plotter, test_source1) 271 272 unique1, _ = numpy.unique(plot, return_counts=True) 273 self.assertTrue(numpy.alltrue(unique1 == [0]), 274 "Checking all pixels start as 0") 275 276 # Fill screen 277 for _ in range(200): 278 plotter.data_add((test_source1.data(),)) 279 280 unique2, _ = numpy.unique(plot, return_counts=True) 281 self.assertTrue(numpy.alltrue(unique2 == [0, 1]), 282 "Checking pixels are now a mix of 0 and 1") 283 self.assertEqual(plotter._values, 200) 284 self.assertEqual(plotter._data_values, 200) 285 286 # Force a single scroll of the data 287 for _ in range(10): 288 plotter.data_add((test_source1.data(),)) 289 290 self.assertEqual(plotter._values, 200 + 10) 291 self.assertEqual(plotter._data_values, 200 + 10 - self._SCROLL_PX) 292 293 # This should clear all data and the screen 294 if verbose >= 3: 295 print("change_stylemode() to a new mode which will clear screen") 296 plotter.change_stylemode("dots", "wrap") 297 unique3, _ = numpy.unique(plot, return_counts=True) 298 self.assertTrue(numpy.alltrue(unique3 == [0]), 299 "Checking all pixels are now 0") 300 301 plotter.display_off() 302 303 def test_check_internal_data_three_channels(self): 304 width = self._PLOT_WIDTH 305 plotter = self.make_a_Plotter("lines", "scroll") 306 (tg, plot) = (Mock(), numpy.zeros((width, self._PLOT_HEIGHT), numpy.uint8)) 307 plotter.display_on(tg_and_plot=(tg, plot)) 308 test_triplesource1 = self.make_a_PlotSource(channels=3) 309 310 self.ready_plot_source(plotter, test_triplesource1) 311 312 unique1, _ = numpy.unique(plot, return_counts=True) 313 self.assertTrue(numpy.alltrue(unique1 == [0]), 314 "Checking all pixels start as 0") 315 316 # Three data samples 317 all_data = [] 318 for d_idx in range(3): 319 all_data.append(test_triplesource1.data()) 320 plotter.data_add(all_data[-1]) 321 322 # all_data is now [(10, 15, 40), (11, 16, 41), (12, 17, 42)] 323 self.assertEqual(plotter._data_y_pos[0][0:3], 324 array.array('i', [90, 89, 88]), 325 "channel 0 plotted y positions") 326 self.assertEqual(plotter._data_y_pos[1][0:3], 327 array.array('i', [85, 84, 83]), 328 "channel 1 plotted y positions") 329 self.assertEqual(plotter._data_y_pos[2][0:3], 330 array.array('i', [60, 59, 58]), 331 "channel 2 plotted y positions") 332 333 # Fill rest of screen 334 for d_idx in range(197): 335 all_data.append(test_triplesource1.data()) 336 plotter.data_add(all_data[-1]) 337 338 # Three values more values to force a scroll 339 for d_idx in range(3): 340 all_data.append(test_triplesource1.data()) 341 plotter.data_add(all_data[-1]) 342 343 # all_data[-4] is (49, 54, 59) 344 # all_data[-3:0] is [(50, 55, 40) (51, 56, 41) (52, 57, 42)] 345 expected_data_size = width - self._SCROLL_PX + 3 346 st_x_pos = width - self._SCROLL_PX 347 d_idx = plotter._data_idx - 3 348 349 self.assertTrue(self._SCROLL_PX > 3, 350 "Ensure no scrolling occurred from recent 3 values") 351 # the data_idx here is 2 because the size is now plot_width + 1 352 self.assertEqual(plotter._data_idx, 2) 353 self.assertEqual(plotter._x_pos, st_x_pos + 3) 354 self.assertEqual(plotter._data_values, expected_data_size) 355 self.assertEqual(plotter._values, len(all_data)) 356 357 if verbose >= 4: 358 print("YP",d_idx, plotter._data_y_pos[0][d_idx:d_idx+3]) 359 print("Y POS", [str(plotter._data_y_pos[ch_idx][d_idx:d_idx+3]) 360 for ch_idx in [0, 1, 2]]) 361 ch0_ypos = [50, 49, 48] 362 self.assertEqual([plotter._data_y_pos[0][idx] for idx in range(d_idx, d_idx + 3)], 363 ch0_ypos, 364 "channel 0 plotted y positions") 365 ch1_ypos = [45, 44, 43] 366 self.assertEqual([plotter._data_y_pos[1][idx] for idx in range(d_idx, d_idx + 3)], 367 ch1_ypos, 368 "channel 1 plotted y positions") 369 ch2_ypos = [60, 59, 58] 370 self.assertEqual([plotter._data_y_pos[2][idx] for idx in range(d_idx, d_idx + 3)], 371 ch2_ypos, 372 "channel 2 plotted y positions") 373 374 # Check for plot points - fortunately none overlap 375 total_pixel_matches = 0 376 for ch_idx, ch_ypos in enumerate((ch0_ypos, ch1_ypos, ch2_ypos)): 377 expected = plotter.channel_colidx[ch_idx] 378 for idx, y_pos in enumerate(ch_ypos): 379 actual = plot[st_x_pos+idx, y_pos] 380 if actual == expected: 381 total_pixel_matches += 1 382 else: 383 if verbose >= 4: 384 print("Pixel value for channel", 385 "{:d}, naive expectation {:d},".format(ch_idx, 386 expected), 387 "actual {:d} at {:d}, {:d}, {:d}".format(idx, 388 actual, 389 st_x_pos + idx, 390 y_pos)) 391 # Only 7 out of 9 will match because channel 2 put a vertical 392 # line at x position 175 over-writing ch0 and ch1 393 self.assertEqual(total_pixel_matches, 7, "plotted pixels check") 394 # Check for that line from pixel positions 42 to 60 395 for y_pos in range(42, 60 + 1): 396 self.assertEqual(plot[st_x_pos, y_pos], 397 plotter.channel_colidx[2], 398 "channel 2 (over-writing) vertical line") 399 400 plotter.display_off() 401 402 def test_clear_after_scrolling_three_channels(self): 403 """A specific test to check screen clears after a scroll with 404 multiple channels being plotted (three) to help 405 investigate a bug with that failing to happen in most cases 406 for the second and third channels.""" 407 plotter = self.make_a_Plotter("lines", "scroll") 408 (tg, plot) = (Mock(), numpy.zeros((self._PLOT_WIDTH, self._PLOT_HEIGHT), numpy.uint8)) 409 plotter.display_on(tg_and_plot=(tg, plot)) 410 test_triplesource1 = self.make_a_PlotSource(channels=3) 411 412 self.ready_plot_source(plotter, test_triplesource1) 413 414 unique1, _ = numpy.unique(plot, return_counts=True) 415 self.assertTrue(numpy.alltrue(unique1 == [0]), 416 "Checking all pixels start as 0") 417 418 # Fill screen 419 for _ in range(200): 420 plotter.data_add(test_triplesource1.data()) 421 422 unique2, _ = numpy.unique(plot, return_counts=True) 423 self.assertTrue(numpy.alltrue(unique2 == [0, 1, 2, 3]), 424 "Checking pixels are now a mix of 0, 1, 2, 3") 425 # Force a single scroll of the data 426 for _ in range(10): 427 plotter.data_add(test_triplesource1.data()) 428 429 # This should clear all data and the screen 430 if verbose >= 3: 431 print("change_stylemode() to a new mode which will clear screen") 432 plotter.change_stylemode("dots", "wrap") 433 unique3, _ = numpy.unique(plot, return_counts=True) 434 self.assertTrue(numpy.alltrue(unique3 == [0]), 435 "Checking all pixels are now 0") 436 437 plotter.display_off() 438 439 def test_auto_rescale_wrap_mode(self): 440 """Ensure the auto-scaling is working and not leaving any remnants of previous plot.""" 441 plotter = self.make_a_Plotter("lines", "wrap") 442 (tg, plot) = (Mock(), numpy.zeros((self._PLOT_WIDTH, self._PLOT_HEIGHT), numpy.uint8)) 443 plotter.display_on(tg_and_plot=(tg, plot)) 444 test_source1 = self.make_a_PlotSource_bilevel(first_v=60, second_v=900) 445 446 self.ready_plot_source(plotter, test_source1) 447 448 unique1, _ = numpy.unique(plot, return_counts=True) 449 self.assertTrue(numpy.alltrue(unique1 == [0]), 450 "Checking all pixels start as 0") 451 452 # Fill screen with first 200 453 for _ in range(200): 454 plotter.data_add((test_source1.data(),)) 455 456 non_zero_rows1 = self.count_nz_rows(plot) 457 self.assertEqual(non_zero_rows1, list(range(0, 40 + 1)), 458 "From value 60 being plotted at 40 but also upward line at end") 459 460 # Rewrite screen with next 200 but these should force an internal 461 # rescaling of y axis 462 for _ in range(200): 463 plotter.data_add((test_source1.data(),)) 464 465 self.assertEqual(plotter.y_range, (-108.0, 1000.0), 466 "Check rescaled y range") 467 468 non_zero_rows2 = self.count_nz_rows(plot) 469 self.assertEqual(non_zero_rows2, [18], 470 "Only pixels now should be from value 900 being plotted at 18") 471 472 plotter.display_off() 473 474 def test_rescale_zoom_in_minequalsmax(self): 475 """Test y_range adjusts any attempt to set the effective range to 0.""" 476 plotter = self.make_a_Plotter("lines", "wrap") 477 (tg, plot) = (Mock(), numpy.zeros((self._PLOT_WIDTH, self._PLOT_HEIGHT), numpy.uint8)) 478 plotter.display_on(tg_and_plot=(tg, plot)) 479 test_source1 = self.make_a_PlotSource_bilevel(first_v=20, second_v=20) 480 481 self.ready_plot_source(plotter, test_source1) 482 # Set y_range to a value which will cause a range of 0 with 483 # the potential dire consequence of divide by zero 484 plotter.y_range = (20, 20) 485 486 plotter.data_add((test_source1.data(),)) 487 y_min, y_max = plotter.y_range 488 self.assertTrue(y_max - y_min > 0, 489 "Range is not zero and implicitly" 490 + "ZeroDivisionError exception has not occurred.") 491 492 plotter.display_off() 493 494 def test_rescale_zoom_in_narrowrangedata(self): 495 """Test y_range adjusts on data from a narrow range with unusual per pixel scaling mode.""" 496 # There was a bug which was visually obvious in pixel scale_mode 497 # test this to ensure bug was squashed 498 499 # time.monotonic_ns.return_value = lambda: global_time_ns 500 501 local_time_ns = time.monotonic_ns() 502 with patch('time.monotonic_ns', create=True, 503 side_effect=lambda: local_time_ns) as _: 504 plotter = self.make_a_Plotter("lines", "wrap", scale_mode="pixel") 505 (tg, plot) = (Mock(), numpy.zeros((self._PLOT_WIDTH, self._PLOT_HEIGHT), numpy.uint8)) 506 plotter.display_on(tg_and_plot=(tg, plot)) 507 test_source1 = self.make_a_PlotSource_narrowrange() 508 509 self.ready_plot_source(plotter, test_source1) 510 511 # About 11 seconds worth - will have zoomed in during this time 512 for _ in range(300): 513 val = test_source1.data() 514 plotter.data_add((val,)) 515 local_time_ns += round(1/27 * 1e9) # emulation of time.sleep(1/27) 516 517 y_min1, y_max1 = plotter.y_range 518 self.assertAlmostEqual(y_min1, 232.4) 519 self.assertAlmostEqual(y_max1, 264.6) 520 521 unique, counts = numpy.unique(plotter._data_y_pos[0], 522 return_counts=True) 523 self.assertEqual(min(unique), 29) 524 self.assertEqual(max(unique), 171) 525 self.assertEqual(len(unique), 24) 526 self.assertLessEqual(max(counts) - min(counts), 1) 527 528 # Another 14 seconds and now data is in narrow range so another zoom is due 529 # Why does this take so long? 530 for _ in range(400): 531 val = test_source1.data() 532 plotter.data_add((val,)) 533 local_time_ns += round(1/27 * 1e9) # emulation of time.sleep(1/27) 534 535 y_min2, y_max2 = plotter.y_range 536 self.assertAlmostEqual(y_min2, 40.0) 537 self.assertAlmostEqual(y_max2, 460.0) 538 539 #unique2, counts2 = numpy.unique(plotter._data_y_pos[0], 540 # return_counts=True) 541 #self.assertEqual(list(unique2), [29, 100, 171]) 542 #self.assertLessEqual(max(counts2) - min(counts2), 1) 543 544 if verbose >= 3: 545 self.aprint_plot(plot) 546 # Look for a specific bug which leaves some previous pixels 547 # set on screen at column 24 548 # Checking either side as this will be timing sensitive but the time 549 # functions are now precisely controlled in this test so should not vary 550 # with test execution duration vs wall clock 551 for offset in range(-15, 15 + 5, 5): 552 self.assertEqual(list(plot[24 + offset][136:172]), [0] * 36, 553 "Checking for erased pixels at various columns") 554 555 plotter.display_off() 556 557 558 if __name__ == '__main__': 559 unittest.main(verbosity=verbose)