Questo è il terzo articolo della serie su TimescaleDB, Flask e Dash.
Il primo articolo si è concentrato su come configurare il database TimescaleDB all’interno di un’istanza Docker, insieme a PgAdmin per gestire il database.
La seconda parte si è concentrata sul linguaggio Python, creando un sito Web con Flask e quindi integrandolo il framework Web Dash all’interno di Flask.
Questa terza parte si concentra sull’utilizzo di Dash per creare un’applicazione Web reattiva a pagina singola per visualizzare i dati all’interno del database TimescaleDB tramite grafici Plotly accattivanti e interattivi .
Tutto il codice per questo tutorial può essere trovato qui su GitHub.
Parte 3 - Grafici interattivi con Dash per creare un'applicazione di data science
Nella seconda parte di questa serie abbiamo creato un’istanza Dash con il file dash_setup.py
, che prevede il seguente codice:
# /app/dash_setup.py
import dash
from flask.helpers import get_root_path
def register_dashapps(app):
"""
Register Dash apps with the Flask app
"""
# external Bootstrap CSS stylesheets
external_stylesheets = [
'https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css'
]
# external Bootstrap JavaScript files
external_scripts = [
"https://code.jquery.com/jquery-3.5.1.slim.min.js",
"https://cdn.jsdelivr.net/npm/[email protected]/dist/umd/popper.min.js",
"https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.min.js",
]
# To ensure proper rendering and touch zooming for all devices, add the responsive viewport meta tag
meta_viewport = [{
"name": "viewport",
"content": "width=device-width, initial-scale=1, shrink-to-fit=no"
}]
dashapp = dash.Dash(
__name__,
# This is where the Flask app gets appointed as the server for the Dash app
server = app,
url_base_pathname = '/dash/',
# Separate assets folder in "static_dash" (optional)
assets_folder = get_root_path(__name__) + '/static_dash/',
meta_tags = meta_viewport,
external_scripts = external_scripts,
external_stylesheets = external_stylesheets
)
dashapp.title = 'Dash Charts in Single-Page Application'
# Some of these imports should be inside this function so that other Flask
# stuff gets loaded first, since some of the below imports reference the other
# Flask stuff, creating circular references
from app.dashapp.layout import get_layout
from app.dashapp.callbacks import register_callbacks
with app.app_context():
# Assign the get_layout function without calling it yet
dashapp.layout = get_layout
# Register callbacks
# Layout must be assigned above, before callbacks
register_callbacks(dashapp)
return None
In questo codice si utilizzano le funzioni get_layout
e register_callback
che sono importati da specifici moduli.
Iniziamo quindi a descrive i due moduli dashapp.layout
e dashapp.callbacks
# /app/dashapp/layout.py
import os
from flask import url_for
import dash_html_components as html
import dash_core_components as dcc
import dash_bootstrap_components as dbc
from psycopg2.extras import RealDictCursor
# Local imports
from app.database import get_conn
def get_navbar():
"""Get a Bootstrap 4 navigation bar for our single-page application's HTML layout"""
return dbc.NavbarSimple(
children=[
dbc.NavItem(dbc.NavLink("Blog", href="https://mccarthysean.dev")),
dbc.NavItem(dbc.NavLink("IJACK", href="https://myijack.com")),
dbc.DropdownMenu(
children=[
dbc.DropdownMenuItem("References", header=True),
dbc.DropdownMenuItem("Dash", href="https://dash.plotly.com/"),
dbc.DropdownMenuItem("Dash Bootstrap Components", href="https://dash-bootstrap-components.opensource.faculty.ai/"),
dbc.DropdownMenuItem("Testdriven", href="https://testdriven.io/"),
],
nav=True,
in_navbar=True,
label="Links",
),
],
brand="Home",
brand_href="/",
color="dark",
dark=True,
)
def get_sensor_types():
"""Get a list of different types of sensors"""
sql = """
--Get the labels and underlying values for the dropdown menu "children"
SELECT
distinct
type as label,
type as value
FROM sensors;
"""
conn = get_conn()
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute(sql)
# types is a list of dictionaries that looks like this, for example:
# [{'label': 'a', 'value': 'a'}]
types = cursor.fetchall()
return types
def get_body():
"""Get the body of the layout for our Dash SPA"""
types = get_sensor_types()
# The layout starts with a Bootstrap row, containing a Bootstrap column
return dbc.Row(
[
# 1st column and dropdown (NOT empty at first)
dbc.Col(
[
html.Label('Types of Sensors', style={'margin-top': '1.5em'}),
dcc.Dropdown(
options=types,
value=types[0]['value'],
id="types_dropdown"
)
], xs=12, sm=6, md=4
),
# 2nd column and dropdown (empty at first)
dbc.Col(
[
html.Label('Locations of Sensors', style={'margin-top': '1.5em'}),
dcc.Dropdown(
# options=None,
# value=None,
id="locations_dropdown"
)
], xs=12, sm=6, md=4
),
# 3rd column and dropdown (empty at first)
dbc.Col(
[
html.Label('Sensors', style={'margin-top': '1.5em'}),
dcc.Dropdown(
# options=None,
# value=None,
id="sensors_dropdown"
)
], xs=12, sm=6, md=4
),
]
)
def get_layout():
"""Function to get Dash's "HTML" layout"""
# A Bootstrap 4 container holds the rest of the layout
return dbc.Container(
[
get_navbar(),
get_body(),
],
)
Lavoriamo dal basso verso l’alto, iniziando con get_layout()
. Abbiamo aggiunto get_body()
sotto la funzione navbar.
Ecco come appare ora il semplice sito Flask nel browser:
Fare clic sul collegamento per visualizzare il sito Dash su /dash/
:
NOTA: In questa fase il secondo e il terzo menu a discesa saranno vuoti. È stato popolato solo il primo menu a discesa. Per il secondo e il terzo menu a discesa, utilizzeremo i callback di Dash invece di popolarli nel layout iniziale. Rimanete sintonizzati…
Visualizza il sito in development mode
Ho saltato un passaggio importante: come avviare un sito Flask / Dash in modalità “sviluppo” in modo da poterlo visualizzare nel browser.
Aggiungiamo un file chiamato .flaskenv
accanto al file .env
nella cartella principale del progetto, con le seguenti tre righe:
FLASK_APP=wsgi.py
FLASK_RUN_HOST=0.0.0.0
FLASK_RUN_PORT=5002
Flask cerca il file .flaskenv
quando si avvia il sito. Le variabili d’ambiente specificano il file di partenza e le coordinate (indirizzo web o IP) dove sarà pubblicato il sito (ad esempio si inizializza una FLASK_APP
che è stata importata e creata da wsgi.py
ed pubblicata sull’host 0.0.0.0
e porta 5002
).
Quindi, una volta creato il file .flaskenv
, si digita flask run
e sul terminale e il sito sarà pronto. Si può visualizzare il sito collegandosi a http:// localhost:5002 da qualsiasi browser.
Tornando al codice
Tornando al file layout.py
, la funzione get_body()
restituisce un Bootstrap row
e tre Boostrap, columns
uno per ciascuno dei menu a discesa di Dash.
Per ora concentriamoci sulla prima colonna, vediamo che è presente un html.Label
“Dash HTML Component “, seguito da un dcc.Dropdown
“Dash Core Component“. Stiamo creato creando un Bootstrap HTML/CSS/JS solamente usando Python! Questa è la bellezza di Dash, ed è molto conveniente per i data scientist che cercano di produrre i propri modelli e dati.
La variabile types
proviene dalla funzione get_sensor_types()
, che interroga il nostro database TimescaleDB e restituisce i tipi di sensori specifici/univoci in un “elenco di dizionari”. Questo è reso possibile tramite cursor_factory=RealDictCursor
(cioè restituisce le righe del database come comodi dizionari Python).
Molti Pythonistas amano interrogare i loro database usando SQLAlchemy , e anch’io lo uso molto spesso, anche se a volte ho voglia di solo sporcarmi le mani e scrivere un po’ di SQL, come ai vecchi tempi. La libreria psycopg2 ci consente di farlo molto facilmente. Entrambe sono ottime librerie, ben mantenute e testate sul campo.
Le altre due colonne e query nel nostro layout sono sostanzialmente le stesse della prima, quindi non le descriverò individualmente.
Callback in Dash
È ora di descrivere la logica dei callback, così possiamo far effettivamente funzionare il secondo e il terzo menu a discesa. A questo punto, nel codice del file layout.py
il secondo e il terzo elenco a discesa dovrebbero essere vuoti.
Finalmente, quello che stavamo tutti aspettando: un po’ di divertente interattività in Dash! Di seguito il codice del file callbacks.py
. Qui è dove popoleremo il 2 ° e il 3 ° menu a discesa, in base al valore del primo menu a discesa:
# /app/dashapp/callbacks.py
import dash
import dash_core_components as dcc
import dash_html_components as html
from dash.dependencies import Input, Output, State
from psycopg2.extras import RealDictCursor
# Local imports
from app.database import get_conn
def get_sensor_locations(type_):
"""Get a list of different locations of sensors"""
sql = f"""
--Get the labels and underlying values for the dropdown menu "children"
SELECT
distinct
location as label,
location as value
FROM sensors
WHERE type = '{type_}';
"""
conn = get_conn()
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute(sql)
# locations is a list of dictionaries that looks like this, for example:
# [{'label': 'floor', 'value': 'floor'}]
locations = cursor.fetchall()
return locations
def get_sensors(type_, location):
"""
Get a list of sensor dictionaries from our TimescaleDB database,
along with lists of distinct sensor types and locations
"""
sql = f"""
--Get the labels and underlying values for the dropdown menu "children"
SELECT
location || ' - ' || type as label,
id as value
FROM sensors
WHERE
type = '{type_}'
and location = '{location}';
"""
conn = get_conn()
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute(sql)
# sensors is a list of dictionaries that looks like this, for example:
# [{'label': 'floor - a', 'value': 1}]
sensors = cursor.fetchall()
return sensors
def register_callbacks(dash_app):
"""Register the callback functions for the Dash app, within the Flask app"""
@dash_app.callback(
[
Output("locations_dropdown", "options"),
Output("locations_dropdown", "value")
],
[
Input("types_dropdown", "value")
]
)
def get_locations_from_types(types_dropdown_value):
"""Get the location options, based on the type of sensor chosen"""
# First get the location options (i.e. a list of dictionaries)
location_options = get_sensor_locations(types_dropdown_value)
# Default to the first item in the list,
# and get the "value" from the dictionary
location_value = location_options[0]["value"]
return location_options, location_value
@dash_app.callback(
[
Output("sensors_dropdown", "options"),
Output("sensors_dropdown", "value")
],
[
Input("types_dropdown", "value"),
Input("locations_dropdown", "value")
]
)
def get_sensors_from_locations_and_types(types_dropdown_value, locations_dropdown_value):
"""Get the sensors available, based on both the location and type of sensor chosen"""
# First get the sensor options (i.e. a list of dictionaries)
sensor_options = get_sensors(types_dropdown_value, locations_dropdown_value)
# Default to the first item in the list,
# and get the "value" from the dictionary
sensor_value = sensor_options[0]["value"]
return sensor_options, sensor_value
return None
Ricordiamoci che la funzione register_callbacks(dash_app)
è usata all’interno di dash_setup.py
.
Concentriamoci sulla prima funzione di callback. Tutti i callback di Dash hanno funzioni Input()
che li attivano e funzioni Output()
, che sono gli elementi HTML che sono modificati dai callback.
Il primo parametro nelle funzioni Input()
o Output()
è sempre l’id=
dell’elemento, all’interno del file layout.py
. Il secondo parametro è sempre la proprietà che stiamo cercando di modificare. Quindi, nella funzione di callback stiamo utilizzando il “valore” del menu a discesa “types_dropdown” come input per la funzione, e stiamo modificando sia le “opzioni” che il “valore” selezionato del menu a discesa “locations_dropdown”.
La seconda callback è solo leggermente più complicata della prima. Utilizza i valori di entrambi i primi due menu a discesa (ovvero il menu a discesa dei tipi e il menu a discesa delle posizioni) per filtrare le opzioni del sensore e il valore selezionato.
Inoltre la funzione get_sensors(type_, location)
prevede una delle nostre query al database, che acquisisce i sensori unici corrispondenti ai filtri sul tipo e sulla posizione dei sensori. L’altra query del database è quasi identica, ad eccezione che prevede un solo filtro in base al tipo di sensore.
Implementiamo ora un bel grafico delle serie temporali, in modo da visualizzare i dati nel tempo del sensore selezionato tramite i filtri.
Aggiungiamo la seguente funzione get_chart_row()
al tuo file layout.py
, nella parte inferiore. Questo ci fornisce una riga / colonna Bootstrap in cui posizionare un grafico Dash tramite una callback:
...
def get_chart_row(): # NEW
"""Create a row and column for our Plotly/Dash time series chart"""
return dbc.Row(
dbc.Col(
id="time_series_chart_col"
)
)
def get_layout():
"""Function to get Dash's "HTML" layout"""
# A Bootstrap 4 container holds the rest of the layout
return dbc.Container(
[
get_navbar(),
get_body(),
get_chart_row(), # NEW
],
)
Vediamo ora come creare il grafico tramite le callback.
Innanzitutto assicuriamoci di aggiungere i seguenti import nel file callbacks.py
e installiamole nel nostro ambiente virtuale con il comando pip install pandas plotly
:
import pandas as pd
import plotly.graph_objs as go
A questo punto dobbiamo aggiungere la query per acquisire i dati delle serie temporali anche nel file callbacks.py
. Da notare che questa volta inseriamo i dati estratti all’interno di un DataFrame Pandas. Pandas è una libreria essenziale per i data scientist che lavorano con Python.
In poche parole, un Pandas DataFrame è una tabella composta da un array NumPy per ogni colonna. Descrivere Pandas e Numpy non è lo scopo di questo articolo, ma è importante ricordare che queste librerie sono estremamente ottimizzati per il lavoro in ambito della data science.
def get_sensor_time_series_data(sensor_id):
"""Get the time series data in a Pandas DataFrame, for the sensor chosen in the dropdown"""
sql = f"""
SELECT
--Get the 3-hour average instead of every single data point
time_bucket('03:00:00'::interval, time) as time,
sensor_id,
avg(temperature) as temperature,
avg(cpu) as cpu
FROM sensor_data
WHERE sensor_id = {sensor_id}
GROUP BY
time_bucket('03:00:00'::interval, time),
sensor_id
ORDER BY
time_bucket('03:00:00'::interval, time),
sensor_id;
"""
conn = get_conn()
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute(sql)
rows = cursor.fetchall()
columns = [str.lower(x[0]) for x in cursor.description]
df = pd.DataFrame(rows, columns=columns)
return df
Infine, dobbiamo aggiungere il grafico Dash / Plotly.
A tale scopo dobbiamo aggiungere una callback alla funzione register_callbacks(dash_app)
. Da notare che la callback aggiorna la proprietà “children” della colonna Bootstrap con ID “time_series_chart_col”.
Il callback utilizza un Input()
e due State()
come parametri della funzione. La differenza tra Input()
e State()
consiste che la funzione viene chiamata solo se ci sono modifiche a Input()
. Se la State()
cambia, la funzione non viene chiamata, ma abbiamo ancora ha disposizione i loro valori all’interno della funzione.
Successivamente, prendiamo i dati della serie temporale (in un DataFrame, come accennato in precedenza) e consideriamo la colonna time
per l’asse x. Aggiungiamo due grafici a linee (grafici a dispersione collegati da linee), quindi preleviamo due colonne dal dataframe, “temperatura” e “cpu”. Aggiungi anche una variabile per il titolo dei grafici.
Quindi creiamo due oggetti grafici Plotly (ad esempio go.Scatter
) per i due grafici e li passiamo in input alla funzione get_graph()
(che descriviamo tra poco).
Infine, la funzione restituisce un semplice HTML div
con due righe Bootstrap (una riga Bootstrap deve contenere sempre almeno una colonna).
@dash_app.callback(
Output("time_series_chart_col", "children"),
[Input("sensors_dropdown", "value")],
[
State("types_dropdown", "value"),
State("locations_dropdown", "value")
]
)
def get_time_series_chart(
sensors_dropdown_value,
types_dropdown_value,
locations_dropdown_value
):
"""Get the sensors available, based on both the location and type of sensor chosen"""
df = get_sensor_time_series_data(sensors_dropdown_value)
x = df["time"]
y1 = df["temperature"]
y2 = df["cpu"]
title = f"Location: {locations_dropdown_value} - Type: {types_dropdown_value}"
trace1 = go.Scatter(
x=x,
y=y1,
name="Temp"
)
trace2 = go.Scatter(
x=x,
y=y2,
name="CPU"
)
# Create two graphs using the traces above
graph1 = get_graph(trace1, f"Temperature for {title}")
graph2 = get_graph(trace2, f"CPU for {title}")
return html.Div(
[
dbc.Row(dbc.Col(graph1)),
dbc.Row(dbc.Col(graph2)),
]
)
A questo punto non ci rimane che aggiungere la funzione la get_graph()
. I grafici Plotly / Dash hanno molte opzioni, ma non lasciarti scoraggiare; sono facile da capire.
Il primo parametro disabilita il logo Plotly (totalmente opzionale). Successivamente nell’oggetto grafico c’è il parametro figure
, che contiene due sottoparametri principali, data
e layout
. Ecco il riferimento completo alle API , che è una risorsa essenziale se stai lavorando con i grafici Plotly.
In layout
, aggiungiamo alcune opzioni per il xaxis
in modo da inserire alcuni rapidi filtri dato che stiamo costruendo un grafico di serie temporali. Di solito scelgo i pulsanti rangeselector
sopra il grafico o il rangeslider
sotto il grafico, ma in questo esempio li usiamo entrambi per mostrare le potenzialità di ploty. Preferisco i pulsanti rangeselector
sopra il grafico. Se ti piace il trading di azioni, saprai che questi filtri temporali sono abbastanza comuni e intuitivi.
def get_graph(trace, title):
"""Get a Plotly Graph object for Dash"""
return dcc.Graph(
# Disable the ModeBar with the Plotly logo and other buttons
config=dict(
displayModeBar=False
),
figure=go.Figure(
data=[trace],
layout=go.Layout(
title=title,
plot_bgcolor="white",
xaxis=dict(
autorange=True,
# Time-filtering buttons above chart
rangeselector=dict(
buttons=list([
dict(count=1,
label="1d",
step="day",
stepmode="backward"),
dict(count=7,
label="7d",
step="day",
stepmode="backward"),
dict(count=1,
label="1m",
step="month",
stepmode="backward"),
dict(step="all")
])
),
type = "date",
# Alternative time filter slider
rangeslider = dict(
visible = True
)
)
)
)
)
Abbiamo concluso l’implementazione delle callback e delle funzioni grafiche.
Avviamo la nostra applicazione, i grafici avranno il seguente aspetto:
Questo è tutto! Abbiamo approfondito molti aspetti in questo tutorial diviso in tre parti.
Nella prima parte , abbiamo creato un database TimescaleDB e lo abbiamo popolato con i dati simulati delle serie temporali di sensori IoT. Abbiamo anche installato un’app Web PGAdmin per l’amministrazione del nostro database ed entrambe le applicazioni sono state distribuite utilizzando Docker-Compose, uno strumento fantastico per riprodurre facilmente ambienti e implementazioni.
Nella seconda parte, abbiamo combinato un’app Web Flask con un’app Web Dash in modo da poter avere il meglio da entrambe le librerie: Flask può praticamente implementare qualsiasi applicazione Web e Dash è ottimo per la produzione di app a pagina singola di data science senza bisogno di utilizzare JavaScript o React.
In questa terza parte, abbiamo descritto le funzioni di Dash, in modo particolare i callback interattivi del menu a discesa e i grafici Plotly per dare un assaggio di ciò che è possibile fare con Dash.
Spero che questa serie ti sia piaciuta.
A presto!