Grafici delle Serie Temporali con Dash, Flask, TimescaleDB e Docker – Parte 3

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:

timescale-dash-flask-click-here-to-see-the-main-dash-page

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…

timescale-dash-flask-navbar-and-body

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:

timescale-dash-flask-finished-product

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!

Recommended Posts