Sensor Plotter

sensors_clue-sensor-plotter-buttonguide-mmb-1600x1200.jpg
Sensor plotter start-up screen showing button guide.

Download the three CircuitPython files below renaming the clue-plotter.py to code.py by clicking on the links and then using Save as... / Save link as... in the browser. The files are hosted on Adafruit's GitHub repo for this project.

Plug your CLUE board into your computer via a known-good USB data cable. A flash drive named CIRCUITPY should appear in your file explorer/finder program. Copy the three files to the CIRCUITPY drive ensuring the clue-plotter.py is renamed to code.py.

Example Video

The video below demonstrates all of the sensors and at the end shows the analogue inputs on the three large pads marked #0, #1 and #2. A Feather M4 Express provides colour and two signals for #0 and #1 for the demonstration.

The following sections show the lengthy code in all three files. There is also a Code Discussion section at the bottom of the page.

Code

code.py

The file must be renamed to code.py to run on the CLUE. If you select Download: Project Zip, you will get all three files in one zip archive file.

# clue-plotter v1.14
# Sensor and input plotter for Adafruit CLUE in CircuitPython
# This plots the sensors and three of the analogue inputs on
# the LCD display either with scrolling or wrap mode which
# approximates a slow timebase oscilloscope, left button selects
# next source or with long press changes palette or longer press
# turns on output for Mu plotting, right button changes plot style

# Tested with an Adafruit CLUE (Alpha) and CircuitPython and 5.0.0

# copy this file to CLUE board as code.py
# needs companion plot_sensor.py and plotter.py files

# MIT License

# Copyright (c) 2020 Kevin J. Walters

# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:

# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.

# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.

import time

import gc
import board

from plotter import Plotter
from plot_source import PlotSource, TemperaturePlotSource, PressurePlotSource, \
                        HumidityPlotSource, ColorPlotSource, ProximityPlotSource, \
                        IlluminatedColorPlotSource, VolumePlotSource, \
                        AccelerometerPlotSource, GyroPlotSource, \
                        MagnetometerPlotSource, PinPlotSource
from adafruit_clue import clue


debug = 1


# A list of all the data sources for plotting
sources = [TemperaturePlotSource(clue, mode="Celsius"),
           TemperaturePlotSource(clue, mode="Fahrenheit"),
           PressurePlotSource(clue, mode="Metric"),
           PressurePlotSource(clue, mode="Imperial"),
           HumidityPlotSource(clue),
           ColorPlotSource(clue),
           ProximityPlotSource(clue),
           IlluminatedColorPlotSource(clue, mode="Red"),
           IlluminatedColorPlotSource(clue, mode="Green"),
           IlluminatedColorPlotSource(clue, mode="Blue"),
           IlluminatedColorPlotSource(clue, mode="Clear"),
           VolumePlotSource(clue),
           AccelerometerPlotSource(clue),
           GyroPlotSource(clue),
           MagnetometerPlotSource(clue),
           PinPlotSource([board.P0, board.P1, board.P2])
          ]
# The first source to select when plotting starts
current_source_idx = 0

# The various plotting styles - scroll is currently a jump scroll
stylemodes = (("lines", "scroll"),  # draws lines between points
              ("lines", "wrap"),
              ("dots", "scroll"),   # just points - slightly quicker
              ("dots", "wrap")
             )
current_sm_idx = 0


def d_print(level, *args, **kwargs):
    """A simple conditional print for debugging based on global debug level."""
    if not isinstance(level, int):
        print(level, *args, **kwargs)
    elif debug >= level:
        print(*args, **kwargs)


def select_colors(plttr, src, def_palette):
    """Choose the colours based on the particular PlotSource
       or forcing use of default palette."""
    # otherwise use defaults
    channel_colidx = []
    palette = plttr.get_colors()
    colors = PlotSource.DEFAULT_COLORS if def_palette else src.colors()
    for col in colors:
        try:
            channel_colidx.append(palette.index(col))
        except ValueError:
            channel_colidx.append(PlotSource.DEFAULT_COLORS.index(col))
    return channel_colidx


def ready_plot_source(plttr, srcs, def_palette, index=0):
    """Select the plot source by index from srcs list and then setup the
       plot parameters by retrieving meta-data from the PlotSource object."""
    src = srcs[index]
    # Put the description of the source on screen at the top
    source_name = str(src)
    d_print(1, "Selecting source:", source_name)
    plttr.clear_all()
    plttr.title = source_name
    plttr.y_axis_lab = src.units()
    # The range on graph will start at this value
    plttr.y_range = (src.initial_min(), src.initial_max())
    plttr.y_min_range = src.range_min()
    # Sensor/data source is expected to produce data between these values
    plttr.y_full_range = (src.min(), src.max())
    channels_from_src = src.values()
    plttr.channels = channels_from_src  # Can be between 1 and 3
    plttr.channel_colidx = select_colors(plttr, src, def_palette)

    src.start()
    return (src, channels_from_src)


def wait_release(func, menu):
    """Calls func repeatedly waiting for it to return a false value
       and goes through menu list as time passes.

       The menu is a list of menu entries where each entry is a
       two element list of time passed in seconds and text to display
       for that period.
       The entries must be in ascending time order."""

    start_t_ns = time.monotonic_ns()
    menu_option = None
    selected = False

    for menu_option, menu_entry in enumerate(menu):
        menu_time_ns = start_t_ns + int(menu_entry[0] * 1e9)
        menu_text = menu_entry[1]
        if menu_text:
            plotter.info = menu_text
        while time.monotonic_ns() < menu_time_ns:
            if not func():
                selected = True
                break
        if menu_text:
            plotter.info = ""
        if selected:
            break

    return (menu_option, (time.monotonic_ns() - start_t_ns) * 1e-9)


def popup_text(plttr, text, duration=1.0):
    """Place some text on the screen using info property of Plotter object
       for duration seconds."""
    plttr.info = text
    time.sleep(duration)
    plttr.info = None


mu_plotter_output = False
range_lock = False

initial_title = "CLUE Plotter"
# displayio has some static limits on text - pre-calculate the maximum
# length of all of the different PlotSource objects
max_title_len = max(len(initial_title), max([len(str(so)) for so in sources]))
plotter = Plotter(board.DISPLAY,
                  style=stylemodes[current_sm_idx][0],
                  mode=stylemodes[current_sm_idx][1],
                  title=initial_title,
                  max_title_len=max_title_len,
                  mu_output=mu_plotter_output,
                  debug=debug)

# If set to true this forces use of colour blindness friendly colours
use_def_pal = False

clue.pixel[0] = clue.BLACK  # turn off the NeoPixel on the back of CLUE board

plotter.display_on()
# Using left and right here in case the CLUE is cased hiding A/B labels
popup_text(plotter,
           "\n".join(["Button Guide",
                      "Left: next source",
                      "  2secs: palette",
                      "  4s: Mu plot",
                      "  6s: range lock",
                      "Right: style change"]), duration=10)

count = 0

while True:
    # Set the source and start items
    (source, channels) = ready_plot_source(plotter, sources,
                                           use_def_pal,
                                           current_source_idx)

    while True:
        # Read data from sensor or voltage from pad
        all_data = source.data()

        # Check for left (A) and right (B) buttons
        if clue.button_a:
            # Wait for button release with time-based menu
            opt, _ = wait_release(lambda: clue.button_a,
                                  [(2, "Next\nsource"),
                                   (4,
                                    ("Source" if use_def_pal else "Default")
                                    + "\npalette"),
                                   (6,
                                    "Mu output "
                                    + ("off" if mu_plotter_output else "on")),
                                   (8,
                                    "Range lock\n" + ("off" if range_lock else "on"))
                                  ])
            if opt == 0:  # change plot source
                current_source_idx = (current_source_idx + 1) % len(sources)
                break  # to leave inner while and select the new source

            elif opt == 1:  # toggle palette
                use_def_pal = not use_def_pal
                plotter.channel_colidx = select_colors(plotter, source,
                                                       use_def_pal)

            elif opt == 2:  # toggle Mu output
                mu_plotter_output = not mu_plotter_output
                plotter.mu_output = mu_plotter_output

            else:  # toggle range lock
                range_lock = not range_lock
                plotter.y_range_lock = range_lock

        if clue.button_b:  # change plot style and mode
            current_sm_idx = (current_sm_idx + 1) % len(stylemodes)
            (new_style, new_mode) = stylemodes[current_sm_idx]
            wait_release(lambda: clue.button_b,
                         [(2, new_style + "\n" + new_mode)])
            d_print(1, "Graph change", new_style, new_mode)
            plotter.change_stylemode(new_style, new_mode)

        # Display it
        if channels == 1:
            plotter.data_add((all_data,))
        else:
            plotter.data_add(all_data)

        # An occasional print of free heap
        if debug >=3 and count % 15 == 0:
            gc.collect()  # must collect() first to measure free memory
            print("Free memory:", gc.mem_free())

        count += 1

    source.stop()

plotter.display_off()

plot_source.py

# MIT License

# Copyright (c) 2020 Kevin J. Walters

# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:

# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.

# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.

"""
`plot_source`
================================================================================
CircuitPython library for the clue-plotter application.

* Author(s): Kevin J. Walters

Implementation Notes
--------------------
**Hardware:**
* Adafruit CLUE <https://www.adafruit.com/product/4500>
**Software and Dependencies:**
* Adafruit's CLUE library: https://github.com/adafruit/Adafruit_CircuitPython_CLUE
"""

import math

import analogio


class PlotSource():
    """An abstract class for a sensor which returns the data from the sensor
       and provides some metadata useful for plotting.
       Sensors returning vector quanities like a 3-axis accelerometer are supported.
       When the source is used start() will be called and when it's not needed stop() will
       be called.

    :param values: Number of values returned by data method, between 1 and 3.
    :param name: Name of the sensor used to title the graph, only 17 characters fit on screen.
    :param units: Units for data used for y axis label.
    :param abs_min: Absolute minimum value for data, defaults to 0.
    :param abs_max: Absolute maximum value for data, defaults to 65535.
    :param initial_min: The initial minimum value suggested for y axis on graph,
           defaults to abs_min.
    :param initial_max: The initial maximum value suggested for y axis on graph,
           defaults to abs_max.
    :param range_min: A suggested minimum range to aid automatic y axis ranging.
    :param rate: The approximate rate in Hz that that data method returns in a tight loop.
    :param colors: A list of the suggested colors for data.
    :param debug: A numerical debug level, defaults to 0.
       """
    DEFAULT_COLORS = (0xffff00, 0x00ffff, 0xff0080)
    RGB_COLORS = (0xff0000, 0x00ff00, 0x0000ff)

    def __init__(self, values, name, units="",
                 abs_min=0, abs_max=65535, initial_min=None, initial_max=None,
                 range_min=None,
                 rate=None, colors=None, debug=0):
        if type(self) == PlotSource:  # pylint: disable=unidiomatic-typecheck
            raise TypeError("PlotSource must be subclassed")
        self._values = values
        self._name = name
        self._units = units
        self._abs_min = abs_min
        self._abs_max = abs_max
        self._initial_min = initial_min if initial_min is not None else abs_min
        self._initial_max = initial_max if initial_max is not None else abs_max
        if range_min is None:
            self._range_min = (abs_max - abs_min) / 100  # 1% of full range
        else:
            self._range_min = range_min
        self._rate = rate
        if colors is not None:
            self._colors = colors
        else:
            self._colors = self.DEFAULT_COLORS[:values]
        self._debug = debug

    def __str__(self):
        return self._name

    def data(self):
        """Data sample from the sensor.

           :return: A single numerical value or an array or tuple for vector values.
           """
        raise NotImplementedError()

    def min(self):
        return self._abs_min

    def max(self):
        return self._abs_max

    def initial_min(self):
        return self._initial_min

    def initial_max(self):
        return self._initial_max

    def range_min(self):
        return self._range_min

    def start(self):
        pass

    def stop(self):
        pass

    def values(self):
        return self._values

    def units(self):
        return self._units

    def rate(self):
        return self._rate

    def colors(self):
        return self._colors


# This over-reads presumably due to electronics warming the board
# It also looks odd on close inspection as it climbs about 0.1C if
# it's read frequently
# Data sheet say operating temperature is -40C to 85C
class TemperaturePlotSource(PlotSource):
    def _convert(self, value):
        return value * self._scale + self._offset

    def __init__(self, my_clue, mode="Celsius"):
        self._clue = my_clue
        range_min = 0.8
        if mode[0].lower() == "f":
            mode_name = "Fahrenheit"
            self._scale = 1.8
            self._offset = 32.0
            range_min = 1.6
        elif mode[0].lower() == "k":
            mode_name = "Kelvin"
            self._scale = 1.0
            self._offset = 273.15
        else:
            mode_name = "Celsius"
            self._scale = 1.0
            self._offset = 0.0
        super().__init__(1, "Temperature",
                         units=mode_name[0],
                         abs_min=self._convert(-40),
                         abs_max=self._convert(85),
                         initial_min=self._convert(10),
                         initial_max=self._convert(40),
                         range_min=range_min,
                         rate=24)

    def data(self):
        return self._convert(self._clue.temperature)


# The 300, 1100 values are in adafruit_bmp280 but are private variables
class PressurePlotSource(PlotSource):
    def _convert(self, value):
        return value * self._scale

    def __init__(self, my_clue, mode="M"):
        self._clue = my_clue
        if mode[0].lower() == "i":
            # 29.92 inches mercury equivalent to 1013.25mb in ISA
            self._scale = 29.92 / 1013.25
            units = "inHg"
            range_min = 0.04
        else:
            self._scale = 1.0
            units = "hPa"  # AKA millibars (mb)
            range_min = 1

        super().__init__(1, "Pressure", units=units,
                         abs_min=self._convert(300), abs_max=self._convert(1100),
                         initial_min=self._convert(980), initial_max=self._convert(1040),
                         range_min=range_min,
                         rate=22)

    def data(self):
        return self._convert(self._clue.pressure)


class ProximityPlotSource(PlotSource):
    def __init__(self, my_clue):
        self._clue = my_clue
        super().__init__(1, "Proximity",
                         abs_min=0, abs_max=255,
                         rate=720)

    def data(self):
        return self._clue.proximity


class HumidityPlotSource(PlotSource):
    def __init__(self, my_clue):
        self._clue = my_clue
        super().__init__(1, "Rel. Humidity", units="%",
                         abs_min=0, abs_max=100, initial_min=20, initial_max=60,
                         rate=54)

    def data(self):
        return self._clue.humidity

# If clue.touch_N has not been used then it doesn't instantiate
# the TouchIn object so there's no problem with creating an AnalogIn...
class PinPlotSource(PlotSource):
    def __init__(self, pin):
        try:
            pins = [p for p in pin]
        except TypeError:
            pins = [pin]

        self._pins = pins
        self._analogin = [analogio.AnalogIn(p) for p in pins]
        # Assumption here that reference_voltage is same for all
        # 3.3V graphs nicely with rounding up to 4.0V
        self._reference_voltage = self._analogin[0].reference_voltage
        self._conversion_factor = self._reference_voltage / (2**16 - 1)
        super().__init__(len(pins),
                         "Pad: " + ", ".join([str(p).split('.')[-1] for p in pins]),
                         units="V",
                         abs_min=0.0, abs_max=math.ceil(self._reference_voltage),
                         rate=10000)

    def data(self):
        if len(self._analogin) == 1:
            return self._analogin[0].value * self._conversion_factor
        else:
            return tuple([ana.value * self._conversion_factor
                          for ana in self._analogin])

    def pins(self):
        return self._pins


class ColorPlotSource(PlotSource):
    def __init__(self, my_clue):
        self._clue = my_clue
        super().__init__(3, "Color: R, G, B",
                         abs_min=0, abs_max=8000,  # 7169 looks like max
                         rate=50,
                         colors=self.RGB_COLORS,
                        )

    def data(self):
        (r, g, b, _) = self._clue.color  # fourth value is clear value
        return (r, g, b)

    def start(self):
        # These values will affect the maximum return value
        # Set APDS9660 to sample every (256 - 249 ) * 2.78 = 19.46ms
        # pylint: disable=protected-access
        self._clue._sensor.integration_time = 249  # 19.46ms, ~ 50Hz
        self._clue._sensor.color_gain = 0x02  # 16x (library default is 4x)


class IlluminatedColorPlotSource(PlotSource):
    def __init__(self, my_clue, mode="Clear"):
        self._clue = my_clue
        col_fl_lc = mode[0].lower()
        if col_fl_lc == "r":
            plot_colour = self.RGB_COLORS[0]
        elif col_fl_lc == "g":
            plot_colour = self.RGB_COLORS[1]
        elif col_fl_lc == "b":
            plot_colour = self.RGB_COLORS[2]
        elif col_fl_lc == "c":
            plot_colour = self.DEFAULT_COLORS[0]
        else:
            raise ValueError("Colour must be Red, Green, Blue or Clear")

        self._channel = col_fl_lc
        super().__init__(1, "Illum. color: " + self._channel.upper(),
                         abs_min=0, abs_max=8000,
                         initial_min=0, initial_max=2000,
                         colors=(plot_colour,),
                         rate=50)

    def data(self):
        (r, g, b, c) = self._clue.color
        if self._channel == "r":
            return r
        elif self._channel == "g":
            return g
        elif self._channel == "b":
            return b
        elif self._channel == "c":
            return c
        else:
            return None  # This should never happen

    def start(self):
        # Set APDS9660 to sample every (256 - 249 ) * 2.78 = 19.46ms
        # pylint: disable=protected-access
        self._clue._sensor.integration_time = 249  # 19.46ms, ~ 50Hz
        self._clue._sensor.color_gain = 0x03  # 64x (library default is 4x)

        self._clue.white_leds = True

    def stop(self):
        self._clue.white_leds = False


class VolumePlotSource(PlotSource):
    def __init__(self, my_clue):
        self._clue = my_clue
        super().__init__(1, "Volume", units="dB",
                         abs_min=0, abs_max=97+3,   # 97dB is 16bit dynamic range
                         initial_min=10, initial_max=60,
                         rate=41)

    # 20 due to conversion of amplitude of signal
    _LN_CONVERSION_FACTOR = 20 / math.log(10)

    def data(self):
        return (math.log(self._clue.sound_level + 1)
                * self._LN_CONVERSION_FACTOR)


# This appears not to be a blocking read in terms of waiting for a
# a genuinely newvalue from the sensor
# CP standard says this should be radians per second but library
# currently returns degrees per second
# https://circuitpython.readthedocs.io/en/latest/docs/design_guide.html
# https://github.com/adafruit/Adafruit_CircuitPython_LSM6DS/issues/9
class GyroPlotSource(PlotSource):
    def __init__(self, my_clue):
        self._clue = my_clue
        super().__init__(3, "Gyro", units="dps",
                         abs_min=-287-13, abs_max=287+13,  # 286.703 appears to be max
                         initial_min=-100, initial_max=100,
                         colors=self.RGB_COLORS,
                         rate=500)

    def data(self):
        return self._clue.gyro


class AccelerometerPlotSource(PlotSource):
    def __init__(self, my_clue):
        self._clue = my_clue
        super().__init__(3, "Accelerometer", units="ms-2",
                         abs_min=-40, abs_max=40,  # 39.1992 approx max
                         initial_min=-20, initial_max=20,
                         colors=self.RGB_COLORS,
                         rate=500)

    def data(self):
        return self._clue.acceleration


class MagnetometerPlotSource(PlotSource):
    def __init__(self, my_clue):
        self._clue = my_clue
        super().__init__(3, "Magnetometer", units="uT",
                         abs_min=-479-21, abs_max=479+21,  # 478.866 approx max
                         initial_min=-80, initial_max=80,  # Earth around 60uT
                         colors=self.RGB_COLORS,
                         rate=500)

    def data(self):
        return self._clue.magnetic

plotter.py

# MIT License

# Copyright (c) 2020 Kevin J. Walters

# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:

# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.

# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.

"""
`plotter`
================================================================================
CircuitPython library for the clue-plotter application's plotting facilties.
Internally this holds some values in a circular buffer to enable redrawing
and has some basic statistics on data.
Not intended to be a truly general purpose plotter but perhaps could be
developed into one.

* Author(s): Kevin J. Walters

Implementation Notes
--------------------
**Hardware:**
* Adafruit CLUE <https://www.adafruit.com/product/4500>
**Software and Dependencies:**
* Adafruit's CLUE library: https://github.com/adafruit/Adafruit_CircuitPython_CLUE
"""

import time
import array

import displayio
import terminalio

from adafruit_display_text.label import Label


def mapf(value, in_min, in_max, out_min, out_max):
    return (value - in_min) * (out_max - out_min) / (in_max - in_min) + out_min


# This creates ('{:.0f}', '{:.1f}', '{:.2f}', etc
_FMT_DEC_PLACES = tuple("{:." + str(x) + "f}" for x in range(10))

def format_width(nchars, value):
    """Simple attempt to generate a value within nchars characters.
       Return value can be too long, e.g. for nchars=5, bad things happen
       with values > 99999 or < -9999 or < -99.9."""
    neg_format = _FMT_DEC_PLACES[nchars - 3]
    pos_format = _FMT_DEC_PLACES[nchars - 2]
    if value <= -10.0:
        text_value = neg_format.format(value)  # may overflow width
    elif value < 0.0:
        text_value = neg_format.format(value)
    elif value >= 10.0:
        text_value = pos_format.format(value)  # may overflow width
    else:
        text_value = pos_format.format(value)  # 0.0 to 9.99999
    return text_value


class Plotter():
    _DEFAULT_SCALE_MODE = {"lines": "onscroll",
                           "dots": "screen"}

    # Palette for plotting, first one is set transparent
    TRANSPARENT_IDX = 0
    # Removed one colour to get number down to 8 for more efficient
    # bit-packing in displayio's Bitmap
    _PLOT_COLORS = (0x000000,
                    0x0000ff,
                    0x00ff00,
                    0x00ffff,
                    0xff0000,
                    # 0xff00ff,
                    0xffff00,
                    0xffffff,
                    0xff0080)

    POS_INF = float("inf")
    NEG_INF = float("-inf")

    # Approximate number of seconds to review data for zooming in
    # and how often to do that check
    ZOOM_IN_TIME = 8
    ZOOM_IN_CHECK_TIME_NS = 5 * 1e9
    # 20% headroom either side on zoom in/out
    ZOOM_HEADROOM = 20 / 100

    GRID_COLOR = 0x308030
    GRID_DOT_SPACING = 8

    _GRAPH_TOP = 30  # y position for the graph placement

    INFO_FG_COLOR = 0x000080
    INFO_BG_COLOR = 0xc0c000
    LABEL_COLOR = 0xc0c0c0

    def _display_manual(self):
        """Intention was to disable auto_refresh here but this needs a
           simple displayio refresh to work well."""
        self._output.auto_refresh = True

    def _display_auto(self):
        self._output.auto_refresh = True

    def _display_refresh(self):
        """Intention was to call self._output.refresh() but this does not work well
           as current implementation is designed with a fixed frame rate in mind."""
        if self._output.auto_refresh:
            return True
        else:
            return True

    def __init__(self, output,
                 style="lines", mode="scroll", scale_mode=None,
                 screen_width=240, screen_height=240,
                 plot_width=192, plot_height=201,
                 x_divs=4, y_divs=4,
                 scroll_px=50,
                 max_channels=3,
                 est_rate=50,
                 title="",
                 max_title_len=20,
                 mu_output=False,
                 debug=0):
        """scroll_px of greater than 1 gives a jump scroll."""
        # pylint: disable=too-many-locals,too-many-statements
        self._output = output
        self.change_stylemode(style, mode, scale_mode=scale_mode, clear=False)
        self._screen_width = screen_width
        self._screen_height = screen_height
        self._plot_width = plot_width
        self._plot_height = plot_height
        self._plot_height_m1 = plot_height - 1
        self._x_divs = x_divs
        self._y_divs = y_divs
        self._scroll_px = scroll_px
        self._max_channels = max_channels
        self._est_rate = est_rate
        self._title = title
        self._max_title_len = max_title_len

        # These arrays are used to provide a circular buffer
        # with _data_values valid values - this needs to be sized
        # one larger than screen width to retrieve prior y position
        # for line undrawing in wrap mode
        self._data_size = self._plot_width + 1
        self._data_y_pos = []
        self._data_value = []
        for _ in range(self._max_channels):
            # 'i' is 32 bit signed integer
            self._data_y_pos.append(array.array('i', [0] * self._data_size))
            self._data_value.append(array.array('f', [0.0] * self._data_size))

        # begin-keep-pylint-happy
        self._data_mins = None
        self._data_maxs = None
        self._data_stats_maxlen = None
        self._data_stats = None
        self._values = None
        self._data_values = None
        self._x_pos = None
        self._data_idx = None
        self._plot_lastzoom_ns = None
        # end-keep-pylint-happy
        self._init_data()

        self._mu_output = mu_output
        self._debug = debug

        self._channels = None
        self._channel_colidx = []

        # The range the data source generates within
        self._abs_min = None
        self._abs_max = None

        # The current plot min/max
        self._plot_min = None
        self._plot_max = None
        self._plot_min_range = None  # Used partly to prevent div by zero
        self._plot_range_lock = False
        self._plot_dirty = False  # flag indicate some data has been plotted

        self._font = terminalio.FONT
        self._y_axis_lab = ""
        self._y_lab_width = 6  # maximum characters for y axis label
        self._y_lab_color = self.LABEL_COLOR

        self._displayio_graph = None
        self._displayio_plot = None
        self._displayio_title = None
        self._displayio_info = None
        self._displayio_y_labs = None
        self._displayio_y_axis_lab = None
        self._last_manual_refresh = None

    def _init_data(self, ranges=True):
        # Allocate arrays for each possible channel with plot_width elements
        self._data_mins = [self.POS_INF]
        self._data_maxs = [self.NEG_INF]
        self._data_start_ns = [time.monotonic_ns()]
        self._data_stats_maxlen = 10

        # When in use the arrays in here are variable length
        self._data_stats = [[] * self._max_channels]

        self._values = 0  # total data processed
        self._data_values = 0  # valid elements in data_y_pos and data_value
        self._x_pos = 0
        self._data_idx = 0

        self._plot_lastzoom_ns = 0  # monotonic_ns() for last zoom in
        if ranges:
            self._plot_min = None
            self._plot_max = None
            self._plot_min_range = None  # Used partly to prevent div by zero
        self._plot_dirty = False  # flag indicate some data has been plotted

    def _recalc_y_pos(self):
        """Recalculates _data_y_pos based on _data_value for changes in y scale."""
        # Check if nothing to do - important since _plot_min _plot_max not yet set
        if self._data_values == 0:
            return

        for ch_idx in range(self._channels):
            # intentional use of negative array indexing
            for data_idx in range(self._data_idx - 1,
                                  self._data_idx - 1 - self._data_values,
                                  -1):
                self._data_y_pos[ch_idx][data_idx] = round(mapf(self._data_value[ch_idx][data_idx],
                                                                self._plot_min,
                                                                self._plot_max,
                                                                self._plot_height_m1,
                                                                0))

    def get_colors(self):
        return self._PLOT_COLORS

    def clear_all(self, ranges=True):
        if self._values != 0:
            self._undraw_bitmap()
        self._init_data(ranges=ranges)

    # Simple implementation here is to clear the screen on change...
    def change_stylemode(self, style, mode, scale_mode=None, clear=True):
        if style not in ("lines", "dots"):
            raise ValueError("style not lines or dots")
        if mode not in ("scroll", "wrap"):
            raise ValueError("mode not scroll or wrap")
        if scale_mode is None:
            scale_mode = self._DEFAULT_SCALE_MODE[style]
        elif scale_mode not in ("pixel", "onscroll", "screen", "time"):
            raise ValueError("scale_mode not pixel, onscroll, screen or time")

        # Clearing everything on screen and everything stored in variables
        # apart from plot ranges is simplest approach here - clearing
        # involves undrawing which uses the self._style so must not change
        # that beforehand
        if clear:
            self.clear_all(ranges=False)

        self._style = style
        self._mode = mode
        self._scale_mode = scale_mode

        if self._mode == "wrap":
            self._display_auto()
        elif self._mode == "scroll":
            self._display_manual()

    def _make_empty_tg_plot_bitmap(self):
        plot_bitmap = displayio.Bitmap(self._plot_width, self._plot_height,
                                       len(self._PLOT_COLORS))
        # Create a colour palette for plot dots/lines
        plot_palette = displayio.Palette(len(self._PLOT_COLORS))

        for idx in range(len(self._PLOT_COLORS)):
            plot_palette[idx] = self._PLOT_COLORS[idx]
        plot_palette.make_transparent(0)
        tg_plot_data = displayio.TileGrid(plot_bitmap,
                                          pixel_shader=plot_palette)
        tg_plot_data.x = self._screen_width - self._plot_width - 1
        tg_plot_data.y = self._GRAPH_TOP
        return (tg_plot_data, plot_bitmap)

    def _make_tg_grid(self):
        # pylint: disable=too-many-locals
        grid_width  = self._plot_width
        grid_height = self._plot_height_m1
        div_width = self._plot_width // self._x_divs
        div_height = self._plot_height // self._y_divs
        a_plot_grid = displayio.Bitmap(div_width, div_height, 2)

        # Grid colours
        grid_palette = displayio.Palette(2)
        grid_palette.make_transparent(0)
        grid_palette[0] = 0x000000
        grid_palette[1] = self.GRID_COLOR

        # Horizontal line on grid rectangle
        for x in range(0, div_width, self.GRID_DOT_SPACING):
            a_plot_grid[x, 0] = 1

        # Vertical line on grid rectangle
        for y in range(0, div_height, self.GRID_DOT_SPACING):
            a_plot_grid[0, y] = 1

        right_line = displayio.Bitmap(1, grid_height, 2)
        tg_right_line = displayio.TileGrid(right_line,
                                           pixel_shader=grid_palette)
        for y in range(0, grid_height, self.GRID_DOT_SPACING):
            right_line[0, y] = 1

        bottom_line = displayio.Bitmap(grid_width + 1, 1, 2)
        tg_bottom_line = displayio.TileGrid(bottom_line,
                                            pixel_shader=grid_palette)
        for x in range(0, grid_width + 1, self.GRID_DOT_SPACING):
            bottom_line[x, 0] = 1

        # Create a TileGrid using the Bitmap and Palette
        # and tiling it based on number of divisions required
        tg_plot_grid = displayio.TileGrid(a_plot_grid,
                                          pixel_shader=grid_palette,
                                          width=self._x_divs,
                                          height=self._y_divs,
                                          default_tile = 0)
        tg_plot_grid.x = self._screen_width - self._plot_width - 1
        tg_plot_grid.y = self._GRAPH_TOP
        tg_right_line.x = tg_plot_grid.x + grid_width
        tg_right_line.y = tg_plot_grid.y
        tg_bottom_line.x = tg_plot_grid.x
        tg_bottom_line.y = tg_plot_grid.y + grid_height

        g_plot_grid = displayio.Group(max_size=3)
        g_plot_grid.append(tg_plot_grid)
        g_plot_grid.append(tg_right_line)
        g_plot_grid.append(tg_bottom_line)

        return g_plot_grid

    def _make_empty_graph(self, tg_and_plot=None):
        font_w, font_h = self._font.get_bounding_box()

        self._displayio_title = Label(self._font,
                                      text=self._title,
                                      max_glyphs=self._max_title_len,
                                      scale=2,
                                      line_spacing=1,
                                      color=self._y_lab_color)
        self._displayio_title.x = self._screen_width - self._plot_width
        self._displayio_title.y = font_h // 2

        self._displayio_y_axis_lab = Label(self._font,
                                           text=self._y_axis_lab,
                                           max_glyphs=self._y_lab_width,
                                           line_spacing=1,
                                           color=self._y_lab_color)
        self._displayio_y_axis_lab.x = 0  # 0 works here because text is ""
        self._displayio_y_axis_lab.y = font_h // 2

        plot_y_labels = []
        # y increases top to bottom of screen
        for y_div in range(self._y_divs + 1):
            plot_y_labels.append(Label(self._font,
                                       text=" " * self._y_lab_width,
                                       max_glyphs=self._y_lab_width,
                                       line_spacing=1,
                                       color=self._y_lab_color))
            plot_y_labels[-1].x = (self._screen_width - self._plot_width
                                   - self._y_lab_width * font_w - 5)
            plot_y_labels[-1].y = (round(y_div * self._plot_height / self._y_divs)
                                   + self._GRAPH_TOP - 1)
        self._displayio_y_labs = plot_y_labels

        # Three items (grid, axis label, title) plus the y tick labels
        g_background = displayio.Group(max_size=3+len(plot_y_labels))
        g_background.append(self._make_tg_grid())
        for label in self._displayio_y_labs:
            g_background.append(label)
        g_background.append(self._displayio_y_axis_lab)
        g_background.append(self._displayio_title)

        if tg_and_plot is not None:
            (tg_plot, plot) = tg_and_plot
        else:
            (tg_plot, plot) = self._make_empty_tg_plot_bitmap()

        self._displayio_plot = plot

        # Create the main Group for display with one spare slot for
        # popup informational text
        main_group = displayio.Group(max_size=3)
        main_group.append(g_background)
        main_group.append(tg_plot)
        self._displayio_info = None

        return main_group

    def set_y_axis_tick_labels(self, y_min, y_max):
        px_per_div = (y_max - y_min) / self._y_divs
        for idx, tick_label in enumerate(self._displayio_y_labs):
            value = y_max - idx * px_per_div
            text_value = format_width(self._y_lab_width, value)
            tick_label.text = text_value[:self._y_lab_width]

    def display_on(self, tg_and_plot=None):
        if self._displayio_graph is None:
            self._displayio_graph = self._make_empty_graph(tg_and_plot=tg_and_plot)

        self._output.show(self._displayio_graph)

    def display_off(self):
        pass

    def _draw_vline(self, x1, y1, y2, colidx):
        """Draw a clipped vertical line at x1 from pixel one along from y1 to y2.
           """
        if y2 == y1:
            if 0 <= y2 <= self._plot_height_m1:
                self._displayio_plot[x1, y2] = colidx
            return

        # For y2 above y1, on screen this translates to being below
        step = 1 if y2 > y1 else -1

        for line_y_pos in range(max(0, min(y1 + step, self._plot_height_m1)),
                                max(0, min(y2, self._plot_height_m1)) + step,
                                step):
            self._displayio_plot[x1, line_y_pos] = colidx

    # def _clear_plot_bitmap(self):  ### woz here

    def _redraw_all_col_idx(self, col_idx_list):
        x_cols = min(self._data_values, self._plot_width)
        wrapMode = self._mode == "wrap"
        if wrapMode:
            x_data_idx = (self._data_idx - self._x_pos) % self._data_size
        else:
            x_data_idx = (self._data_idx - x_cols) % self._data_size

        for ch_idx in range(self._channels):
            col_idx = col_idx_list[ch_idx]
            data_idx = x_data_idx
            for x_pos in range(x_cols):
                # "jump" the gap in the circular buffer for wrap mode
                if wrapMode and x_pos == self._x_pos:
                    data_idx = (data_idx + self._data_size - self._plot_width) % self._data_size
                    # ideally this should inhibit lines between wrapped data

                y_pos = self._data_y_pos[ch_idx][data_idx]
                if self._style == "lines" and x_pos != 0:
                    # Python supports negative array index
                    prev_y_pos = self._data_y_pos[ch_idx][data_idx - 1]
                    self._draw_vline(x_pos, prev_y_pos, y_pos, col_idx)
                else:
                    if 0 <= y_pos <= self._plot_height_m1:
                        self._displayio_plot[x_pos, y_pos] = col_idx
                data_idx += 1
                if data_idx >= self._data_size:
                    data_idx = 0

    # This is almost always going to be quicker
    # than the slow _clear_plot_bitmap implemented on 5.0.0 displayio
    def _undraw_bitmap(self):
        if not self._plot_dirty:
            return

        self._redraw_all_col_idx([self.TRANSPARENT_IDX] * self._channels)
        self._plot_dirty = False


    def _redraw_all(self):
        self._redraw_all_col_idx(self._channel_colidx)
        self._plot_dirty = True


    def _undraw_column(self, x_pos, data_idx):
        """Undraw a single column at x_pos based on data from data_idx."""
        colidx = self.TRANSPARENT_IDX
        for ch_idx in range(self._channels):
            y_pos = self._data_y_pos[ch_idx][data_idx]
            if self._style == "lines" and x_pos != 0:
                # Python supports negative array index
                prev_y_pos = self._data_y_pos[ch_idx][data_idx - 1]
                self._draw_vline(x_pos, prev_y_pos, y_pos, colidx)
            else:
                if 0 <= y_pos <= self._plot_height_m1:
                    self._displayio_plot[x_pos, y_pos] = colidx

    # very similar code to _undraw_bitmap although that is now
    # more sophisticated as it supports wrap mode
    def _redraw_for_scroll(self, x1, x2, x1_data_idx):
        """Redraw data from x1 to x2 inclusive for scroll mode only."""
        for ch_idx in range(self._channels):
            colidx = self._channel_colidx[ch_idx]
            data_idx = x1_data_idx
            for x_pos in range(x1, x2 + 1):
                y_pos = self._data_y_pos[ch_idx][data_idx]
                if self._style == "lines" and x_pos != 0:
                    # Python supports negative array index
                    prev_y_pos = self._data_y_pos[ch_idx][data_idx - 1]
                    self._draw_vline(x_pos, prev_y_pos, y_pos, colidx)
                else:
                    if 0 <= y_pos <= self._plot_height_m1:
                        self._displayio_plot[x_pos, y_pos] = colidx
                data_idx += 1
                if data_idx >= self._data_size:
                    data_idx = 0

        self._plot_dirty = True

    def _update_stats(self, values):
        """Update the statistics for minimum and maximum."""
        for idx, value in enumerate(values):
            # Occasionally check if we need to add a new bucket to stats
            if idx == 0 and self._values & 0xf == 0:
                now_ns = time.monotonic_ns()
                if  now_ns - self._data_start_ns[-1] > 1e9:
                    self._data_start_ns.append(now_ns)
                    self._data_mins.append(value)
                    self._data_maxs.append(value)
                    # Remove the first elements if too long
                    if len(self._data_start_ns) > self._data_stats_maxlen:
                        self._data_start_ns.pop(0)
                        self._data_mins.pop(0)
                        self._data_maxs.pop(0)
                    continue

            if value < self._data_mins[-1]:
                self._data_mins[-1] = value
            if value > self._data_maxs[-1]:
                self._data_maxs[-1] = value

    def _data_store(self, values):
        """Store the data values in the circular buffer."""
        for ch_idx, value in enumerate(values):
            self._data_value[ch_idx][self._data_idx] = value

        # Increment the data index for circular buffer
        self._data_idx += 1
        if self._data_idx >= self._data_size:
            self._data_idx = 0

    def _data_draw(self, values, x_pos, data_idx):
        offscale = False

        for ch_idx, value in enumerate(values):
            # Last two parameters appear "swapped" - this deals with the
            # displayio screen y coordinate increasing downwards
            y_pos = round(mapf(value,
                               self._plot_min, self._plot_max,
                               self._plot_height_m1, 0))

            if y_pos < 0 or y_pos >= self._plot_height:
                offscale = True

            self._data_y_pos[ch_idx][data_idx] = y_pos

            if self._style == "lines" and self._x_pos != 0:
                # Python supports negative array index
                prev_y_pos = self._data_y_pos[ch_idx][data_idx - 1]
                self._draw_vline(x_pos, prev_y_pos, y_pos,
                                 self._channel_colidx[ch_idx])
                self._plot_dirty = True  # bit wrong if whole line is off screen
            else:
                if not offscale:
                    self._displayio_plot[x_pos, y_pos] = self._channel_colidx[ch_idx]
                    self._plot_dirty = True

    def _check_zoom_in(self):
        """Check if recent data warrants zooming in on y axis scale based on checking
           minimum and maximum times which are recorded in approximate 1 second buckets.
           Returns two element tuple with (min, max) or empty tuple for no zoom required.
           Caution is required with min == max."""
        start_idx = len(self._data_start_ns) - self.ZOOM_IN_TIME
        if start_idx < 0:
            return ()

        now_ns = time.monotonic_ns()
        if now_ns < self._plot_lastzoom_ns + self.ZOOM_IN_CHECK_TIME_NS:
            return ()

        recent_min = min(self._data_mins[start_idx:])
        recent_max = max(self._data_maxs[start_idx:])
        recent_range = recent_max - recent_min
        headroom = recent_range * self.ZOOM_HEADROOM

        # No zoom if the range of data is near the plot range
        if (self._plot_min > recent_min - headroom
                and self._plot_max < recent_max + headroom):
            return ()

        new_plot_min = max(recent_min - headroom, self._abs_min)
        new_plot_max = min(recent_max + headroom, self._abs_max)
        return (new_plot_min, new_plot_max)

    def _auto_plot_range(self, redraw_plot=True):
        """Check if we need to zoom out or in based on checking historical
           data values unless y_range_lock has been set.
           """
        if self._plot_range_lock:
            return False
        zoom_in = False
        zoom_out = False

        # Calcuate some new min/max values based on recentish data
        # and add some headroom
        y_min = min(self._data_mins)
        y_max = max(self._data_maxs)
        y_range = y_max - y_min
        headroom = y_range * self.ZOOM_HEADROOM
        new_plot_min = max(y_min - headroom, self._abs_min)
        new_plot_max = min(y_max + headroom, self._abs_max)

        # set new range if the data does not fit on the screen
        # this will also redo y tick labels if necessary
        if (new_plot_min < self._plot_min or new_plot_max > self._plot_max):
            if self._debug >= 2:
                print("Zoom out")
            self._change_y_range(new_plot_min, new_plot_max,
                                 redraw_plot=redraw_plot)
            zoom_out = True

        else:  # otherwise check if zoom in is warranted
            rescale_zoom_range = self._check_zoom_in()
            if rescale_zoom_range:
                if self._debug >= 2:
                    print("Zoom in")
                self._change_y_range(rescale_zoom_range[0], rescale_zoom_range[1],
                                     redraw_plot=redraw_plot)
                zoom_in = True

        if zoom_in or zoom_out:
            self._plot_lastzoom_ns = time.monotonic_ns()
            return True
        return False

    def data_add(self, values):
        # pylint: disable=too-many-branches
        changed = False
        data_idx = self._data_idx
        x_pos = self._x_pos

        self._update_stats(values)

        if self._mode == "wrap":
            if self._x_pos == 0 or self._scale_mode == "pixel":
                changed = self._auto_plot_range(redraw_plot=False)

            # Undraw any previous data at current x position
            if (not changed and self._data_values >= self._plot_width
                    and self._values >= self._plot_width):
                self._undraw_column(self._x_pos, data_idx - self._plot_width)

        elif self._mode == "scroll":
            if x_pos >= self._plot_width:  # Fallen off x axis range?
                changed = self._auto_plot_range(redraw_plot=False)
                if not changed:
                    self._undraw_bitmap()  # Need to cls for the scroll

                sc_data_idx = ((data_idx + self._scroll_px - self._plot_width)
                               % self._data_size)
                self._data_values -= self._scroll_px
                self._redraw_for_scroll(0,
                                        self._plot_width - 1 - self._scroll_px,
                                        sc_data_idx)
                x_pos = self._plot_width - self._scroll_px

            elif self._scale_mode == "pixel":
                changed = self._auto_plot_range(redraw_plot=True)

        # Draw the new data
        self._data_draw(values, x_pos, data_idx)

        # Store the new values in circular buffer
        self._data_store(values)

        # increment x position dealing with wrap/scroll
        new_x_pos = x_pos + 1
        if new_x_pos >= self._plot_width:
            # fallen off edge so wrap or leave position
            # on last column for scroll
            if self._mode == "wrap":
                self._x_pos = 0
            else:
                self._x_pos = new_x_pos  # this is off screen
        else:
            self._x_pos = new_x_pos

        if self._data_values < self._data_size:
            self._data_values += 1

        self._values += 1

        if self._mu_output:
            print(values)

        # scrolling mode has automatic refresh in background turned off
        if self._mode == "scroll":
            self._display_refresh()

    def _change_y_range(self, new_plot_min, new_plot_max, redraw_plot=True):
        y_min = new_plot_min
        y_max = new_plot_max
        if self._debug >= 2:
            print("Change Y range", new_plot_min, new_plot_max, redraw_plot)

        # if values reduce range below the minimum then widen the range
        # but keep it within the absolute min/max values
        if self._plot_min_range is not None:
            range_extend = self._plot_min_range - (y_max - y_min)
            if range_extend > 0:
                y_max += range_extend / 2
                y_min -= range_extend / 2
                if y_min < self._abs_min:
                    y_min = self._abs_min
                    y_max = y_min + self._plot_min_range
                elif y_max > self._abs_max:
                    y_max = self._abs_max
                    y_min = y_max - self._plot_min_range

        self._plot_min = y_min
        self._plot_max = y_max
        self.set_y_axis_tick_labels(self._plot_min, self._plot_max)

        if self._values:
            self._undraw_bitmap()
            self._recalc_y_pos()  ## calculates new y positions
            if redraw_plot:
                self._redraw_all()

    @property
    def title(self):
        return self._title

    @title.setter
    def title(self, value):
        self._title = value[:self._max_title_len]  # does not show truncation
        self._displayio_title.text = self._title

    @property
    def info(self):
        if self._displayio_info is None:
            return None
        return self._displayio_info.text

    @info.setter
    def info(self, value):
        """Place some text on the screen.
           Multiple lines are supported with newline character.
           Font will be 3x standard terminalio font or 2x if that does not fit."""
        if self._displayio_info is not None:
            self._displayio_graph.pop()

        if value is not None and value != "":
            font_scale = 3
            line_spacing = 1.25

            font_w, font_h = self._font.get_bounding_box()
            text_lines = value.split("\n")
            max_word_chars = max([len(word) for word in text_lines])
            # If too large reduce the scale
            if (max_word_chars * font_scale * font_w > self._screen_width
                    or len(text_lines) * font_scale * font_h * line_spacing > self._screen_height):
                font_scale -= 1

            self._displayio_info = Label(self._font, text=value,
                                         line_spacing=line_spacing,
                                         scale=font_scale,
                                         background_color=self.INFO_FG_COLOR,
                                         color=self.INFO_BG_COLOR)
            # centre the (left justified) text
            self._displayio_info.x = (self._screen_width
                                      - font_scale * font_w * max_word_chars) // 2
            self._displayio_info.y = self._screen_height // 2
            self._displayio_graph.append(self._displayio_info)

        else:
            self._displayio_info = None

        if self._mode == "scroll":
            self._display_refresh()

    @property
    def channels(self):
        return self._channels

    @channels.setter
    def channels(self, value):
        if value > self._max_channels:
            raise ValueError("Exceeds max_channels")
        self._channels = value

    @property
    def y_range(self):
        return (self._plot_min, self._plot_max)

    @y_range.setter
    def y_range(self, minmax):
        if minmax[0] != self._plot_min or minmax[1] != self._plot_max:
            self._change_y_range(minmax[0], minmax[1], redraw_plot=True)

    @property
    def y_full_range(self):
        return (self._plot_min, self._plot_max)

    @y_full_range.setter
    def y_full_range(self, minmax):
        self._abs_min = minmax[0]
        self._abs_max = minmax[1]

    @property
    def y_min_range(self):
        return self._plot_min_range

    @y_min_range.setter
    def y_min_range(self, value):
        self._plot_min_range = value

    @property
    def y_axis_lab(self):
        return self._y_axis_lab

    @y_axis_lab.setter
    def y_axis_lab(self, text):
        self._y_axis_lab = text[:self._y_lab_width]
        font_w, _ = self._font.get_bounding_box()
        x_pos = (40 - font_w * len(self._y_axis_lab)) // 2
        # max() used to prevent negative (off-screen) values
        self._displayio_y_axis_lab.x = max(0, x_pos)
        self._displayio_y_axis_lab.text = self._y_axis_lab

    @property
    def channel_colidx(self):
        return self._channel_colidx

    @channel_colidx.setter
    def channel_colidx(self, value):
        # tuple() ensures object has a local / read-only copy of data
        self._channel_colidx = tuple(value)

    @property
    def mu_output(self):
        return self._mu_output

    @mu_output.setter
    def mu_output(self, value):
        self._mu_output = value

    @property
    def y_range_lock(self):
        return self._plot_range_lock

    @y_range_lock.setter
    def y_range_lock(self, value):
        self._plot_range_lock = value

Code Discussion

The Design section covers a lot of aspects of the program. A few interesting parts of the implementation are discussed here.

IlluminatedColorPlotSource class

This has the two required methods, the constructor and data(). It also implements two optional methods:

  • start() to set the gain and duration parameters on the colour sensor and turn on the white LEDs.
  • stop() to turn off the white LEDs.
Download: file
class IlluminatedColorPlotSource(PlotSource):
    def __init__(self, my_clue, mode="Clear"):
        self._clue = my_clue
        col_fl_lc = mode[0].lower()
        if col_fl_lc == "r":
            plot_colour = self.RGB_COLORS[0]
        elif col_fl_lc == "g":
            plot_colour = self.RGB_COLORS[1]
        elif col_fl_lc == "b":
            plot_colour = self.RGB_COLORS[2]
        elif col_fl_lc == "c":
            plot_colour = self.DEFAULT_COLORS[0]
        else:
            raise ValueError("Colour must be Red, Green, Blue or Clear")

        self._channel = col_fl_lc
        super().__init__(1, "Illum. color: " + self._channel.upper(),
                         abs_min=0, abs_max=8000,
                         initial_min=0, initial_max=2000,
                         colors=(plot_colour,),
                         rate=50)

    def data(self):
        (r, g, b, c) = self._clue.color
        if self._channel == "r":
            return r
        elif self._channel == "g":
            return g
        elif self._channel == "b":
            return b
        elif self._channel == "c":
            return c
        else:
            return None  # This should never happen

    def start(self):
        # Set APDS9960 to sample every (256 - 249 ) * 2.78 = 19.46ms
        # pylint: disable=protected-access
        self._clue._sensor.integration_time = 249  # 19.46ms, ~ 50Hz
        self._clue._sensor.color_gain = 0x03  # 64x (library default is 4x)

        self._clue.white_leds = True

    def stop(self):
        self._clue.white_leds = False

The constructor configures the object to select the appropriate value from the colour sensor. It is intended to accept Red, Green, Blue or Clear but will accept red and rouge as it is only checking the lowercase first character. This could be viewed as an inappropriate application of the robustness principle.

The full range of the sensor data is hard-coded in the constructor as 0 and 8000. This is based on observations of the data reaching 7169, 8000 gives nicer values on the y axis tick labels. It's better to retrieve this value programmatically, if it's available, and verify that value against the manufacturer's data sheet.

The clue object does not have a property, public method or public variable to access the sensor to set integration_time and color_again. The single underscore prefix on the _sensor instance variable indicates that it is intended only for the class itself to access but Python does not enforce this. This allows static code analyzers like pylint to detect misuse. In this case, the minor misuse is acknowledged with a pylint disable pragma which inhibits the warning. For critical applications this would be inappropriate as it is breaking the object's encapsulation and makes the application vulnerable in possibly disastrous ways to future changes in the class.

Units and Interfaces

The setting of integration_time in ColorPlotSource and IlluminatedColorPlotSource uses an obscure, manufacturer-specific formula for the actual time not described in the underlying library documentation. This is confusing for a programmer who has previously used the Arduino library which uses milliseconds as units. Changing the CircuitPython library integration_time would require a coordinated and synchronised change with every application which uses the library. This is generally impractical.

The gyro value retrieved in GyroPlotSource is documented as being measured in degrees per second and tests confirm this. The design guide for CircuitPython's libraries lists gyro with a type of (float, float, float) and units of "x, y, z radians per second". This presents the same problem as the previous case as a change will have impact to many existing users.

These two examples highlight the importance of thoughtful design and review of interfaces.

Setting Properties and Pass by ...

Python has a feature called properties which is often used in classes to create things which appear to be instance variables but actually execute code when they are read (get) or assigned to (set). The classic example below features a pair of getter and setter methods with decorators.

Download: file
@property
    def channel_colidx(self):
        return self._channel_colidx

    @channel_colidx.setter
    def channel_colidx(self, value):
        # tuple() ensures object has a local / read-only copy of data
        self._channel_colidx = tuple(value)

The comment is already explaining a subtlety here of using the passed value. The value is intended to be a sequence. If a list is passed the the caller part of the program can modify the list stored in the class as it is passed by reference. For comparison, if the value had been an int this would not be possible as it is passed by value. This is shown on REPL below with a similar example.

Download: file
>>> class class_storing_sequence():
...     def __init__(self, a_sequence):
...         self._a_sequence = a_sequence
...         self._a_sequence_copy = tuple(a_sequence)
...
>>> words = ["pass", "by"]
>>> obj = class_storing_sequence(words)
>>> words.extend(["object", "reference"])

>>> obj._a_sequence
['pass', 'by', 'object', 'reference']
>>> obj._a_sequence_copy
('pass', 'by')

An even more confusing permutation would be another part of the program retrieving the channel_colidx list and intentionally or unintentionally modifying it. This could cause tricky-to-find bugs. The cautious use of tuple() in channel_colidx(self, value) creates an independent copy of the sequence. The use of tuple() rather than list() is very intentional as this makes it read-only due to Python tuples being immutable.

The general issue is described and discussed in detail in Robert Heaton's blog post: Is Python pass-by-reference or pass-by-value?

This guide was first published on Apr 01, 2020. It was last updated on Apr 01, 2020.
This page (Sensor Plotter) was last updated on Jul 11, 2020.