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.
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:
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:
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:
pip install 'flask[async]'
And then add a simple endpoint for instructing the server to update its cache:
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:
t8 = pickle.load(open('model.pkl', 'rb'))
Clients can then call another endpoint of the server to request a prediction from the model:
@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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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.
Have questions or comments? Continue the conversation on TechForum, DigiKey's online community and technical resource.
Visit TechForum