sensors_clue-sensor-plotter-class-plotter-v3.png
UML class diagram for Plotter which writes to the screen using displayio library. Private operations and some attributes are omitted for brevity.

The Plotter class takes the data and plots it on the screen with optional output to the serial console for plotting by Mu or general data collection.

The full class diagram would reveal a lot of attributes and operations suggesting it's a bulky, complex class. Sometimes this is indicative that the design could benefit from some further decomposition and refinement.

The expected usage after instantiation of the object is:

  1. y_range() - set the initial range on the plot.
  2. y_min_range - optional but limits the degree of zoom in.
  3. y_full_range() - set the absolute range for data from sensor.
  4. channels - set number of elements of data per sample.
  5. channel_colidx - set colours to use in for of sequence of palette numbers.
  6. display_on() - initialize display - once only.
  7. data_add() - use repeatedly to draw new points or lines based on the settings - this will also eventually scroll or wrap.
  8. Goto 7.

Accessibility

From The Role of Accessibility in a Universal Web:

"Universal design" is the process of creating products that are usable by people with the widest possible range of abilities, operating within the widest possible range of situations; whereas "accessibility" primarily refers to design for people with disabilities. While the focus of accessibility is disabilities, research and development in accessibility brings benefits to everyone, particularly users with situational limitations, including device limitations and environmental limitations.

Small devices like the CLUE with its 1.3" (33mm) screen are, by their nature, limited for the visual aspects of accessibility but we can still consider:

It's common and very tempting to represent (x,y,z) values using the three primary colours: red, green and blue. Unfortunately this combination clashes with common forms of colour blindess.

The RGB palette is used in the sensor plotter for many of the sensors but the user can also override this with a "default palette". This is loosely based on common digital storage oscilloscopes:

  1. Yellow (0xffff00),
  2. Cyan (0x00ffff),
  3. "Almost Pink" (0xff0080).

These can be tested on a colour simulator but it's best to test with some real people.

Auto-scaling Algorithm

The PlotSource object has methods to provide the absolute minimum and maximum values for the data from that source. A typical feature would be to set the y axis scale based on the observed data values to get a more detailed view.

The current algorithm is shown below in two flow charts, the second is a sub routine used in the first one.

sensors_clue-sensor-plotter-range-algorithm-part1-v2.png
Flowchart showing the first half of the Plotter's range (scale) algorithm.

The data_mins and data_maxs are lists of the recent minimum and maximums for approximate 1 second periods retained for a configurable number of seconds.

The change_range() sub routine (implemented as a method) implements the optional y_min_range feature. This prevents zooming in excessively showing uninteresting, random noise from some sensors.

The zoom out will always occur if the data is off the current range, i.e. off screen. The zoom in is a little more cautious.

sensors_clue-sensor-plotter-range-algorithm-part2-v2.png
Flowchart showing the second half of the Plotter's range (scale) algorithm.

There's one extra feature to reduce the frequency of zooming in not shown in the diagrams. A timestamp is recorded whenever a zoom in takes place and this is used to prevent zooming in again until N seconds has passed.

Based on acceptance testing, the zooming still occurs when it looks unnecessary. This algorithm needs further improvement perhaps using a hysterisis-based approach.

Efficient Use of displayio

The displayio library for CircuitPython (or Adafruit_GFX for Arduino) provides a single library which can be used with a variety of different size LED, LCD and ePaper screens. This abstraction is very useful and removes the need to directly program the CLUE's ST7789 LCD display. The only details the programmer needs to know for low update rates are:

  • the resolution of the screen (240x240) and
  • whether it has enough colour depth to render the desired colours sufficiently accurately (16bit).

These small LCD screens are not designed for high frame rates. If the screen needs to be updated frequently then the performance needs to be explored. The displayio library is implemented in compiled code to improve the performance but it needs to be thoughtfully used from CircuitPython since this is slower due to being executed on an interpreter.

Scrolling

A plotter needs to do something when the points/lines reach the edge of the screen. It can either

  • wrap like an oscilloscope or
  • scroll the existing data to the left.

The Bitmap class does not currently provide clear, fill or scroll methods. Some early exploratory programming revealed that slice assignment isn't supported and clearing a large Bitmap pixel by pixel is a very slow process. Some simple code to time clearing a Bitmap is shown below.

Download: file
# Quick benchmark of clearing a displayio Bitmap using for loops

# See https://github.com/adafruit/circuitpython/issues/2688

import time
import board, displayio

WIDTH = 201
HEIGHT = 200

display = board.DISPLAY

# eight colours is 3 bits per pixel when packed
bitmap = displayio.Bitmap(WIDTH, HEIGHT, 8)

palette = displayio.Palette(8)
palette[0] = 0x000000
palette[1] = 0xff0000
palette[2] = 0x00ff00
palette[3] = 0x0000ff

tile_grid = displayio.TileGrid(bitmap, pixel_shader=palette)
group = displayio.Group()
group.append(tile_grid)

display.auto_refresh=False
display.show(group)

def refresh_screen(disp):
    while True:
        refreshed = False
        try:
            refreshed = disp.refresh(minimum_frames_per_second=0)
        except Exception:
            pass
        if refreshed:
            break

def fillscreen1(bmp, col_idx):
    for x in range(WIDTH):
        for y in range(HEIGHT):
            bmp[x, y] = col_idx

def fillscreen2(bmp, col_idx):
    for y in range(HEIGHT):
        for x in range(WIDTH):
            bmp[x, y] = col_idx

def fillscreen3(bmp, col_idx):
    for idx in range(WIDTH * HEIGHT):
        bmp[idx] = col_idx

# "Big" Python has a timeit library but not present on CircuitPython
# so it's time for some for loops
for func in (fillscreen1, fillscreen2, fillscreen3):
    for _ in range(2):
        for colour_idx in (0, 0, 0, 1, 2, 3):
            t1 = time.monotonic_ns()
            func(bitmap, colour_idx)
            refresh_screen(display)
            t2 = time.monotonic_ns()
            func_name = str(func).split(" ")[1]
            print(func_name,
                  colour_idx,
                  "{:.3f}s".format((t2 - t1) / 1e9))
            time.sleep(0.5)

This simple benchmark could be improved as it both updates the Bitmap data and performs a single refresh of the screen. It would be informative to observe the performance of the two actions individually.

The output is shown below. fillscreen1 takes 1.25 seconds, fillscreen3 is faster at 0.75 seconds, fillscreen2 isn't shown as it was same as fillscreen1.

Download: file
fillscreen1 0 1.252s
fillscreen1 0 1.250s
fillscreen1 0 1.250s
fillscreen1 1 1.251s
fillscreen1 2 1.251s
fillscreen1 3 1.249s
fillscreen1 0 1.250s
fillscreen1 0 1.251s
fillscreen1 0 1.251s
fillscreen1 1 1.249s
fillscreen1 2 1.250s
fillscreen1 3 1.251s

fillscreen3 0 0.753s
fillscreen3 0 0.754s
fillscreen3 0 0.755s
fillscreen3 1 0.755s
fillscreen3 2 0.753s
fillscreen3 3 0.754s
fillscreen3 0 0.755s
fillscreen3 0 0.753s
fillscreen3 0 0.754s
fillscreen3 1 0.754s
fillscreen3 2 0.755s
fillscreen3 3 0.753s

These numbers would mean the screen would barely be able to update once per second. It's also slower if two bitmaps are overlaid which is a tempting solution to providing a static background.

This benchmarking lead to a change in design to use a more complex "un-drawing" technique. This reduces the number of pixel changes dramatically decreasing the time to clear the screen. The downside is the added complexity in storing the data and in the procedure to draw over the existing plot with background colour pixels.

Further testing revealed this undraw was still fairly slow. This lead to another iteration of the design. Reducing the frequency of scrolling was required and this could be achieved with a "jump" scroll - scrolling the data by more than one pixel at a time.

Resolution and Scaling

The final implementation of the Plotter class uses a Bitmap with a resolution of 192x201 pixels for the plot. The width was reduced to allow an extra character on the y axis tick labels.

Group has a feature to scale objects by an integer amount. This is implemented in C and is likely to be efficient. Another potential option to speed up the code would be to lower the resolution and use scale=2 to display it - a trade-off between resolution and performance. This could be implemented as a user-selected option.

This guide was first published on Apr 01, 2020. It was last updated on Apr 01, 2020.
This page (Plotter Class) was last updated on Sep 28, 2020.