Adafruit_Learning_System_Gu.../CLUE/CLUE_Sensor_Plotter/plotter.py
Anne Barela dc43693f17 Copy CLUE projects to new CLUE subdirectory
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.
2025-02-24 11:21:29 -06:00

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