To "clean up" the Learn System repo, we need to move groups of guides into subdirectories. This PR duplicates the CLUE guides into the CLUE subdirectory so guides may be changed prior to deleting redundant project repos to make more space.
908 lines
32 KiB
Python
908 lines
32 KiB
Python
# SPDX-FileCopyrightText: 2020 Kevin J Walters for Adafruit Industries
|
|
#
|
|
# SPDX-License-Identifier: MIT
|
|
|
|
# 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.bitmap_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()
|
|
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,
|
|
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 // 1
|
|
|
|
self._displayio_y_axis_lab = Label(
|
|
self._font, text=self._y_axis_lab, 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 // 1
|
|
|
|
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,
|
|
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()
|
|
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()
|
|
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.root_group = 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 = 2
|
|
line_spacing = 1
|
|
|
|
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 // 3
|
|
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
|