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