Stuck trying to plot data from COM port in real time

I’m trying to plot data coming from the serial port in real-time. Not sure if how I’m doing it the correct approach but this is what I did. I created this module that I call SerialPortReader.jl the code for it is as follows:

"""
    SerialPortReader

This module provides functionality to read data from a serial port, 
parse it and pass it to a user-specified function for further processing.
"""
module SerialPortReader

    # Import serial port package
    using LibSerialPort

    # Flag to control data reading
    global continue_reading = false

    """
        read_serial_data(ch::Channel, portname::String, baudrate::Int)

    Reads data from the serial port specified by `portname` at the given `baudrate`, 
    splits the incoming string into substrings, drops the first element and puts the 
    rest into the passed `Channel` `ch`.

    # Arguments
    - `ch`: Channel where the read data is put (Channel)
    - `portname`: Name of the serial port (String)
    - `baudrate`: Baud rate for the serial communication (Int)

    # Returns
    - `nothing`
    """
    function read_serial_data(ch::Channel, portname::String, baudrate::Int)
        try
            LibSerialPort.open(portname, baudrate) do sp
                while (continue_reading)
                    if bytesavailable(sp) > 0
                        data = readuntil(sp, '\n')
                        data_str = String(data)
                        str_values = split(data_str)  # Split the string into an array of substrings
                        str_values = str_values[2:end]  # Drop the first element
                        put!(ch, str_values)  # Put the data line into the channel
                    end
                    sleep(0.1)  # Pause to prevent high CPU usage
                end
            end
        catch e
            println("Could not open port $portname: $e")  # Error handling
        end
    end

    """
    start_reading(portname::String, baudrate::Int; print_data_to_console::Bool=false)

    Starts reading data from the serial port specified by `portname` at the given `baudrate`. 
    This function starts two asynchronous tasks: one for reading the data from the serial port 
    and another for processing the data lines. If `print_data_to_console` is set to true, the 
    read data is printed to the console.

    # Arguments
    - `portname`: Name of the serial port (String)
    - `baudrate`: Baud rate for the serial communication (Int)
    - `print_data_to_console`: If set to true, the read data is printed to the console. (Bool, default=false)

    # Returns
    - A Channel that the read data is pushed into
    """
    function start_reading(portname::String, baudrate::Int; print_data_to_console::Bool=false)
        global continue_reading = true
        ch = Channel{Array{SubString{String},1}}(Inf)  # Create a channel to store the data lines
        data_ch = Channel{Array{SubString{String},1}}(Inf)  # Create a channel to return the data
        @async read_serial_data(ch, portname, baudrate)  # Run read_serial_data in a separate task
        @async while continue_reading  # Process the data lines in another task
            data_line = take!(ch)  # Take a data line from the channel
            if print_data_to_console
                println(data_line)  # print the data line to the console
            end
            put!(data_ch, data_line)  # put the data line into the data channel
        end
        return data_ch  # return the data channel
    end

    """
        stop_reading()

    Stops reading data from the serial port.

    # Returns
    - `nothing`
    """
    function stop_reading()
        global continue_reading = false
    end

    # Export the functions
    export start_reading, stop_reading

end

In a second script that I call test_script.jl I call the start_reading function from SerialPortReader.jl, parse the data, add a timestamp and output it in the REPL to just to make sure everything it working. so far so good. See the code for test_scripts.jl below:

# Load packages that will be used
using GLMakie
using Observables
using DataStructures: CircularBuffer
include("SerialPortReader.jl")
using .SerialPortReader  # Import your SerialPortReader module
using Dates


# Define a global variable to control the viewing process
global continue_viewing = true

"""
    parse_data(data::Array{SubString{String},1})

Parse the data read from the serial port. This function needs to be implemented.

# Arguments
- `data`: Data read from the serial port (Array{SubString{String},1})

# Returns
- Parsed data
"""
function parse_data(data::Array{SubString{String},1})
    # Remove any non-numeric strings
    numeric_data = filter(x -> isdigit(x[1]) || x[1] == '.', data)

    # Convert each string in the array to a float
    float_data = map(x -> parse(Float64, x), numeric_data)
    
    # If there are more than five elements, keep only the last five
    if length(float_data) > 5
        float_data = float_data[end-3:end]
    end

    return float_data
end

"""
    view_data(portname::String, baudrate::Int)

Starts reading data from the serial port, parses the data, adds an elapsed time column in seconds, 
and prints the data to the console.

# Arguments
- `portname`: Name of the serial port (String)
- `baudrate`: Baud rate for the serial communication (Int)

# Returns
- `nothing`
"""
function view_data(portname::String, baudrate::Int)
    global continue_viewing = true
    data_ch = SerialPortReader.start_reading(portname, baudrate)
    start_time = now()
    @async while continue_viewing
        data = take!(data_ch)
        parsed_data = parse_data(data)
        elapsed_time = (now() - start_time).value / 1000
        parsed_data = [elapsed_time; parsed_data]
        @show parsed_data
    end
end

"""
    stop_viewing()

Stops the data viewing process.

# Returns
- `nothing`
"""
function stop_viewing()
    global continue_viewing = false
    SerialPortReader.stop_reading()
end

# Call the functions
view_data("COM12", 9600)
sleep(15)
stop_viewing()

When I execute test_scripts.jl I get this output in the REPL which is what I expect.

parsed_data = [0.621, 52.1, 23.4, 74.12, 23.16, 73.68]
parsed_data = [2.575, 52.1, 23.4, 74.12, 23.16, 73.68]
parsed_data = [4.638, 52.0, 23.4, 74.12, 23.15, 73.68]
parsed_data = [6.59, 51.9, 23.4, 74.12, 23.15, 73.67]
parsed_data = [8.643, 51.9, 23.4, 74.12, 23.15, 73.67]
parsed_data = [10.599, 51.8, 23.4, 74.12, 23.15, 73.67]
parsed_data = [12.654, 51.8, 23.4, 74.12, 23.15, 73.67]
parsed_data = [14.598, 51.8, 23.4, 74.12, 23.15, 73.67]
false

julia> 

Where I’m getting stuck is how to plot lets say the first and second element of parse_data within the view_data function in real-time using Makie. I’ve attempted the CircularBuffer route and the Dataframes route in combination with Observables. I tried using Channels, but all ended in failure. Unfortunately, I’ve failed too many ways to post here, so I’ve decided to start with where things are working and see if someone could provide me with at least a hint that could point me in the right direction.

This is some real-time plotting program I wrote some years ago: GitHub - ufechner7/KiteViewer: 3D viewer and simulator for airborne wind energy systems

It is not a package and needs an old version of Makie, but it might give you an idea.

1 Like

An example what you did with GLMakie would help, otherwise we don’t know what problems you might have encountered. How you extract data via the serial port doesn’t really matter from Makie’s perspective, it’s just numerical data after all

I tried this and got a plot but it was empty:

function view_data(portname::String, baudrate::Int)
    global continue_viewing = true
    data_ch = SerialPortReader.start_reading(portname, baudrate)
    start_time = now()
    
    # Create a scatter plot
    fig = Figure()
    ax = Axis(fig[1, 1], xlabel="Elapsed Time (s)", ylabel="Next Index")
    scatterplot = scatter!(ax, Float32[], Float32[], color=:red)
    display(fig)
    
    @async while continue_viewing
        data = take!(data_ch)
        parsed_data = parse_data(data)
        elapsed_time = (now() - start_time).value / 1000
        parsed_data = [elapsed_time; parsed_data]
        @show parsed_data
        # Convert the parsed data to an array of Float32 elements
        x = Float32(parsed_data[1])
        y = Float32(parsed_data[2])  # Add 1 to the index to start from 1
        
        # Update the scatter plot with the parsed data
        scatterplot[] = ([x], [y])
        
        # Redraw the plot
        fig[1][:xlim] = (max(0, x-10), x+1)
        fig[1][:ylim] = (0, max(1, y+1))
        display(fig)
    end
end

There’s a lot that goes wrong here…

You should probably look at the docs if you want to create something with Makie:
https://docs.makie.org/stable/documentation/animation/index.html#animations
https://docs.makie.org/stable/documentation/nodes/index.html#observables_interaction

This example from the docs should be pretty close to what you’re trying:
https://docs.makie.org/stable/documentation/animation/index.html#appending_data_with_observables

1 Like

@sdanisch I looked at the content you recommended. It did help a bit , but I’m still a bit stuck. I tried to follow the content as best as I could keeping in mind my specific application but I’m still not getting the behavior I’m looking for. I’m pretty certain my plot is updating when I start collecting data, however the incoming data is overwriting the data already in my dataframe. It is related to how I am updating the observable inside the while loop in the start_reading_data_for_live_plot() function. I have pasted my full script below as well as some images of different behaviors I’m getting. I’m not really sure what to do next at the moment.

# Import necessary packages
using DataFrames
using Dates
using GLMakie
using LibSerialPort
using Observables

# Global flag to control the data reading process
global continue_reading = false

# Function to open a serial port given a port name and baud rate
function open_serial_port(portname::String, baudrate::Int)::SerialPort
    try
        port = LibSerialPort.open(portname, baudrate)
        println("Serial port opened successfully!")
        return port
    catch e
        println("Could not open port $portname: $e")  # Error handling
        return nothing
    end
end

# Function to close a serial port
function close_serial_port(port::SerialPort)::Nothing
    close(port)
    return nothing
end

# Function to check if a serial port is open or closed
function check_serial_port(port::SerialPort)::Bool
    if isopen(port)
        println("Serial port open")
        return true
    else
        println("Serial port not open")
        return false
    end
end

# Function to read data from a serial port
# It reads data continuously in an asynchronous loop
function read_serial_data(port::SerialPort)::Union{Channel{Array{SubString{String},1}}, Nothing}
    if check_serial_port(port) == true
        data_ch = Channel{Array{SubString{String},1}}(1)

        @async while true
            data = readuntil(port, '\n')
            data_str = String(data)
            str_values = split(data_str)  # Split the string into an array of substrings
            str_values = str_values[2:end]  # Drop the first element
            put!(data_ch, str_values)
            sleep(0.1)  # Pause to prevent high CPU usage
        end

        return data_ch
    else
        println("Serial port not open")
        return nothing
    end
end

# Function to parse data from an array of strings to an array of floats
function parse_data(data::Array{SubString{String},1})::Array{Float64,1}
    # Remove any non-numeric strings
    numeric_data = filter(x -> isdigit(x[1]) || x[1] == '.', data)

    # Convert each string in the array to a float
    float_data = map(x -> parse(Float64, x), numeric_data)

    return float_data
end

# Function to stop reading data for live plot
function stop_reading_data_for_live_plot()::Nothing
    global continue_reading = false
    return nothing
end

# Function to start reading data for live plot
function start_reading_data_for_live_plot(incoming_data::Union{Channel{Array{SubString{String},1}},Nothing}, df::DataFrame)::Nothing
    global continue_reading = true

    # Re-initialize df_live
    empty!(df)

    if incoming_data == nothing
        println("No data channel available")
        return nothing
    end

    start_time = now()
    @async while continue_reading
        data = take!(incoming_data)
        parsed_data = parse_data(data)
        elapsed_time = (now() - start_time).value / 1000
        parsed_data = [elapsed_time; parsed_data]
        push!(df, parsed_data)
        sleep(0.1)
        # Pause to prevent high CPU usage

        # Update the Observables (I know I need this but I don't know how to do it)
        obs_time[] = df.Time
        obs_humidity[] = df.Humidity
    end
    return nothing
end

# Define an Observable DataFrame to store the live data
df_live = DataFrame(Time=Float64[], Humidity=Float64[], Temperature_C=Float64[], 
                    Temperature_F=Float64[], Heat_Index_C=Float64[], Heat_Index_F=Float64[])


obs_time = Observable(df_live.Time)
obs_humidity = Observable(df_live.Humidity)

on(obs_time) do _
    lineplot[1] = obs_time[]
end

on(obs_humidity) do _
    lineplot[2] = obs_humidity[]
end

# Define a figure to plot the live data
fig = Figure(resolution = (800, 600))
ax = Axis(fig[1, 1])
limits!(ax, -10, 60, 0, 80)
lineplot = scatter!(ax, obs_time, obs_humidity, color = :red, markersize = 50, linestyle = :solid)

display(fig)






# Open the serial port and start reading data
data_ch = open_serial_port("COM18", 9600)
incoming_data = read_serial_data(data_ch)

# Start reading data for live plot
start_reading_data_for_live_plot(incoming_data, df_live)

sleep(15)

# Stop reading data for live plot
stop_reading_data_for_live_plot()

# Close the serial port
close_serial_port(data_ch)

# Display the DataFrame in a table
vscodedisplay(df_live)

# Check if the serial port is closed
check_serial_port(data_ch)

Image of behavior when I try to update observables in while loop:

Image of behavior when I comment out the observables in the while loop (becomes a static plot that doesn’t update unless I run lineplot = scatter!(ax, obs_time, obs_humidity, color = :red, markersize = 50, linestyle = :solid) again after I stop reading data:

Debuggung is easier if you:

  • do not use vscode (less things can go wrong)
  • have a separate program that sends dummy data
  • just use println statements for debuggung

You could use virtual com ports to connect your test sender and receiver…

If you look at this one again Animations · Makie it uses a vector of Points. This is better for animating, because you can change the length of the vector without having uneven lengths for x and y. Compare also with Observables & Interaction · Makie

Here is a MWE that gives you a continuously updated plot:

using GLMakie
using DataFrames

df = DataFrame(t = Float64[0.0], y = Float64[0.0])
obs_t = Observable(df.t)
obs_y = Observable(df.y)

toggle = true
@async while toggle
  # pushing a new row to a dataframe already takes care of possible
  # uneven-length errors that jules mentioned
  push!(df, (t=df.t[end]+1.0, y=randn()))
  notify(obs_t) # same as obs_t[] = df.t but nicer syntax
  # note: no need to also notify(obs_y), would only plot the same data twice
  sleep(0.1)
end

fig = Figure()
ax = Axis(fig[1,1])
on(obs_t) do _
  # recompute axis limits whenever `obs_t` is notified
  reset_limits!(ax)
end
scatter!(ax, obs_t, obs_y)
display(fig)
1 Like

Thank you!