Pages

Thursday, September 25, 2014

Real Time Serial Data Monitor with Python

In this post we will cover how to write a Python script using the PySerial and Matplotlib packages to plot serial data in real time...


Let’s say you’ve just hooked up a new sensor to your Arduino, and you want to get a feel for the data it’s outputting. So you upload a simple sketch to the Arduino and open up the serial monitor. The serial monitor starts spitting out numbers at a ridiculously fast speed, and there’s enough noise in the sensor that the only thing the serial monitor tells you is “Yes. There is indeed data.”

But what if I want to understand it? What if I want to see the data on a graph?

One common method is to copy-and-paste all of the data from the serial monitor into a program like excel and graph it there. But that sucks. It takes a lot of time and makes it hard for your brain to connect the relationship between input into the sensor and the data. So let’s write a free, open-source program that can read serial data and plot it on the screen in real time.

(Just want the code? Here it is.)

Hold on, what is serial data and how do I send it to the computer? 


Getting Serial Data



Serial data is information that is communicated one bit at a time. As opposed to analog data, which [basically] can be a range of voltage values, serial data is communicated as 0 or 1. By sending long streams of 0s and 1s at extremely high speeds, digital processors communicate. There are different protocols for how the processors should interpret the bits, but in this case we are talking about USB.

For more information on how to send serial data from a controller to a computer, check out documentation for your controller. For Arduino, go to their Serial.print reference page.

We will be embedding our controller (through a USB connection) with a code that will tell it to send serial data back through that same USB connection. So we can upload a sketch to our Arduino and read the data with something other than the Arduino IDE.

For our test run in this post, we'll use the following Arduino sketch to read the the x, y, and z accelerometer data from an MPU-6050 6-axis IMU (inertial measurement unit). If you're asking "what the fuck is that?", don't worry about it. The real time data monitor will process whatever numbers you send it, just note:
  1. You can send in any number of lines to be plotted by the real time data monitor...
  2. Assuming you send it in line by line as values separated by tabs, where the first value on each line is the time data for each of the successive values on that line. Like This:
0.0    43.4    21.3    65.2 
0.1    43.2    19.8    66.7 
0.2    44.1    20.7    66.2 
...
You can of course change this behavior if you'd like. This is just how this particular script is set up. Anyways, here's the sketch:


// MPU-6050 Short Example Sketch
// By Barak Alon, modified from Arduino User JohnChi
#include<Wire.h>
const int MPU=0x68;  // I2C address of the MPU-6050
int AcX,AcY,AcZ;
float this_t;

void setup(){
  Wire.begin();
  Wire.beginTransmission(MPU);
  Wire.write(0x6B);  // PWR_MGMT_1 register
  Wire.write(0);     // set to zero (wakes up the MPU-6050)
  Wire.endTransmission(true);
  Serial.begin(9600);
}

void loop(){
  Wire.beginTransmission(MPU);
  Wire.write(0x3B);  // starting with register 0x3B (ACCEL_XOUT_H)
  Wire.endTransmission(false);
  Wire.requestFrom(MPU,6,true);  // request a total of 6 registers
  AcX=Wire.read()<<8|Wire.read();  // 0x3B (ACCEL_XOUT_H) & 0x3C (ACCEL_XOUT_L)     
  AcY=Wire.read()<<8|Wire.read();  // 0x3D (ACCEL_YOUT_H) & 0x3E (ACCEL_YOUT_L)
  AcZ=Wire.read()<<8|Wire.read();  // 0x3F (ACCEL_ZOUT_H) & 0x40 (ACCEL_ZOUT_L)
  
  this_t = millis()/1000.0;  // record the current time in seconds
  
  // print the acceleration data
  Serial.print(this_t);
  Serial.print("\t");
  Serial.print(AcX); 
  Serial.print("\t");
  Serial.print(AcY); 
  Serial.print("\t");
  Serial.println(AcZ); 
}


The Basic Process



Here’s a flowchart of what our Python script will be doing at a very basic level: 




Before we start collected serial data and plotting it, we first create a plot and initialize some lines. During our loop, we continuously add x and y values into the line data, but at this point they are empty and waiting. We also open up a connection to the serial port so our script can read the data that is coming in. 

During our loop, we read a line from the serial input buffer. The input buffer is a place in the computer’s memory where we the computer automatically stores all the incoming serial data while it waits to be read. PySerial is the python package that will handle most of the dirty work of reading serial data. It knows what a line is by reading data until it gets to a newline character (“\n”).

Next, we process that data to get it into a form we can work with- instead of lines of byte literals (how the data comes in through serial communication), we want pretty lists that we can easily add to the x and y data of our lines. We then add that processed data into our lines and redraw our plot on the screen- Matplotlib is the Python package that will handle all of the plotting. By repeating this process over and over and high speeds, we animate the data coming in at real time. 

But there’s a problem – Matplotlib isn’t fast enough to keep up with the incoming data...


The concept behind our buffer 



Let’s think of it like this: 




The Arduino is sending data to the computer at ultra high speeds (9600 baud, for example). It takes matplotlib a relatively long time to redraw the plot on screen, and while it is redrawing the plot, data is piling up in the serial input buffer. Matplotlib starts graphing the data slower than its actually coming in, until the data fills up the maximum space in the serial input buffer and the whole process goes to hell. 

This is because matplotlib isn’t designed to plot data in real time – it’s designed to make report-quality graphs. Looking to the above picture, Mr. Python has to pick up a paper containing one line of serial data (from the bottom of the pile, to be more accurate..), get up from his desk, walk over to the chart, and add a point to the plot. This is a process that was not designed to operate quickly, and the Arduino is just shooting so many papers to Mr. Python’s inbox that he can’t keep up. 

But we can get around this by adding our own buffer. We will move data from the Serial input buffer into a secondary buffer, and we will only redraw our plot every several lines of serial data, decreasing the load on matplotlib. This would be like giving Mr. Python a separate piece of paper that he copies lines from his inbox to, and he only has to get up out of his chair to redraw the plot once he’s copied, say, 10 lines down. This may mean a choppier plot because data is being drawn in chunks, but it allows our script to keep up with the mass of incoming data. 


A More Complete Flowchart 





You’ll notice a few additions from last time. Nothing crazy, but not we added “flush out the serial input buffer and reset the controller” – this is because when the connection first open there can be left over data in the buffer, so we want to dispose of this nonsense and reset the Arduino to start the embedded code over again. 

Alright, too much explanation. Let’s get to the code. 


The Code 




'''
Real Time Data

This script plots real time serial data on a left-scrolling chart.

Expected Serial Data Format:

time    x1      x2      ...     xn
 |      |       |       ...     |
 v      v       v       ...     v
x.x \t  x.x \t  x.x \t  ...\t   x.x \n
x.x \t  x.x \t  x.x \t  ...\t   x.x \n
x.x \t  x.x \t  x.x \t  ...\t   x.x \n

Last updated: 9/17/14
Author: Barak Alon
'''

import serial, time, sys, select
import numpy as np
import matplotlib.pyplot as plt


PORT_NAME = '/dev/tty.usbmodem411'    # serial port name depends on system
BAUDRATE = 9600

# Buffer size is in terms of number of "serial data rows to collect before  
# redrawing the plot." If the program is having trouble keeping up with
# incoming serial data, increase the buffer size.
BUFFER_SIZE = 5  

# This is the number of data streams to plot (should be one less than columns).
NUM_OF_LINES = 3  

# X-Axis Range is the size of the x-limits to keep on screen as the chart 
# scrolls. If data is in seconds, this is how long a data point is on screen.
XAXIS_RANGE = 10.0  


def initialize_plot(number_of_lines):
    """
    Set up the matplotlib figure to be plotted, collect the data labels
    from user, and return the line handles.
    """
    plt.ion()    # "interactive mode" - allows for continuous editing
 
    line_objects = [] 
    labels = []
 
    # Create the line objects that will be plotted in real time.
    for i in range(number_of_lines):
        line, = plt.plot([], [])
        line_objects.append(line)
        label = input("Enter label for data in column " + str(i+2) + ": ")
        labels.append(label)
 
    # Create the plot legend, attaching the labels to their corresponding lines.
    plt.legend(line_objects,
               labels,
               loc='upper center',
               prop={'size':10},
               bbox_to_anchor=(0.5, 1.07),
               ncol=3,
              )
 
    # Actually draw the plot on screen, without blocking the progress of script.
    plt.show(block=False)
 
    # The plot updates constantly, making it hard to resize the window. Also,
    # I was having issues with the matplotlib API, so this is a quick fix...
    input("Adjust plot window size now, then hit <enter>...")
 
    return line_objects
           
def refresh_serial_port(serial_port):
    """
    Flush out the serial input buffer and reset the controller. 
    (Data can be all screwy when you first open a port. This helps clean it up.)
    """
    serial_port.setDTR(False)
    time.sleep(1)
    serial_port.flushInput()
    serial_port.setDTR(True)

def process_buffer(buf, number_of_lines):
    """
    Take the data in the buffer return neat data columns that we can use.
    (Data in the buffer is stored as lines of byte literals, and we want 
    columns of floats.)
    """
    # Split the byte literal lines into lists of byte literals.
    data = [line.split() for line in buf if line]
    
    # Convert each byte literal into a float.
    data = [[float(value) for value in row] for row in data]    
    
    # Transpose the data matrix (it works better with update_plot like this).
    cols = []
    for j in range(number_of_lines + 1):
        col = [row[j] for row in data]
        cols.append(col)
    
    return cols
    
def update_plot(line_objects, cols, x_range):
    """
    Take the line objects, add in the data from the buffer, and redraw the plot.
    """
    # Update the x-values for every line as the time data, which should be
    # stored in column 0. Set the y-values from the rest of the columns.
    for i, line in enumerate(line_objects):
        line.set_data(np.append(line.get_xdata(), cols[0]), 
                      np.append(line.get_ydata(), cols[i+1]))                          
    
    # In order to scroll the plot to the left, the right x-limit should be the 
    # last time value from the buffer. The left x-limit should be that minus
    # the range of x-values to keep on the screen.
    last_time_value = cols[0][len(cols[0])-1]
    plt.xlim(last_time_value - x_range, last_time_value)
    
    # Relimit the y-axis and rescale the plot.
    ax = plt.gca()       # "gca" stands for "get current axes"
    ax.relim()
    ax.autoscale_view()
    
    # Finally, take all this new data and redraw the plot on screen.
    plt.draw()


def main():
    buffer = []

    print("Initializing plot and lines...")
    lines = initialize_plot(NUM_OF_LINES)

    print("Opening serial port " + 
          PORT_NAME + " at " + str(BAUDRATE) + " baudrate...")           
    ser = serial.Serial(port=PORT_NAME, baudrate=BAUDRATE, timeout=1)

    print("Beginning data collection...")
    print("Press <enter> to stop...")
    refresh_serial_port(ser)

    while True:
        # Read a line from the serial input buffer and add it to our own buffer
        buffer.append(ser.readline())    
  
        # Flush the buffer if it reaches it's maximum size
        if len(buffer) >= BUFFER_SIZE:
            columns = process_buffer(buffer, NUM_OF_LINES)
            update_plot(lines, columns, XAXIS_RANGE)
            buffer = []
  
    # This little diddly allows the user to hit <enter> to quit the loop
    if sys.stdin in select.select([sys.stdin], [], [], 0)[0]:
        line = input()
        break
    
    # Close the serial port, preventing the port from being frozen.
    print("Closing serial port...")        
    ser.close()


# Call the main function
if __name__ == '__main__':
    main()
(Get in on Github here)

A couple notes on the global variables that the script uses to change some of the settings: 
  • PORT_NAME - The name of your serial port will depend on your system. For Linux based systems it’s likely something like “/dev/tty/blahblahblah”, and if it’s Windows it’s probably close to “COM3”. 
  • BAUDRATE – This is the speed at which data is being sent from the Arduino (in “symbols per second”). Make sure this is the same baudrate that your controller is sending data to the computer at.
  • BUFFER_SIZE - This defines how many lines to keep in the buffer before flushing it to be processed and plotted. This significantly decreases the amount of times that matplotlib has to redraw the plot and should be increased if the script is having trouble keep up with incoming serial data. 
  • NUM_OF_LINES - This is simply the amount of lines to plot, or in other words the amount of data stream coming in. It should be one less than the number of columns (because the 0th column is reserved for time). 
  • XAXIS_RANGE - The plot is left scrolling with time - this essentially defines how long to keep data on the plot. If your time data is coming in seconds, this is the time it takes in seconds for data to get from the right side of the plot to the left. 

Let's go over how our buffer works. Here is a copy of the main function with some key lines highlighted:


def main():
    buffer = []

    print("Initializing plot and lines...")
    lines = initialize_plot(NUM_OF_LINES)

    print("Opening serial port " + 
          PORT_NAME + " at " + str(BAUDRATE) + " baudrate...")           
    ser = serial.Serial(port=PORT_NAME, baudrate=BAUDRATE, timeout=1)

    print("Beginning data collection...")
    print("Press <enter> to stop...")
    refresh_serial_port(ser)

    while True:
        # Read a line from the serial input buffer and add it to our own buffer
        buffer.append(ser.readline())   
  
        # Flush the buffer if it reaches it's maximum size
        if len(buffer) >= BUFFER_SIZE:
            columns = process_buffer(buffer, NUM_OF_LINES)
            update_plot(lines, columns, XAXIS_RANGE)
            buffer = []
  
    # This little diddly allows the user to hit <enter> to quit the loop
    if sys.stdin in select.select([sys.stdin], [], [], 0)[0]:
        line = input()
        break
    
    # Close the serial port, preventing the port from being frozen.
    print("Closing serial port...")        
    ser.close()

We have to first create an empty list so the .append() function has something to append to. Then we append one line from the serial input buffer every go of our loop. When the number of lines in our buffer reaches the buffer size that we declare at the front of our script, it processes the the data, redraws the plot, and empties out our buffer so we can repeat the process.


Other than that I tried to comment up the script as best I could. If you have any question, comments, or corrections, please reach out!


Here it is in Use… 



video 



Hope this helps!

No comments:

Post a Comment