Plotting Records

Part 3 of 3

This notebook is the last in a series of three notebooks demonstrating how daily and monthly record highs, lows, and averages are calculated from NOAA CO-OPS weather and tide station data. The notebook follows sequentially from NOAA-CO-OPS-records in which we calculated record highs, lows, and averages from observational data for a particular NOAA CO-OPS weather and tide station. Daily and monthly records were written to netCDF files. Here we visualize these records as plots and as a colored dataframe.

In the previous notebook we calculated several records of interest:

For those records marked with an asterisk (*), we also noted the year in which that particular record was set. Now let’s return to these statistics to visualize them.

Packages and configurations

As always, we first import the packages we need. We will use Bokeh to make interactive plots and great_tables to display the data behind the plots in a visually appealing manner.

To better visualize the seasonality of daily and monthly averages, average highs, and average lows, we will fit a curve to the calculated averages and plot these curves instead of the actual values. This will be done with curve_fit from SciPy.

from statsmodels.tsa.seasonal import seasonal_decompose
from datetime import datetime as dt
from scipy.optimize import curve_fit
from scipy.interpolate import interp1d
from great_tables import GT, loc, style
from bokeh.plotting import figure, show
from bokeh.io import output_notebook
import bokeh.models as bm
import xarray as xr
import pandas as pd
import numpy as np
import json
import sys
import os
output_notebook(hide_banner=True)

sys.path.append('/workspaces/climatology/')
from clipy import climo

By default, Python only displays warnings the first time they are thrown. Ideally, we want a code that does not throw any warnings, but it sometimes takes some trial and error to resolve the issue being warned about. So, for diagnostic purposes, we’ll set the kernel to always display warnings.

import warnings
warnings.filterwarnings('always')

Functions

Let’s define functions to plot the daily and monthly data. These two plots will be similar in appearance but have some differences (for example, the x axis), so two separate functions will be needed.

First, we’ll need some helper functions. Some of these were used previously, while others are new:

def camel(text):
    """Convert 'text' to camel case"""
    s = text.replace(',','').replace("-", " ").replace("_", " ")
    s = s.split()
    if len(text) == 0:
        return text
    return s[0].lower() + ''.join(i.capitalize() for i in s[1:])

def round_down(num, divisor):
    """Round down to the nearest divisor. For example, round_down(45.5, 10)
    will return 40. To round up to the nearest divisor, see `round_up`.

    Parameters
    ----------
    num : float
        Number to be rounded down
    divisor : int
        Divisor of num
    
    Returns
    -------
    Float resulting from `num - (num % divisor)`
    """
    return num - (num%divisor)

def round_up(num, divisor):
    """Round up to the nearest divisor. For example, round_up(45.5, 10) will
    return 50. To round down to the nearest divisor, see `round_down`.

    Parameters
    ----------
    num : float
        Number to be rounded up
    divisor : int
        Divisor of num
    
    Returns
    -------
    Float resulting from `num + (num % divisor)`
    """
    return num + (divisor - (num%divisor))

def cos_fit(data):
    """Fit cosine curve to data
    
    Parameters
    ----------
    data : list or 1d array of data to be fit

    Returns
    -------
    Array of fitted values
    """
    X = np.arange(0, len(data))/len(data)

    # Initial parameter values
    guess_freq = 1
    guess_amplitude = 3*np.std(data)/(2**0.5)
    guess_phase = 0
    guess_offset = np.mean(data)
    p0 = [guess_freq, guess_amplitude,
          guess_phase, guess_offset]

    # Function to fit
    def my_cos(x, freq, amplitude, phase, offset):
        return np.cos(x * freq + phase) * amplitude + offset

    # Fit curve to data
    fit = curve_fit(my_cos, X, data, p0=p0)
    
    return my_cos(np.array(X), *fit[0])
    

Defining all of the colors in a dictionary will make it easier to customize everything later and will clean up the plotting codes. Below is a dictionary of three color schemes: “mg” are my chosen colors, “bm” colors are the same color scheme as Brian McNoldy’s figures on his website, and “cb” are colorblind-friendly colors.

# Color dictionary
# https://www.tutorialrepublic.com/css-reference/css-color-names.php
colors = dict(
    mg=dict({
        'Date': 'white',
        'Month': 'white',
        'Daily Average': '#F5F5F5',
        'Monthly Average': '#F5F5F5',
        'Record High Daily Average': '#ff8080',
        'Record High Daily Average Year': '#ff8080',
        'Record High Monthly Average': '#ff8080',
        'Record High Monthly Average Year': '#ff8080',
        'Record Low Daily Average': '#c1d5f8',
        'Record Low Daily Average Year': '#c1d5f8',
        'Record Low Monthly Average': '#c1d5f8',
        'Record Low Monthly Average Year': '#c1d5f8',
        'Average High': '#dc8d8d',
        'Lowest High': '#e6aeae',
        'Lowest High Year': '#e6aeae',        
        'Record High': '#d26c6c',
        'Record High Year': '#d26c6c',
        'Average Low': '#a2bff4',
        'Highest Low': '#d1dffa',
        'Highest Low Year': '#d1dffa',
        'Record Low': '#74a0ef',
        'Record Low Year': '#74a0ef',
        'Years': 'white',
        'Plot Light Color': '#D3D3D3'}),
    bm=dict({
        'Date': 'white',
        'Month': 'white',
        'Daily Average': 'gainsboro',
        'Monthly Average': 'gainsboro',
        'Record High Daily Average': 'mistyrose',
        'Record High Daily Average Year': 'mistyrose',
        'Record High Monthly Average': 'mistyrose',
        'Record High Monthly Average Year': 'mistyrose',
        'Record Low Daily Average': 'lavender',
        'Record Low Daily Average Year': 'lavender',
        'Record Low Monthly Average': 'lavender',
        'Record Low Monthly Average Year': 'lavender',
        'Average High': 'orangered',
        'Lowest High': 'darkorange',
        'Lowest High Year': 'darkorange',        
        'Record High': 'orange',
        'Record High Year': 'orange',
        'Average Low': 'mediumpurple',
        'Highest Low': 'navyblue',
        'Highest Low Year': 'navyblue',
        'Record Low': 'lightblue',
        'Record Low Year': 'lightblue',
        'Years': 'white',
        'Plot Light Color': 'white'}),
    cb=dict({
        'Date': '#f9f9f9',
        'Month': '#f9f9f9',
        'Daily Average': '#F5F5F5',
        'Monthly Average': '#F5F5F5',
        'Record High Daily Average': '#d75f4c',
        'Record High Daily Average Year': '#d75f4c',
        'Record High Monthly Average': '#d75f4c',
        'Record High Monthly Average Year': '#d75f4c',
        'Record Low Daily Average': '#3a93c3',
        'Record Low Daily Average Year': '#3a93c3',
        'Record Low Monthly Average': '#3a93c3',
        'Record Low Monthly Average Year': '#3a93c3',
        'Average High': '#f6a482',
        'Lowest High': '#fedbc7',
        'Lowest High Year': '#fedbc7',        
        'Record High': '#b31529',
        'Record High Year': '#b31529',
        'Average Low': '#8ec4de',
        'Highest Low': '#d1e5f0',
        'Highest Low Year': '#d1e5f0',
        'Record Low': '#1065ab',
        'Record Low Year': '#1065ab',
        'Years': '#f9f9f9',
        'Plot Light Color': '#f9f9f9'})
    )

The plots will be made using Bokeh for interactivity. Consequently, there are many steps involved in building and formatting the plot with the desired functionality. We will plot daily/monthly averages, average highs, and average lows as curves; record highs and record lows as points; and will highlight records set this year for emphasis. The plot will also contain a legend and a hoverbox that displays the values of each series for a given date when one hovers the mouse pointer over the plot. The functions below will be used to generate daily and monthly climatology plots, and comments within the functions explain what each step does.

Note that daily_climo also supports showing flood thresholds when used to plot water level data. These thresholds need to be retrieved for each site and passed as a dictionary, for example:

flood_thresholds = {
    'major': 2.5,
    'moderate': 1.7,
    'minor': 1.3
    }
def config_plot(p, scheme='cb'):
    """Configure Bokeh plot for website

    Parameters
    ----------
    p : Bokeh Figure class
    scheme : {'mg', 'bm', 'cb}
        Specifies which color scheme to use: 'mg' for M. Grossi's, 'bm' for
        B. McNoldy's, or 'cb' to use a colorblind scheme. Defaults to 'mg'.
    """

    # Plot properties
    p.background_fill_color = '#404040'
    p.border_fill_color = '#404040'
    p.width = 1000
    p.outline_line_color = None
    p.sizing_mode = 'scale_height'

    # x-axis
    p.xgrid.grid_line_color = None
    p.xaxis.axis_line_color = 'grey'
    p.xaxis.major_tick_line_color = 'grey'
        
    # y-axis
    p.yaxis.axis_label_text_color = colors[scheme]['Plot Light Color']
    p.ygrid.grid_line_color = 'grey'
    p.yaxis.axis_line_color = None
    p.yaxis.major_tick_line_color = None
    p.yaxis.minor_tick_line_color = None
    p.outline_line_color = None

    # Fonts
    p.title.text_font = 'arial narrow'
    p.title.text_font_size = '16px'
    p.title.text_color = 'darkgray'
    p.xaxis.major_label_text_font = 'arial narrow'
    p.xaxis.major_label_text_color = colors[scheme]['Plot Light Color']
    p.xaxis.major_label_text_font_size = '14px'
    p.yaxis.major_label_text_font = 'arial narrow'
    p.yaxis.axis_label_text_font = 'arial narrow'
    p.yaxis.axis_label_text_font_style = 'normal'
    p.yaxis.major_label_text_color = colors[scheme]['Plot Light Color']    
    p.yaxis.major_label_text_font_size = '14px'
    p.yaxis.axis_label_text_font_size = '14px'
def daily_climo(data, var, flood_thresholds, scheme='cb'):
    """Create a daily climatology plot for environmental variable `var`
    from `data`.
    
    Parameters
    ----------
        data : xarray
            Data array containing climatological stats
        var : str
            One of the available environmental variables in `data`
        flood_threshold : dict
            Flood thresholds to add to water level plot
        scheme : {'mg', 'bm', 'cb}
            Specifies which color scheme to use: 'mg' for M. Grossi's, 'bm' for
            B. McNoldy's, or 'cb' to use a colorblind scheme. Defaults to 'mg'.
    """

    # Dates for x axis
    df = data.sel(variable=var).to_dataframe().drop('variable', axis=1)
    df['xdates'] = pd.date_range(start='2020-01-01', end='2020-12-31', freq='1D')
    df['Average High Curve'] = cos_fit(df['Average High'])
    df['Daily Average Curve'] = cos_fit(df['Daily Average'])
    df['Average Low Curve'] = cos_fit(df['Average Low'])
    
    # Records this year
    thisYear = pd.to_datetime('today').year
    df['High Records'] = df['Record High'].where(df['Record High Year'] == thisYear)
    df['Low Records'] = df['Record Low'].where(df['Record Low Year'] == thisYear)
    source = bm.ColumnDataSource(df)
    
    # Create a new plot
    ts_start = dt.strptime(data.attrs[f'{var} data range'][0], '%Y-%m-%d').strftime('%-m/%-d/%Y')
    ts_end = dt.strptime(data.attrs[f'{var} data range'][1], '%Y-%m-%d').strftime('%-m/%-d/%Y')
    p = figure(title=f'DATA RECORD: {ts_start} - {ts_end}',
               x_axis_type='datetime', height=600,
               y_range=(round_down(df['Record Low'].min(), 10),
                        round_up(df['Record High'].max(), 10)),
               tools='pan, wheel_zoom, box_zoom, undo, reset, fullscreen')

    # This year record highs
    hr = p.scatter(x='xdates', y='High Records', source=source,
                   name=f'{thisYear} High Record', size=4, color='white',
                   hover_fill_color='white', hover_alpha=0.5)
    hr.level = 'overlay'
    # This year record lows
    lr = p.scatter(x='xdates', y='Low Records', source=source,
                   name=f'{thisYear} Low Record', size=4, color='white',
                   hover_fill_color='white', hover_alpha=0.5)
    lr.level = 'overlay'
    # Record highs
    rh = p.scatter(x='xdates', y='Record High', source=source,
                   name='Record High', size=2,
                   color=colors[scheme]['Record High'])
    # Average high
    ah = p.line(x='xdates', y='Average High Curve', source=source,
                name='Average High', width=3,
                color=colors[scheme]['Average High'])
    # Daily average
    da = p.line(x='xdates', y='Daily Average Curve', source=source,
                name='Daily Average', width=2,
                color=colors[scheme]['Daily Average'])
    # Average lows
    al = p.line(x='xdates', y='Average Low Curve', source=source,
                name='Average Low', width=3,
                color=colors[scheme]['Average Low'])
    # Record lows
    rl = p.scatter(x='xdates', y='Record Low', source=source,
                   name='Record Low', size=2,
                   color=colors[scheme]['Record Low'])
    config_plot(p)

    # Flood thresholds (water level plot only)
    if var=='Water Level':
        for level, threshold in flood_thresholds.items():
            hline = bm.Span(location=threshold, dimension='width',
                         line_dash=[20,8], line_alpha=0.75,
                         line_color='cadetblue', line_width=2)
            p.renderers.extend([hline])
            mytext = bm.Label(x=pd.to_datetime('2019-12-15'), y=threshold+0.1,
                              text=f'{level} flood threshold'.upper(), text_color='cadetblue',
                              text_font_size='8px',
                              text_font='arial narrow')
            p.add_layout(mytext)
    
    # Tools
    crosshair = bm.CrosshairTool(dimensions='height',
                              line_color='grey', line_alpha=0.5)
    hover = bm.HoverTool(mode='vline', renderers=[da],
                      formatters={'@xdates': 'datetime'})
    units = data.attrs[f"{var} units"]
    if var == 'Water Level':
        hover.tooltips = """
            <b> @xdates{{%b %d}} </b> <br>
            Record High: @{{Record High}}{{0.00}} {u}<br>
            Average High: @{{Average High Curve}}{{0.00}} {u}<br>
            Daily Average: @{{Daily Average Curve}}{{0.00}} {u}<br>
            Average Low: @{{Average Low Curve}}{{0.00}} {u}<br>
            Record Low: @{{Record Low}}{{0.00}} {u}<br>
            {y} High Record: @{{High Records}}{{0.00}} {u}<br>
            {y} Low Record: @{{Low Records}}{{0.00}} {u}
            """.format(u=units, y=thisYear)
    else:
        hover.tooltips = """
            <b> @xdates{{%b %d}} </b> <br>
            Record High: @{{Record High}}{{0.0}} {u}<br>
            Average High: @{{Average High Curve}}{{0.0}} {u}<br>
            Daily Average: @{{Daily Average Curve}}{{0.0}} {u}<br>
            Average Low: @{{Average Low Curve}}{{0.0}} {u}<br>
            Record Low: @{{Record Low}}{{0.0}} {u}<br>
            {y} High Record: @{{High Records}}{{0.0}} {u}<br>
            {y} Low Record: @{{Low Records}}{{0.0}} {u}
            """.format(u=units, y=thisYear)
    p.add_tools(hover, crosshair)
    p.toolbar.autohide = True

    # x-axis
    p.xaxis[0].formatter = bm.DatetimeTickFormatter(months="%b %d")
    p.xaxis[0].ticker.desired_num_ticks = 12
    
    # y-axis
    if var == 'Water Level':
        p.yaxis.axis_label=f'{var} relative to {data.attrs["datum"].upper()} ({data.attrs[f"{var} units"]})'
    else:
        p.yaxis.axis_label=f'{var} ({data.attrs[f"{var} units"]})'
    ymin = round_down(df['Record Low'].min(), 10)
    ymax = round_up(df['Record High'].max(), 10)
    p.y_range = bm.Range1d(ymin, ymax, bounds=(ymin, ymax))
    
    # Legend
    legend = bm.Legend(items=[
        ('{} Record'.format(thisYear), [hr, lr]),
        ('Record High', [rh]),
        ('Average High', [ah]),
        ('Daily Average', [da]),
        ('Average Low', [al]),
        ('Record Low', [rl])],
                    background_fill_color='#404040', border_line_color=None,
                    label_text_color=colors[scheme]['Plot Light Color'],
                    location='center_right', click_policy='mute')
    p.add_layout(legend, 'right')
    show(p)

def monthly_climo(data, var, scheme='cb'):
    """Create a monthly climatology plot for environmental variable `var`
    from `data`.
    
    Parameters
    ----------
        data : xarray
            Data array containing climatological stats
        var : str
            One of the available environmental variables in `data`
        flood_threshold : dict
            Flood thresholds to add to water level plot
        scheme : {'mg', 'bm', 'cb}
            Specifies which color scheme to use: 'mg' for M. Grossi's, 'bm' for
            B. McNoldy's, or 'cb' to use a colorblind scheme. Defaults to 'mg'.
    """

    # Dates for x axis
    df = data.sel(variable=var).to_dataframe().drop('variable', axis=1).reset_index()
    df['Average High Curve'] = cos_fit(df['Average High'])
    df['Monthly Average Curve'] = cos_fit(df['Monthly Average'])
    df['Average Low Curve'] = cos_fit(df['Average Low'])
    
    # Record this year
    thisYear = pd.to_datetime('today').year
    df['High Records'] = df['Record High'].where(df['Record High Year'] == thisYear)
    df['Low Records'] = df['Record Low'].where(df['Record Low Year'] == thisYear)
    source = bm.ColumnDataSource(df)
    
    # Create a new plot
    ts_start = dt.strptime(data.attrs[f'{var} data range'][0], '%Y-%m-%d').strftime('%-m/%-d/%Y')
    ts_end = dt.strptime(data.attrs[f'{var} data range'][1], '%Y-%m-%d').strftime('%-m/%-d/%Y')
    p = figure(title=f'DATA RECORD: {ts_start} - {ts_end}', height=600,
               x_range=['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
                        'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'],
               y_range=(round_down(df['Record Low'].min(), 1), round_up(df['Record High'].max(), 1)),
               tools='pan, wheel_zoom, box_zoom, undo, reset, fullscreen')

    # This year record highs
    hr = p.scatter(x='Month', y='High Records', source=source,
                   name=f'{thisYear} High Record', size=7, color='white',
                   hover_fill_color='white', hover_alpha=0.5)
    hr.level = 'overlay'
    # This year record lows
    lr = p.scatter(x='Month', y='Low Records', source=source,
                   name=f'{thisYear} Low Record', size=7, color='white',
                   hover_fill_color='white', hover_alpha=0.5)
    lr.level = 'overlay'
    # Record highs
    rh = p.scatter(x='Month', y='Record High', source=source,
                   name='Record High', size=7,
                   color=colors[scheme]['Record High'])
    # Average high
    ah = p.line(x='Month', y='Average High Curve', source=source,
                name='Average High', width=4,
                color=colors[scheme]['Average High'])
    # Monthly average
    ma = p.line(x='Month', y='Monthly Average Curve', source=source,
                name='Monthly Average', width=3,
                color=colors[scheme]['Monthly Average'])
    # Average lows
    al = p.line(x='Month', y='Average Low Curve', source=source,
                name='Average Low', width=4,
                color=colors[scheme]['Average Low'])
    # Record lows
    rl = p.scatter(x='Month', y='Record Low', source=source,
                   name='Record Low', size=7,
                   color=colors[scheme]['Record Low'])
    config_plot(p)
    
    # Tools
    crosshair = bm.CrosshairTool(dimensions='height',
                              line_color='grey', line_alpha=0.5)
    hover = bm.HoverTool(mode='vline', renderers=[ma],
                      formatters={'@xdates': 'datetime'})
    units = data.attrs[f"{var} units"]
    if var == 'Water Level':
        hover.tooltips = """
            <b> @Month </b> <br>
            Record High: @{{Record High}}{{0.00}} {u}<br>
            Average High: @{{Average High Curve}}{{0.00}} {u}<br>
            Monthly Average: @{{Monthly Average Curve}}{{0.00}} {u}<br>
            Average Low: @{{Average Low Curve}}{{0.00}} {u}<br>
            Record Low: @{{Record Low}}{{0.00}} {u}<br>
            {y} High Record: @{{High Records}}{{0.00}} {u}<br>
            {y} Low Record: @{{Low Records}}{{0.00}} {u}
            """.format(u=units, y=thisYear)
    else:
        hover.tooltips = """
            <b> @Month </b> <br>
            Record High: @{{Record High}}{{0.0}} {u}<br>
            Average High: @{{Average High Curve}}{{0.0}} {u}<br>
            Monthly Average: @{{Monthly Average Curve}}{{0.0}} {u}<br>
            Average Low: @{{Average Low Curve}}{{0.0}} {u}<br>
            Record Low: @{{Record Low}}{{0.0}} {u}<br>
            {y} High Record: @{{High Records}}{{0.0}} {u}<br>
            {y} Low Record: @{{Low Records}}{{0.0}} {u}
            """.format(u=units, y=thisYear)
    p.add_tools(hover, crosshair)
    p.toolbar.autohide = True
    
    # y-axis
    if var == 'Water Level':
        p.yaxis.axis_label=f'{var} relative to {data.attrs["datum"].upper()} ({data.attrs[f"{var} units"]})'
    else:
        p.yaxis.axis_label=f'{var} ({data.attrs[f"{var} units"]})'
    
    # Legend
    legend = bm.Legend(items=[
        ('{} Record'.format(thisYear), [hr, lr]),
        ('Record High', [rh]),
        ('Average High', [ah]),
        ('Monthly Average', [ma]),
        ('Average Low', [al]),
        ('Record Low', [rl])],
                    background_fill_color='#404040', border_line_color=None,
                    label_text_color=colors[scheme]['Plot Light Color'],
                    location='center_right', click_policy='mute')
    p.add_layout(legend, 'right')
    show(p)

def histograms(stats, var, y_range=None, scheme='cb'):
    """Plot histograms of record counts per year

    Paramaters
    ----------
    stats : xarray
        xarray containing daily or monthly records for a CO-OPS station,
        arranged with years as rows and record as columns
    var : str
        Name of variable to be plotted. Must be in `stats`.
    y_range : list or tuple of length 2
        List of tuple containing the lower and upper bounds of the y-axis to be displayed. These are create scroll limits for interactivity.
    scheme : {'mg', 'bm', 'cb}
        Specifies which color scheme to use: 'mg' for M. Grossi's, 'bm' for
        B. McNoldy's, or 'cb' to use a colorblind scheme. Defaults to 'mg'.
    """
    data = record_counts(data=stats, var=var)
    # Create histogram for each record
    for col in data.columns:
        if col != 'Year':

            # Create plot
            p = figure(x_range=data['Year'], tooltips="@Year: @$name", height=400,
                       tools='pan, wheel_zoom, box_zoom, undo, reset, fullscreen',
                       title=f'Distribution of {col.lower()}s'.upper())
            bars = p.vbar(x='Year', top=col, source=data,
                          name=col, color=colors[scheme][col], alpha=0.85)
            config_plot(p, scheme=scheme)
            p.title.text_font_size = '20px'

            # x-axis
            p.xaxis.major_label_orientation = 45
            p.x_range.range_padding = 0.05

            # y-axis
            p.yaxis.axis_label='Number of Records Set'
            p.y_range.start = 0
            p.yaxis.axis_label='Number of Records'
            if y_range is not None:
                p.y_range = bm.Range1d(min(y_range), max(y_range),
                                       bounds=(min(y_range), max(y_range)))
            show(p)

def trend(data, var, scheme='cb', true_average=False, fname=None):
    """Plot time series trend

    Parameters
    ----------
    data : pyclimo Data object
        Data object containing observational data for a CO-OPS station
    var : str
        Name of the variable to regress. Must be in climatology dataset.
    scheme : {'mg', 'bm', 'cb}
        Specifies which color scheme to use: 'mg' for M. Grossi's, 'bm' for
        B. McNoldy's, or 'cb' to use a colorblind scheme. Defaults to 'mg'.
    true_average : Bool
        If True, all measurements from each 24-hour day will be used to calculate the
        average. Otherwise, only the maximum and minimum observations are used. Defaults to False (meteorological standard).
    fname : str or None
        File name with directory to be written out, if provided. If None, plot will be displayed instead. Defaults to None.
    """
 
    # Monthly averages
    dailyAvgs = data.mon_daily_avgs(true_average=true_average)[var]
    monthlyAvgs = dailyAvgs.groupby(pd.Grouper(freq='M')).mean(numeric_only=True)
    df = pd.DataFrame(monthlyAvgs)
    df = df.loc[df.first_valid_index():]
    
    # Linearly interpret missing data
    df['rownum'] = np.arange(df.shape[0])
    col = df.columns.values[0]
    df_nona = df.dropna(subset = [col])
    f = interp1d(df_nona['rownum'], df_nona[col])
    df['linear_fill'] = f(df['rownum'])

    # Normalize time series to deseasonalize
    tsMin = df['linear_fill'].min()
    tsMax = df['linear_fill'].max()
    tseries_norm = (df['linear_fill'] - tsMin) / (tsMax - tsMin)

    # Deseasonalize time series and un-normalize
    components = seasonal_decompose(tseries_norm, model='additive', period=4)
    deseasoned = tseries_norm - components.seasonal
    df['deseasoned'] = (deseasoned * (tsMax - tsMin)) + tsMin

    # Apply linear regression
    coef = np.polyfit(df['rownum'], df['deseasoned'], 1)
    slope = coef[0] # height/mon
    poly1d_fn = np.poly1d(coef)
    df['linear_reg'] = poly1d_fn(df['rownum'])
    mask = ~df[var].isna()
    masked = np.ma.masked_where(df[var].isna(), df['deseasoned'])
    
    # Create plot
    df.index.name = 'xdates'
    ts_start = df.index.min().strftime('%-m/%-d/%Y')
    ts_end = df.index.max().strftime('%-m/%-d/%Y')
    source = bm.ColumnDataSource(df.reset_index())
    p = figure(title=f'RELATIVE {var.upper()} TREND: {ts_start} - {ts_end}\n'+
                     f'{np.round(slope*12, 4)} {data.units[var]}/yr or {np.round(slope*12*100, 2)} {data.units[var]} in 100 years',
                background_fill_color='#404040', border_fill_color='#404040',
                width=1000, height=400, x_axis_type='datetime',
                tools='pan, wheel_zoom, box_zoom, undo, reset, fullscreen',
                outline_line_color=None, sizing_mode='scale_height')

    # Data with regression line
    sct = p.line(x='xdates', y=var, source=source, name='Monthly Average',
                color=colors['mg']['Plot Light Color'], alpha=0.5)
    sct.level = 'overlay'
    reg = p.line(x='xdates', y='linear_reg', source=source, name='Trend',
                color=colors['mg']['Record Low'], line_width=5)
    reg.level = 'overlay'
    config_plot(p)

    # Tools
    crosshair = bm.CrosshairTool(dimensions='height',
                                line_color='grey', line_alpha=0.5)
    hover = bm.HoverTool(mode='vline', renderers=[sct],
                        formatters={'@xdates': 'datetime'})
    hover.tooltips = f"""
    <b> @xdates{{%b %Y}} </b> <br>
    Monthly Average: @{{Water Level}}{{0.00}} {data.units[var]}
    """
    p.add_tools(hover, crosshair)
    p.toolbar.autohide = True

    # y-axis
    p.yaxis.axis_label = f'Monthly average {var.lower()} relative to {data.datum}'
    if max(df[var]) < 0:
        p.y_range = bm.Range1d(-max(abs(df[var]))-0.5, 0.45,
                                bounds=(-max(abs(df[var]))-0.5, 0.45))
    
    # Save or display
    if fname is not None:
        from bokeh.io import output_file
        from bokeh.plotting import save
        output_file(fname)
        save(p)
    else:
        show(p)

Loading data

Now we need to load in the records for the desired station, which will be used to determine the directory from which to load the data. As before, stationname is the custom human-readable “City, ST” string for the station.

stationname = 'Virginia Key, FL'

Derive the local directory name containing the data from the station name. This is the same way the directory was created when the data were downloaded.

dirname = camel(stationname)
outdir = '../'+dirname

print(f"Station folder: {dirname}")
print(f"Full directory: {outdir}")
Station folder: virginiaKeyFl
Full directory: ../virginiaKeyFl

Next, load the data and metadata.

# Records
days = xr.load_dataset(os.path.join(outdir, 'statistics-daily.nc'))
mons = xr.load_dataset(os.path.join(outdir, 'statistics-monthly.nc'))

And finally, we can make some plots. Let’s look at daily and monthly climatology for Air Temperature.

Records

var = 'Air Temperature'
daily_climo(data=days, var=var, flood_thresholds=flood_thresholds, scheme='cb')
monthly_climo(data=mons, var=var, scheme='cb')

Notice that for the average high, average low, and daily/monthly average, we fit a cosine curve to the data and plot this instead. This is but one of many possible ways to illustrate these data.

Record Counts

We can also consider the number of records held by each year in the time series. Let’s demonstrate daily air temperature record counts here. Start by creating a data frame and count the number of times each year appears.

# Dataframe
stats = days.sel(variable=var).to_dataframe()
stats = stats[stats.columns[stats.columns.str.endswith('Year')]]

# Value counts for each year
counts = stats.stack().groupby(level=[1]).value_counts().unstack().T.fillna(0)
counts
Highest Low Year Lowest High Year Record High Daily Average Year Record High Year Record Low Daily Average Year Record Low Year
1994 13.0 13.0 10.0 8.0 14.0 11.0
1995 2.0 25.0 3.0 9.0 21.0 19.0
1996 2.0 29.0 0.0 4.0 26.0 26.0
1997 1.0 8.0 1.0 4.0 16.0 20.0
1998 23.0 12.0 26.0 11.0 9.0 8.0
1999 3.0 21.0 3.0 7.0 24.0 20.0
2000 0.0 24.0 0.0 2.0 20.0 17.0
2001 5.0 33.0 4.0 5.0 23.0 13.0
2002 7.0 8.0 12.0 11.0 8.0 8.0
2003 22.0 11.0 15.0 16.0 9.0 6.0
2004 3.0 14.0 2.0 2.0 9.0 7.0
2005 3.0 8.0 3.0 3.0 7.0 12.0
2006 3.0 17.0 0.0 2.0 18.0 17.0
2007 10.0 21.0 5.0 6.0 14.0 10.0
2009 9.0 10.0 10.0 16.0 15.0 13.0
2010 7.0 32.0 6.0 7.0 40.0 37.0
2011 4.0 6.0 1.0 5.0 4.0 9.0
2012 1.0 21.0 3.0 4.0 18.0 14.0
2013 9.0 20.0 6.0 11.0 16.0 19.0
2014 10.0 5.0 13.0 8.0 10.0 17.0
2015 36.0 4.0 36.0 27.0 6.0 6.0
2016 13.0 1.0 9.0 10.0 5.0 7.0
2017 13.0 6.0 14.0 13.0 6.0 14.0
2018 12.0 4.0 8.0 18.0 8.0 8.0
2019 0.0 1.0 0.0 2.0 1.0 0.0
2020 39.0 2.0 45.0 29.0 3.0 4.0
2021 16.0 3.0 19.0 23.0 4.0 6.0
2022 42.0 1.0 45.0 31.0 4.0 6.0
2023 27.0 2.0 32.0 49.0 5.0 8.0
2024 20.0 3.0 29.0 20.0 2.0 3.0
2025 11.0 1.0 6.0 3.0 1.0 1.0

Resort the rows and columns after the unstack and restructure for plotting.

# Resort rows and columns
counts = counts.reindex(range(min(counts.index), max(counts.index)+1), fill_value=0)
counts = counts[stats.columns[stats.columns.str.endswith('Year')]]

# Restructure
counts.columns = [i.replace(' Year', '') for i in counts.columns]
counts.index.name = 'Year'
counts.index = counts.index.astype(str)
counts.reset_index(inplace=True)
counts
Year Record High Daily Average Record Low Daily Average Lowest High Record High Highest Low Record Low
0 1994 10.0 14.0 13.0 8.0 13.0 11.0
1 1995 3.0 21.0 25.0 9.0 2.0 19.0
2 1996 0.0 26.0 29.0 4.0 2.0 26.0
3 1997 1.0 16.0 8.0 4.0 1.0 20.0
4 1998 26.0 9.0 12.0 11.0 23.0 8.0
5 1999 3.0 24.0 21.0 7.0 3.0 20.0
6 2000 0.0 20.0 24.0 2.0 0.0 17.0
7 2001 4.0 23.0 33.0 5.0 5.0 13.0
8 2002 12.0 8.0 8.0 11.0 7.0 8.0
9 2003 15.0 9.0 11.0 16.0 22.0 6.0
10 2004 2.0 9.0 14.0 2.0 3.0 7.0
11 2005 3.0 7.0 8.0 3.0 3.0 12.0
12 2006 0.0 18.0 17.0 2.0 3.0 17.0
13 2007 5.0 14.0 21.0 6.0 10.0 10.0
14 2008 0.0 0.0 0.0 0.0 0.0 0.0
15 2009 10.0 15.0 10.0 16.0 9.0 13.0
16 2010 6.0 40.0 32.0 7.0 7.0 37.0
17 2011 1.0 4.0 6.0 5.0 4.0 9.0
18 2012 3.0 18.0 21.0 4.0 1.0 14.0
19 2013 6.0 16.0 20.0 11.0 9.0 19.0
20 2014 13.0 10.0 5.0 8.0 10.0 17.0
21 2015 36.0 6.0 4.0 27.0 36.0 6.0
22 2016 9.0 5.0 1.0 10.0 13.0 7.0
23 2017 14.0 6.0 6.0 13.0 13.0 14.0
24 2018 8.0 8.0 4.0 18.0 12.0 8.0
25 2019 0.0 1.0 1.0 2.0 0.0 0.0
26 2020 45.0 3.0 2.0 29.0 39.0 4.0
27 2021 19.0 4.0 3.0 23.0 16.0 6.0
28 2022 45.0 4.0 1.0 31.0 42.0 6.0
29 2023 32.0 5.0 2.0 49.0 27.0 8.0
30 2024 29.0 2.0 3.0 20.0 20.0 3.0
31 2025 6.0 1.0 1.0 3.0 11.0 1.0

Now the rows are sorted monotonically by year, the column names indicate the record, and the table values are the number of times each year appears in the record. Thus, we are finally ready to create a histogram for each record:

for col in counts.columns:
    if col != 'Year':

        # Create plot
        p = figure(x_range=counts['Year'], tooltips="@Year: @$name", height=400,
                    tools='pan, wheel_zoom, box_zoom, undo, reset, fullscreen',
                    title=f'Distribution of {col.lower()}s'.upper())
        bars = p.vbar(x='Year', top=col, source=counts,
                        name=col, color=colors['cb'][col], alpha=0.85)
        config_plot(p, scheme='cb')
        p.title.text_font_size = '20px'

        # x-axis
        p.xaxis.major_label_orientation = 45
        p.x_range.range_padding = 0.05

        # y-axis
        p.yaxis.axis_label='Number of Records Set'
        p.y_range.start = 0
        p.yaxis.axis_label='Number of Records'
        show(p)

The website shows similar histograms of monthly record counts, made in exactly the same way.

Water Level Trend

Water level data are the only data that are quality controlled and, in many locations, the time series span several decades, making it possible to discern trends. To do this, we need to read in the full data record.

os.chdir('..')
with open('stations.json', 'r') as f:
    stationlist = json.load(f)
    
data = climo.Data(stationname=stationname, stationid=stationlist[stationname])
Loading metadata from file
Loading historical data from file
Loading daily statistics from file
Loading monthly statistics from file
Filtering observational data
Done!

Now we calculate monthly averages for each month in the time series.

var = 'Water Level'
dailyAvgs = data.mon_daily_avgs(true_average=False)[var]
monthlyAvgs = dailyAvgs.groupby(pd.Grouper(freq='M')).mean(numeric_only=True)
df = pd.DataFrame(monthlyAvgs)
df = df.loc[df.first_valid_index():df.last_valid_index()]

Next, we fill in missing data gaps by applying a simple linear interpretation.

df['rownum'] = np.arange(df.shape[0])
col = df.columns.values[0]
df_nona = df.dropna(subset = [col])
f = interp1d(df_nona['rownum'], df_nona[col])
df['linear_fill'] = f(df['rownum'])

In order to apply a linear regression, we need to remove the seasonal signal from the time series to extract the long term trend. But first, we normalize the time series.

# Normalize time series to deseasonalize
tsMin = df['linear_fill'].min()
tsMax = df['linear_fill'].max()
tseries_norm = (df['linear_fill'] - tsMin) / (tsMax - tsMin)
# Deseasonalize time series and un-normalize
components = seasonal_decompose(tseries_norm, model='additive', period=4)
deseasoned = tseries_norm - components.seasonal
df['deseasoned'] = (deseasoned * (tsMax - tsMin)) + tsMin
# Apply linear regression
coef = np.polyfit(df['rownum'], df['deseasoned'], 1)
slope = coef[0] # height/mon
poly1d_fn = np.poly1d(coef)
df['linear_reg'] = poly1d_fn(df['rownum'])
mask = ~df[var].isna()
masked = np.ma.masked_where(df[var].isna(), df['deseasoned'])

Finally, let’s make the plot:

# Create plot
df.index.name = 'xdates'
ts_start = df.index.min().strftime('%-m/%-d/%Y')
ts_end = df.index.max().strftime('%-m/%-d/%Y')
source = bm.ColumnDataSource(df.reset_index())
p = figure(title=f'RELATIVE {var.upper()} TREND: {ts_start} - {ts_end}\n'+
                    f'{np.round(slope*12, 4)} {data.units[var]}/yr or {np.round(slope*12*100, 2)} {data.units[var]} in 100 years',
            background_fill_color='#404040', border_fill_color='#404040',
            width=1000, height=600, x_axis_type='datetime',
            tools='pan, wheel_zoom, box_zoom, undo, reset, fullscreen',
            outline_line_color=None, sizing_mode='scale_height')

# Data with regression line
sct = p.line(x='xdates', y=var, source=source, name='Monthly Average',
            color=colors['mg']['Plot Light Color'], alpha=0.5)
sct.level = 'overlay'
reg = p.line(x='xdates', y='linear_reg', source=source, name='Trend',
            color=colors['mg']['Record Low'], line_width=5)
reg.level = 'overlay'
config_plot(p)

# Tools
crosshair = bm.CrosshairTool(dimensions='height',
                            line_color='grey', line_alpha=0.5)
hover = bm.HoverTool(mode='vline', renderers=[sct],
                    formatters={'@xdates': 'datetime'})
hover.tooltips = f"""
<b> @xdates{{%b %Y}} </b> <br>
Monthly Average: @{{Water Level}}{{0.00}} {data.units[var]}
"""
p.add_tools(hover, crosshair)
p.toolbar.autohide = True

# y-axis
p.yaxis.axis_label = f'Monthly average {var.lower()} relative to {data.datum}'
if max(df[var]) < 0:
    p.y_range = bm.Range1d(-max(abs(df[var]))-0.5, 0.45,
                            bounds=(-max(abs(df[var]))-0.5, 0.45))

show(p)

Data Table

One may wish to see the data behind these plots, or see the other records not plotted. We will use the great_tables library to display colored tables. We’ll demonstrate this below for Air Temperature.

great_tables displays dataframes, so we first need to extract the data from the xarray object, convert to a Pandas datarame, and reset the index.

def getrows(record):
    thisyear = dt.today().year
    return df[(df[record] == thisyear)].index.to_list()

def getcols(record):
    return [record.replace(' Year', ''), record]

df = mons.sel(variable=var.title()).to_dataframe().drop('variable', axis=1).round(2).reset_index()

It is helpful to highly records set this year, so let’s find those in the dataframe.

# Data record
ts_start = dt.strptime(mons.attrs[f'{var} data range'][0], '%Y-%m-%d').strftime('%-m/%-d/%Y')
ts_end = dt.strptime(mons.attrs[f'{var} data range'][1], '%Y-%m-%d').strftime('%-m/%-d/%Y')

# Records this year
thisYear = pd.to_datetime('today').year
cols = df.columns[df.columns.str.endswith('Year')]
thisYearRecords = (df==thisYear)[cols].sum().sum()
lastYearRecords = (df==thisYear-1)[cols].sum().sum()

Now create the table and add the columns. We also specify any formatting of each column here including the colors, which is taken from the color dictionary above.

gtbl = GT(stats)
for column in stats.columns:
    gtbl = gtbl.tab_style(style=[style.fill(color=colors['cb'][column]), style.text(v_align='middle')], locations=loc.body(columns=column))

Finally, format the rest of the table, including text alignment, font, header formatting, and title. We also show the records set this year in bold to make them stand out.

gtbl = (gtbl
.cols_align(align='center')
.tab_style(style=[style.text(color='gainsboro', weight='bold'), style.fill(color='dimgray')], locations=loc.column_header())
.tab_options(table_font_size='13px', 
             table_body_hlines_color='white',
             heading_align='left',
             heading_title_font_weight='bold',
             heading_background_color='gainsboro')
.tab_header(title="""As of today, {} {} record observations have been reported this year. Last year, {} records were reported.""".format(thisYearRecords, var.lower(), lastYearRecords),
            subtitle='DATA RECORD: {} - {}'.format(ts_start, ts_end))
)

# Bolden records this year
for record in df.columns[df.columns.str.endswith('Year')]:
    gtbl = gtbl.tab_style(
        style = style.text(weight='bold'),
        locations = loc.body(columns=getcols(record), rows=getrows(record)))

gtbl.show()
As of today, 0 air temperature record observations have been reported this year. Last year, 4 records were reported.
DATA RECORD: 2/1/1994 - 5/30/2025
Month Monthly Average Record High Monthly Average Record High Monthly Average Year Record Low Monthly Average Record Low Monthly Average Year Average High Lowest High Lowest High Year Record High Record High Year Average Low Highest Low Highest Low Year Record Low Record Low Year Years
Jan 68.66 72.57 2013 63.04 2001 76.05 72.95 2011 78.05 2022 55.55 63.5 2013 48.3 1997 23
Feb 70.89 74.9 2018 65.47 1996 76.49 74.2 2000 78.55 2021 59.36 70.0 2018 47.9 1996 24
Mar 72.27 77.62 2003 66.08 2010 78.49 74.15 2010 82.85 2003 63.33 72.0 1997 55.1 1996 25
Apr 75.63 79.38 2020 72.81 2004 80.74 77.3 2004 85.8 2020 68.4 72.6 2015 61.2 2009 25
May 78.73 81.93 2024 76.97 2007 82.48 79.35 2007 87.25 2024 73.95 77.1 2003 67.95 1999 26
Jun 81.54 83.61 2010 79.84 2014 84.75 82.75 2014 87.65 2009 77.64 80.75 2004 75.1 1995 21
Jul 82.91 85.0 2023 80.98 2013 85.79 84.2 2012 88.7 2018 79.02 82.3 2022 76.1 2013 26
Aug 83.3 85.91 2022 81.82 1994 85.85 84.05 2003 88.5 2022 79.51 83.55 2022 77.0 1994 24
Sep 82.09 83.74 2024 80.57 2001 85.17 83.9 2000 86.7 2021 78.34 81.6 2024 74.3 2001 25
Oct 79.63 81.24 2020 77.55 2000 83.81 80.95 2010 86.75 2023 72.88 77.8 2020 64.6 2005 24
Nov 75.04 78.62 2015 71.37 2012 79.84 76.9 2012 82.05 2020 65.97 74.45 2020 57.35 2006 24
Dec 71.43 76.87 2015 62.1 2010 77.47 72.5 2010 79.65 1994 59.41 70.5 2015 48.75 2010 25

And there we have it! All of the records for each month, color coded for easier reading.

Some concluding remarks on the choice of packages here. Another common Python library for making interactive plots is Plotly. I tried this first (see below) but encountered a known issue with rendering Plotly plots in Quarto web dashboards. In short, the first time Plotly is called in a web application, the plot renders to the proper size of the web container, but subsequent calls to Plotly (like navigating to a new tab or page) do not size figures properly. The workaround demonstrated here fixed the width rendering, but all of the resulting plots were only half the height of the container/page. Plotly also supports displaying colored tables, but these experienced the same rendering issue with Quarto. Cue Bokeh. This library did not have the rendering problem, although the plots had slighly less interactivity than the Plotly versions. Creating colored tables with Bokeh, however, turned out to be frustratingly difficult and very poorly documented. For example, Bokeh tables are colored using HTML, but there was no documentation on how to color an entire column of data. In contrast, the library great_tables made this easy, although it too currently lacks the full interactivity that Plotly offered (e.g., sorting by column).


The following is a Plotly version of the daily climatology plot above. It is basically the same but supports some behaviors that, so far, are not possible (or much harder to accomplish) with Bokeh, such as only showing records in the hoverbox on days when a record is set.

import plotly.graph_objects as go
def daily_climo(data, var, scheme='mg'):
    """Create a daily climatology plot for environmental variable 'var'
    from 'data'.
    
    Inputs:
        data: xarray containing climatological stats
        var: str, one of the available environmental variables in 'data'
        scheme: str, either 'mg' or 'bm' specifying whether to use M. Grossi's
            color scheme or B. McNoldy's
        show: Bool, display the plot to screen instead of saving to file
    """

    # Dates for x axis
    xdates = pd.date_range(start='2020-01-01',end='2020-12-31', freq='1D')
    df = data.sel(variable=var)
    
    # Color dictionary
    colors = dict(
        mg=dict({
            'Record High Year': 'white',
            'Record High': '#d26c6c',
            'Average High': '#dc8d8d',
            'Daily Average': '#F5F5F5',
            'Average Low': '#a2bff4',
            'Record Low': '#74a0ef',
            'Record Low Year': 'white'}),
        bm=dict({
            'Record High Year': 'white',
            'Record High': 'orange',
            'Average High': 'red',
            'Daily Average': 'grey',
            'Average Low': 'purple',
            'Record Low': 'white'}        
        ))
    
    # Create figure
    fig = go.Figure()

    # Record highs
    # High records this year
    thisYear = pd.to_datetime('today').year
    thisYearRecords = (df==thisYear).to_dataframe().drop('variable', axis=1)[['Record High Year', 'Record Low Year']].sum().sum()
    lastYearRecords = (df==thisYear-1).to_dataframe().drop('variable', axis=1)[['Record High Year', 'Record Low Year']].sum().sum()
    highRecords = df['Record High'].where(df['Record High Year'] == thisYear).to_dataframe()['Record High']
    highRecords.index = pd.to_datetime(highRecords.index+'-2020')
    lowRecords = df['Record Low'].where(df['Record Low Year'] == thisYear).to_dataframe()['Record Low']
    lowRecords.index = pd.to_datetime(lowRecords.index+'-2020')
    
    first_time = dt.strptime(df.attrs[f'{var} data range'][0], '%Y-%m-%d').strftime('%-m/%-d/%Y')
    last_time = dt.strptime(df.attrs[f'{var} data range'][1], '%Y-%m-%d').strftime('%-m/%-d/%Y')
    fig.add_trace(
    go.Scatter(
        x=highRecords.index, y=highRecords.values,
        name=f'{pd.to_datetime("today").year} Record'.upper(),
        mode='markers',
        marker=dict(size=6, color='white'),
        hovertext=[f'{thisYear} Record: {i}' if not pd.isnull(i) else '' for i in highRecords.values],
        hoverinfo='text'
    ))
    fig.add_trace(
    go.Scatter(
        x=lowRecords.index, y=lowRecords.values,
        name='Low Record',
        mode='markers',
        marker=dict(size=6, color='white'),
        hoverinfo='none'
    ))
    fig.add_trace(
    go.Scatter(
        x=xdates, y=df['Record High'],
        name='Record High'.upper(),
        mode='markers',
        marker=dict(size=3, color=colors[scheme]['Record High'])
    ))
    # Average highs
    fig.add_trace(
    go.Scatter(
        x=xdates, y=cos_fit(df['Average High']).round(1),
        name='Average High'.upper(),
        marker=dict(size=3, color=colors[scheme]['Average High'])
    ))
    # Daily average
    fig.add_trace(
    go.Scatter(
        x=xdates, y=cos_fit(df['Daily Average']).round(1),
        name='Daily Average'.upper(),
        marker=dict(size=3, color=colors[scheme]['Daily Average'])
    ))
    # Average lows
    fig.add_trace(
    go.Scatter(
        x=xdates,
        y=cos_fit(df['Average Low']).round(1),
        name='Average Low'.upper(),
        marker=dict(size=3, color=colors[scheme]['Average Low'])
    ))
    # Record lows
    fig.add_trace(
    go.Scatter(
        x=xdates, y=df['Record Low'],
        name='Record Low'.upper(),
        mode='markers',
        marker=dict(size=3, color=colors[scheme]['Record Low'])
    ))
    # Hover box
    fig.update_traces(
        hoverlabel = dict(bordercolor='white')
    )
    # Plot settings
    fig.update_layout(
        template='plotly_dark',
        paper_bgcolor='#404040',
        plot_bgcolor='#404040',
        height=600, width=1000,
        title=dict(text='Daily {} records'.format(var.lower())+
                        '<br><sup>{}-{}</sup>'.format(first_time, last_time)+
                        '<br><sup>As of today, <b>{}</b> {} record highs/lows have been set. Last year, {} records were set.</sup>'.format(
                            thisYearRecords, var.lower(), lastYearRecords
                        ),
                  font=dict(size=20)),
        yaxis = dict(title=f'{var} ({data.attrs[f"{var} units"]})',
                     showgrid=True, gridcolor='grey'),
        xaxis = dict(showgrid=False, showspikes=True,
                     dtick='M1', tickformat='%b %d'),
        hovermode='x unified',
        legend=dict(itemsizing='constant'),
        hoverlabel=dict(font_size=12)
    )
    for trace in fig['data']: 
        if trace['name'] == 'Low Record':
            trace['showlegend'] = False
    fig.show()
daily_climo(days, 'Air Temperature', scheme='mg')

That concludes this climatology demonstration series.


Back to top