Maker.io main logo

Build an Arduino-based ML Rain-Prediction Display

2023-12-06 | By Maker.io Staff

License: See Original Project Displays

The previous article in this series discussed exploring and using data to build a machine-learning ‎model for predicting rainfall. It also investigates several preprocessing methods and ways of ‎choosing an algorithm. Ultimately, the system learned to predict whether it would rain on a day ‎based on the expected temperature and wind speed. However, the results still need to reach the ‎user, and this part of the series discusses how you can design and build a stunning UI for ‎displaying the prediction result on any compatible Arduino.‎

close-up-2

Adapting the ML Program

Before displaying the results using a graphical UI, the system has to obtain the current weather ‎information, pass it to the ML model, and then make a prediction. There are multiple ways to ‎accomplish this goal. However, in this example, the ML program fetches the current weather ‎from an online API before predicting and caching the result. The ML program then exposes the ‎result to external clients, such as the Arduino, via a simple online endpoint. This way, the Arduino ‎only has to handle drawing and updating the UI without extra overhead. In addition, you could ‎also add multiple clients that display or process the information from the ML program in different ‎ways.‎

Fetching Weather Data from an API‎

Before making any predictions, the ML program requires the current day’s minimum and ‎maximum temperatures, the wind speed, and the date. This example uses a simple Python ‎library called python-weather, which can be installed using the following command:‎

Copy Code
pip install python-weather

After importing the module, retrieving the weather is as simple as calling a single function and ‎providing the library with a city name:‎

Copy Code
import python_weather
import asyncio
import os

async def getWeather(city):
client = python_weather.Client(unit=python_weather.METRIC)
return await client.get(city)

The ML program should retrieve the current weather data when told to do so by a client and then ‎store the results. For that purpose, it should expose an API endpoint to let clients instruct the ‎program to refresh its cache.‎

Start by installing Flask with support for async operations:‎

Copy Code
pip install 'flask[async]'

And then add a simple endpoint for instructing the server to update its cache:‎

Copy Code
from flask import Flask
from flask import request
from flask import jsonify
from datetime import datetime

app = Flask(__name__)
updated = False
min_temp = 0
max_temp = 0
wind = 0
month = 0
temperature = 0
location = "__NOT_SET__"

@app.route("/update", methods=['GET'])
async def updateWeather():
global updated
global min_temp
global max_temp
global wind
global month
global location
global temperature
hour = datetime.now().hour
day = datetime.now().day
month = datetime.now().month
year = datetime.now().year
location = request.args.get('city')
if location == None or len(location) == 0:
return "Error: city name be set!"
weather = await getWeather(location)
for forecast in weather.forecasts:
if forecast.date.day == day and forecast.date.month == month and forecast.date.year == year:
min_temp = forecast.lowest_temperature
max_temp = forecast.highest_temperature
for hourly in forecast.hourly:
if hourly.time.hour <= hour:
wind = hourly.wind_gust
temperature = hourly.temperature
updated = True
return "OK"

The first few lines import the necessary modules for building a Flask API. The following eight ‎lines define a few global variables for caching the weather parameters and storing the server ‎object. Clients can call the updateWeather method to make the server update its cache. The ‎global statements in the procedure ensure that the function saves the results in the global ‎variables defined outside the function so that other methods in the program can use the same ‎values. After getting the current day, month, and year, the method checks whether the client ‎supplied a city name before requesting the current weather data from the API. The API returns ‎multiple forecast objects for the current and upcoming days, and each day contains forecasts for ‎multiple hours of each day. The two for loops search the returned forecast data for the newest ‎entry that’s not in the future and store the values in the global variables.‎

Exporting the Prediction Result

Once the server stores the weather data in its cache, it can use the ML model to make ‎predictions. In the previous part of this series, the Jupyter Notebook stored the trained machine-‎learning model using Pickle. Before making predictions, the server program needs to load the ‎model using Pickle:‎

Copy Code
t8 = pickle.load(open('model.pkl', 'rb'))

‎Clients can then call another endpoint of the server to request a prediction from the model:‎

Copy Code
@app.route("/classify", methods=['GET'])
def classify():
if updated == False:
return "Error: Must call update first!", 503
a = np.array([month, max_temp, min_temp, wind]);
a = np.reshape(a, (1,-1))
res = t8.predict(a)[0]
return jsonify({"rain": "true" if res == 1 else "false", "temp_min": min_temp, "temp_max": max_temp, "wind": wind, "temperature": temperature})

‎This method first checks whether the client has previously called the update function at some ‎point in the past. It then places the globally cached weather data in an array, reshapes it, and ‎passes it to the model to obtain a prediction. Finally, the method returns the weather prediction to ‎the client, and the server also returns its cached weather data to allow the Arduino client to ‎update the graphical user interface.‎

The Python script’s main method then starts the flask server:‎

Copy Code
if __name__=="__main__":
app.run(host="0.0.0.0",port=1234,debug=True)

You can download the complete Python server program here. In addition, you can also use this ‎link to grab a copy of the pre-trained ML model.‎

Exporting a Squareline Studio UI to an Arduino‎

This part of the project assumes that you're familiar with the basics of building a graphical UI ‎using Squareline Studio and know how to interface touch screens with an Arduino. You can ‎download the finished UI files here. The archive also contains the button icons, which you need ‎to copy to the assets folder of Squareline Studio. The images are part of Google's material icon ‎pack, which can be downloaded and used for free.‎

If you decide to build your own GUI, write down the refresh button's name, as you'll need to link ‎it to an event in the Arduino code to fetch a prediction result from the API. You'll also need to ‎take note of the label names to update and read their values from within the Arduino program:‎

squareline-ui-element-names

This image summarizes the UI element names. Note that Squareline prepends the names with ‎ui_ during the export.‎

Lastly, don’t forget to add events you want Squareline to handle. In this example, I added a ‎second screen for changing settings, such as the units, and I want the second button on the main ‎screen to make the app switch to the settings menu:‎

screen-switch-event

Don’t forget to add all events that Squareline can handle during this step.

When done, export the UI files from Squareline Studio, configure the Arduino UI to use the ‎correct assets folder, and ensure that the Arduino program compiles and runs on the board of ‎your choice. Refer to this article for a refresher on exporting a UI from Squareline Studio.‎

Sending Requests from an Arduino‎

This section of the article assumes you know how to connect your Arduino to a local network ‎and send requests. Either way, after establishing a WiFi connection, the Arduino needs to call the ‎server’s update endpoint and set the city name. The Arduino code accomplishes this by sending ‎a simple HTTP request:‎

Copy Code
void connectToServer()
{
while (!client.connect(server, 1234))
Serial.println("Connection failed! Retrying...");
}

void updateServer()
{
connectToServer();
if (client.connected())
{
String request = "GET /update?city=" + String(selectedCity) + " HTTP/1.1";
String host = "Host: " + serverIp;

client.println(request);
client.println(host);
client.println("Connection: close");
client.println();
}
else return;

while (!client.available());
client.readString();
}

The first method, called connectToServer, tries to establish a connection to the Flask server that ‎hosts the API endpoints. It blocks the program until it manages to establish a connection. The ‎second function (updateServer) then issues a GET request to the server’s update endpoint, ‎adding the city name stored in a global variable. The last two lines in the updateServer function ‎wait for the client to receive the server’s response and dispose of it, as it’s not required.‎

Similarly, the Arduino can request a rain forecast and the current weather data by sending a ‎request to the server’s classify endpoint. However, in this case, the Arduino must read the ‎server’s response, decode the JSON object, and store the values it contains in its local variables:‎

Copy Code
void getPrediction()
{
connectToServer();
if (client.connected())
{
String request = "GET /classify HTTP/1.1";
String host = "Host: " + serverIp;

client.println(request);
client.println(host);
client.println("Connection: close");
client.println();
}
else return;

while (!client.available());
String ret = client.readString();
int start = ret.indexOf('{');
int end = ret.lastIndexOf('}');
String payload = ret.substring(start, end + 1);
client.stop();

DynamicJsonDocument doc(2048);
deserializeJson(doc, payload);

rain = doc["rain"].as<bool>();
min_temp = doc["temp_min"].as<int>();
max_temp = doc["temp_max"].as<int>();
wind = doc["wind"].as<int>();
temperature = doc["temperature"].as<int>();
}

The first few lines are practically identical to the other function. However, instead of throwing ‎away the server’s response, this function finds the JSON object in the response and then uses ‎the ArduinoJson library to read the values in the object.

Linking UI Events to Arduino Code

Three controls in the UI should trigger an event in the Arduino program when a user interacts ‎with them. First, the refresh button should make the Arduino request a new prediction from the ‎ML program API. Secondly, the two dropdown lists on the settings screen should influence how ‎the Arduino processes the information from the API (units) and what data it requests (city name).‎

The Arduino code needs to link the UI elements to functions — the so-called event handlers — it ‎should call when users perform a particular action, such as clicking a button, to make the ‎Arduino react to user interactions:‎

Copy Code
lv_obj_add_event_cb(ui_RefreshButton, onRefreshButtonPressed, LV_EVENT_PRESSED, NULL);
lv_obj_add_event_cb(ui_CitySettingDropdown, onCityChanged, LV_EVENT_VALUE_CHANGED, NULL);
lv_obj_add_event_cb(ui_TemperatureSettingDropdown, onUnitsChanged, LV_EVENT_VALUE_CHANGED, NULL);

Calling LVGL’s add_event_cb method registers a custom function in the Arduino code as a UI ‎element’s callback. The first parameter contains the UI element’s name, the second represents ‎the local function to call, and the third tells the program which events it should respond to. In this ‎case, the fourth value is unused, but you could use it to pass additional values to the callback ‎function.‎

Next, the Arduino program needs three event handlers, one for each UI element. It’s best ‎practice to keep event handlers as short as possible and perform any long-running tasks in the ‎Arduino program’s update function. Therefore, each of the three functions merely changes the ‎state of a single boolean variable to let the update function know it needs to perform some ‎additional task in its next iteration:‎

Copy Code
static void onRefreshButtonPressed(lv_event_t *event)
{
updateButtonClicked = true;
}

static void onCityChanged(lv_event_t *event)
{
cityNameChanged = true;
}

static void onUnitsChanged(lv_event_t *event)
{
unitsChanged = true;
}

Don’t forget to mark all three variables as volatile booleans to let the CPP compiler know that ‎their values may change unexpectedly due to sporadic external events:‎

Copy Code
volatile boolean updateButtonClicked = false;
volatile boolean cityNameChanged = false;
volatile boolean unitsChanged = true;

Finally, the loop function can change its behavior based on the state of the three boolean flags ‎and, for example, request a new prediction from the API when the updateButtonClicked variable ‎is true:‎

Copy Code
void loop()
{
if (updateButtonClicked)
{
updateServer();
getPrediction();
updateUILabels();
updateButtonClicked = false;
}

if (cityNameChanged)
{
lv_dropdown_get_selected_str(ui_CitySettingDropdown, selectedCity, sizeof(selectedCity));
cityNameChanged = false;
}

if (unitsChanged)
{
useMetric = !lv_dropdown_get_selected(ui_TemperatureSettingDropdown);
updateUILabels();
unitsChanged = false;
}

lv_timer_handler();
delay(5);
}

The first if-block calls a function when the user touches the refresh button. The second and third ‎if-blocks need to read the value of the affected dropdown button and store it in a local variable. ‎For the city name, the Arduino needs to read the entire string and keep it in a local buffer to send ‎it to the API when refreshing the weather data. In contrast, reading the dropdown’s index suffices ‎for the unit setting. In this case, I could’ve also used a simpler checkbox or toggle button in the UI.‎

Finally, don’t forget to change the LVGL user configuration file and my_touchpad_read function ‎according to the requirements of your touch screen. Otherwise, the Arduino won’t display the UI ‎and will fail to register touch inputs.‎

Updating the UI from Arduino Code

Apart from reading UI element values, the Arduino program must also update some labels to ‎communicate the weather data and prediction results with the user, which is performed by the ‎updateUILabels helper function:‎

Copy Code
void updateUILabels()
{
lv_label_set_text_fmt(ui_WindValueLabel, "%d", wind);
lv_label_set_text_fmt(ui_TemperatureLabel, "%d", temperature);
lv_label_set_text_fmt(ui_TempRangeValueLabel, min_temp, max_temp);
lv_label_set_text(ui_PredictionResultValueLabel, rain ? "Rain" : "No Rain");
lv_arc_set_range(ui_TemperatureArc, min_temp, max_temp);
lv_arc_set_value(ui_TemperatureArc, temperature);
}

Each of the LVGL set functions takes at least two parameters. The first one is always the UI ‎element to update, and the second one can be one or multiple values to display, depending on ‎the component. Text elements support the use of formatted strings, which makes it easy to ‎combine various values, such as the minimum and maximum temperatures:‎

close-up

After adding the relevant code sections, the Arduino can update the label values, shown in the ‎graphical user interface.‎

Download the Arduino Project‎

You can download the entire Arduino project with the compiled UI files here.‎

Summary

This article explored how to make a machine-learning model accessible and get prediction ‎results from a client that would likely be too weak to perform model training and prediction, like ‎the Arduino. The previous part of this series stored the trained model using Pickle, a Python ‎library commonly used for this task. In this part, a Python-based server loads the ML model and ‎then provides an API to let clients interact with the model.‎

The API exposes two primary endpoints: one for instructing the server to fetch the current ‎weather forecast from an online API and a second one for accessing the ML model from outside ‎the program. The prediction endpoint returns the current weather and the prediction result as a ‎JSON object.‎

A second program, running exclusively on the Arduino, renders the LVGL-based graphical UI, ‎exported from Squareline studio. The Arduino program establishes a wireless network ‎connection, contacts the Python server that hosts the ML model, and then updates the UI when ‎required.‎

TechForum

Have questions or comments? Continue the conversation on TechForum, DigiKey's online community and technical resource.

Visit TechForum