How It Works

The code basically works like this:

  1. Initial setup
  2. Update data
  3. Update plots
  4. Goto 2

Let's go through each of these.

Initial Setup

The initial setup creates the lists (actually deques) the data will be stored in, the TFT setup, and the plot setup including the axis and the plot lines. This ends up being a fair amount of the code. It's all this:

Download: file
# Setup X data storage
x_time = [x * REFRESH_RATE for x in range(HIST_SIZE)]
x_time.reverse()

# Setup Y data storage
y_data = [ [deque([None] * HIST_SIZE, maxlen=HIST_SIZE) for _ in plot['line_config']]
           for plot in PLOT_CONFIG
         ]

# Setup display
disp = ili9341.ILI9341(board.SPI(), baudrate = 24000000,
                       cs  = digitalio.DigitalInOut(board.D4),
                       dc  = digitalio.DigitalInOut(board.D5),
                       rst = digitalio.DigitalInOut(board.D6))

# Setup plot figure
plt.style.use('dark_background')
fig, ax = plt.subplots(2, 1, figsize=(disp.width / 100, disp.height / 100))

# Setup plot axis
ax[0].xaxis.set_ticklabels([])
for plot, a in enumerate(ax):
    # add grid to all plots
    a.grid(True, linestyle=':')
    # limit and invert x time axis
    a.set_xlim(min(x_time), max(x_time))
    a.invert_xaxis()
    # custom settings
    if 'title' in PLOT_CONFIG[plot]:
        a.set_title(PLOT_CONFIG[plot]['title'], position=(0.5, 0.8))
    if 'ylim' in PLOT_CONFIG[plot]:
        a.set_ylim(PLOT_CONFIG[plot]['ylim'])

# Setup plot lines
#pylint: disable=redefined-outer-name
plot_lines = []
for plot, config in enumerate(PLOT_CONFIG):
    lines = []
    for index, line_config in enumerate(config['line_config']):
        # create line
        line, = ax[plot].plot(x_time, y_data[plot][index])
        # custom settings
        if 'color' in line_config:
            line.set_color(line_config['color'])
        if 'width' in line_config:
            line.set_linewidth(line_config['width'])
        if 'style' in line_config:
            line.set_linestyle(line_config['style'])
        # add line to list
        lines.append(line)
    plot_lines.append(lines)

Update Data

Updating the data is done in the function update_data(). You would change this to be whatever you want (more info below). Wherever the new data comes from, you would then just add each new data point to the data stores. For the basic example, this is just random numbers and a sine curve:

Download: file
def update_data():
    ''' Do whatever to update your data here. General form is:
           y_data[plot][line].append(new_data_point)
    '''
    # upper plot data
    for data in y_data[0]:
        data.append(random.random())

    # lower plot data
    y_data[1][0].append(math.sin(0.5 * time.monotonic()))

Each line of the plot has a dedicated deque to store its data. Each plot stores these deques in a list. These lists are then stored in the variable y_data. Therefore, to access the line for a given plot, you'd use the syntax y_data[plot][line]. You'll generally want to use the append() method to add each new data point. The result is something like:

Download: file
y_data[plot][line].append(new_data_point)

You can also use Python iterator syntax if that works for your scenario. That's how the random data is appended in the example above.

Also remember that indexing is 0 based. So the top plot is 0 and the bottom plot is 1.

Update Plots

Updating the plots is where Matplotlib and Pillow are used. This is all done in the function update_plot(). The basic idea is to update the ydata for each line with the current data. The plot is then re-rendered. Pillow is then used to generate an image object that can be sent to the TFT display.

You generally won't have to deal with this function.

Download: file
def update_plot():
    # update lines with latest data
    for plot, lines in enumerate(plot_lines):
        for index, line in enumerate(lines):
            line.set_ydata(y_data[plot][index])
        # autoscale if not specified
        if 'ylim' not in PLOT_CONFIG[plot].keys():
            ax[plot].relim()
            ax[plot].autoscale_view()
    # draw the plots
    canvas = plt.get_current_fig_manager().canvas
    plt.tight_layout()
    canvas.draw()
    # transfer into PIL image and display
    image = Image.frombytes('RGB', canvas.get_width_height(),
                            canvas.tostring_rgb())
    disp.image(image)

Goto 2

And that's it. The whole thing is driven by the simple loop at the bottom:

Download: file
print("looping")
while True:
    update_data()
    update_plot()
    time.sleep(REFRESH_RATE)
Drawing to the display requires quite a bit of CPU power, so we need to add a time.sleep() so we don't slow down the PC. Note that if you reduce the delay, your CPU usage will increase, and there's a natural limit of perhaps 10 FPS before we can't send data fast enough to the display. Don't expect video-level playback

Customization

We skipped over a bunch of additional "initial setup" at the top of the code. This is all the code between the two comment lines:

Download: file
#==| User Config |======================

This is where you'll edit the code to customize it for your use. There are two general parts:

  1. Configure behavior and aesthetics via the CONSTANTS
  2. Change the update_data() function for your use case

Behavior and Aesthetics

The two constants REFRESH_RATE and HIST_SIZE are pretty straight forward. They determine how often the data plot is updated and how much total data to store. The two together define the total time window for the plot as REFRESH_RATE * HIST_SIZE.

The more interesting constant is the PLOT_CONFIG structure. As the name implies, this is how you will configure the two plots. It's a bit of a nested mess of tuples and dictionaries. But hopefully it's laid out in a way to make it easy to edit.

At a minimum, you need the line_config entry for each plot with one empty dictionary for each line of the plot. So the absolute minimum PLOT_CONFIG would look like:

Download: file
PLOT_CONFIG = (
    #--------------------
    # PLOT 1 (upper plot)
    #--------------------
    {
    'line_config' : (
        { },
        )
    },
    #--------------------
    # PLOT 2 (lower plot)
    #--------------------
    {
    'line_config' : (
        { },
        )
    }
)

This would create a single plot line for both the upper and lower plots with default values used. A plot line is created for each dictionary entry in line_config. So if you wanted to add a second plot line to the upper plot, the above would become:

Download: file
PLOT_CONFIG = (
    #--------------------
    # PLOT 1 (upper plot)
    #--------------------
    {
    'line_config' : (
        { },
        { },
        )
    },
    #--------------------
    # PLOT 2 (lower plot)
    #--------------------
    {
    'line_config' : (
        { },
        )
    }
)

The only change is one additional line with an empty dictionary { }.

The PLOT_CONFIG in the basic test example tries to demonstrate your various customization options. For any of them, if they are not specified, default values are used. So, looking at the basic test example:

Download: file
PLOT_CONFIG = (
    #--------------------
    # PLOT 1 (upper plot)
    #--------------------
    {
    'line_config' : (
        {'color' : '#FF0000', 'width' : 2, 'style' : '--'},
        { },
        )
    },
    #--------------------
    # PLOT 2 (lower plot)
    #--------------------
    {
    'title' : 'sin()',
    'ylim' : (-1.5, 1.5),
    'line_config' : (
        {'color' : '#00FF00', 'width' : 4},
        )
    }
)

The upper plot has no title or y axis limits set. The y axis will autoscale. Two plot lines are setup in line_config. The first line has color, width, and style specified. The second line has nothing specified so will use default settings.

The lower plot specifies a plot title. This is simply some text that will be shown at the top of the plot. It also species specific y axis limits via ylim, therefore the y axis will not autoscale. A single plot line is setup in line_config with color and width specified. A default style will be used.

In general, you should match up the number of lines configured in line_config with the data that is updated in update_data().

Watch out for missing {}, (), and/or ,'s when editing the PLOT_CONFIG structure.

Custom Data

The update_data() function can be whatever you want. As mentioned above, the general syntax for a given line of a given plot is:

Download: file
y_data[plot][line].append(new_data_point)

The system status examples that follow will provide more examples on how this function is used.

This guide was first published on Nov 13, 2019. It was last updated on Nov 13, 2019. This page (How It Works) was last updated on Dec 06, 2019.