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
=True)
output_notebook(hide_banner
'/workspaces/climatology/')
sys.path.append(from clipy import climo
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:
- Daily and monthly averages
- Record high daily and monthly averages*
- Record low daily and monthly averages*
- Average daily and monthly high
- Lowest daily and monthly high*
- Record daily and monthly high*
- Average daily and monthly low
- Highest daily and monthly low*
- Record daily and monthly low*
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.
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
'always') warnings.filterwarnings(
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"""
= text.replace(',','').replace("-", " ").replace("_", " ")
s = s.split()
s 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
"""
= np.arange(0, len(data))/len(data)
X
# Initial parameter values
= 1
guess_freq = 3*np.std(data)/(2**0.5)
guess_amplitude = 0
guess_phase = np.mean(data)
guess_offset = [guess_freq, guess_amplitude,
p0
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
= curve_fit(my_cos, X, data, p0=p0)
fit
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
= dict(
colors =dict({
mg'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'}),
=dict({
bm'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'}),
=dict({
cb'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
= '#404040'
p.background_fill_color = '#404040'
p.border_fill_color = 1000
p.width = None
p.outline_line_color = 'scale_height'
p.sizing_mode
# x-axis
= None
p.xgrid.grid_line_color = 'grey'
p.xaxis.axis_line_color = 'grey'
p.xaxis.major_tick_line_color
# y-axis
= colors[scheme]['Plot Light Color']
p.yaxis.axis_label_text_color = 'grey'
p.ygrid.grid_line_color = None
p.yaxis.axis_line_color = None
p.yaxis.major_tick_line_color = None
p.yaxis.minor_tick_line_color = None
p.outline_line_color
# Fonts
= 'arial narrow'
p.title.text_font = '16px'
p.title.text_font_size = 'darkgray'
p.title.text_color = 'arial narrow'
p.xaxis.major_label_text_font = colors[scheme]['Plot Light Color']
p.xaxis.major_label_text_color = '14px'
p.xaxis.major_label_text_font_size = 'arial narrow'
p.yaxis.major_label_text_font = 'arial narrow'
p.yaxis.axis_label_text_font = 'normal'
p.yaxis.axis_label_text_font_style = colors[scheme]['Plot Light Color']
p.yaxis.major_label_text_color = '14px'
p.yaxis.major_label_text_font_size = '14px' p.yaxis.axis_label_text_font_size
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
= 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'])
df[
# Records this year
= pd.to_datetime('today').year
thisYear 'High Records'] = df['Record High'].where(df['Record High Year'] == thisYear)
df['Low Records'] = df['Record Low'].where(df['Record Low Year'] == thisYear)
df[= bm.ColumnDataSource(df)
source
# Create a new plot
= dt.strptime(data.attrs[f'{var} data range'][0], '%Y-%m-%d').strftime('%-m/%-d/%Y')
ts_start = dt.strptime(data.attrs[f'{var} data range'][1], '%Y-%m-%d').strftime('%-m/%-d/%Y')
ts_end = figure(title=f'DATA RECORD: {ts_start} - {ts_end}',
p ='datetime', height=600,
x_axis_type=(round_down(df['Record Low'].min(), 10),
y_range'Record High'].max(), 10)),
round_up(df[='pan, wheel_zoom, box_zoom, undo, reset, fullscreen')
tools
# This year record highs
= p.scatter(x='xdates', y='High Records', source=source,
hr =f'{thisYear} High Record', size=4, color='white',
name='white', hover_alpha=0.5)
hover_fill_color= 'overlay'
hr.level # This year record lows
= p.scatter(x='xdates', y='Low Records', source=source,
lr =f'{thisYear} Low Record', size=4, color='white',
name='white', hover_alpha=0.5)
hover_fill_color= 'overlay'
lr.level # Record highs
= p.scatter(x='xdates', y='Record High', source=source,
rh ='Record High', size=2,
name=colors[scheme]['Record High'])
color# Average high
= p.line(x='xdates', y='Average High Curve', source=source,
ah ='Average High', width=3,
name=colors[scheme]['Average High'])
color# Daily average
= p.line(x='xdates', y='Daily Average Curve', source=source,
da ='Daily Average', width=2,
name=colors[scheme]['Daily Average'])
color# Average lows
= p.line(x='xdates', y='Average Low Curve', source=source,
al ='Average Low', width=3,
name=colors[scheme]['Average Low'])
color# Record lows
= p.scatter(x='xdates', y='Record Low', source=source,
rl ='Record Low', size=2,
name=colors[scheme]['Record Low'])
color
config_plot(p)
# Flood thresholds (water level plot only)
if var=='Water Level':
for level, threshold in flood_thresholds.items():
= bm.Span(location=threshold, dimension='width',
hline =[20,8], line_alpha=0.75,
line_dash='cadetblue', line_width=2)
line_color
p.renderers.extend([hline])= bm.Label(x=pd.to_datetime('2019-12-15'), y=threshold+0.1,
mytext =f'{level} flood threshold'.upper(), text_color='cadetblue',
text='8px',
text_font_size='arial narrow')
text_font
p.add_layout(mytext)
# Tools
= bm.CrosshairTool(dimensions='height',
crosshair ='grey', line_alpha=0.5)
line_color= bm.HoverTool(mode='vline', renderers=[da],
hover ={'@xdates': 'datetime'})
formatters= data.attrs[f"{var} units"]
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)= True
p.toolbar.autohide
# x-axis
0].formatter = bm.DatetimeTickFormatter(months="%b %d")
p.xaxis[0].ticker.desired_num_ticks = 12
p.xaxis[
# y-axis
if var == 'Water Level':
=f'{var} relative to {data.attrs["datum"].upper()} ({data.attrs[f"{var} units"]})'
p.yaxis.axis_labelelse:
=f'{var} ({data.attrs[f"{var} units"]})'
p.yaxis.axis_label= round_down(df['Record Low'].min(), 10)
ymin = round_up(df['Record High'].max(), 10)
ymax = bm.Range1d(ymin, ymax, bounds=(ymin, ymax))
p.y_range
# Legend
= bm.Legend(items=[
legend '{} Record'.format(thisYear), [hr, lr]),
('Record High', [rh]),
('Average High', [ah]),
('Daily Average', [da]),
('Average Low', [al]),
('Record Low', [rl])],
(='#404040', border_line_color=None,
background_fill_color=colors[scheme]['Plot Light Color'],
label_text_color='center_right', click_policy='mute')
location'right')
p.add_layout(legend,
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
= 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'])
df[
# Record this year
= pd.to_datetime('today').year
thisYear 'High Records'] = df['Record High'].where(df['Record High Year'] == thisYear)
df['Low Records'] = df['Record Low'].where(df['Record Low Year'] == thisYear)
df[= bm.ColumnDataSource(df)
source
# Create a new plot
= dt.strptime(data.attrs[f'{var} data range'][0], '%Y-%m-%d').strftime('%-m/%-d/%Y')
ts_start = dt.strptime(data.attrs[f'{var} data range'][1], '%Y-%m-%d').strftime('%-m/%-d/%Y')
ts_end = figure(title=f'DATA RECORD: {ts_start} - {ts_end}', height=600,
p =['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
x_range'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'],
=(round_down(df['Record Low'].min(), 1), round_up(df['Record High'].max(), 1)),
y_range='pan, wheel_zoom, box_zoom, undo, reset, fullscreen')
tools
# This year record highs
= p.scatter(x='Month', y='High Records', source=source,
hr =f'{thisYear} High Record', size=7, color='white',
name='white', hover_alpha=0.5)
hover_fill_color= 'overlay'
hr.level # This year record lows
= p.scatter(x='Month', y='Low Records', source=source,
lr =f'{thisYear} Low Record', size=7, color='white',
name='white', hover_alpha=0.5)
hover_fill_color= 'overlay'
lr.level # Record highs
= p.scatter(x='Month', y='Record High', source=source,
rh ='Record High', size=7,
name=colors[scheme]['Record High'])
color# Average high
= p.line(x='Month', y='Average High Curve', source=source,
ah ='Average High', width=4,
name=colors[scheme]['Average High'])
color# Monthly average
= p.line(x='Month', y='Monthly Average Curve', source=source,
ma ='Monthly Average', width=3,
name=colors[scheme]['Monthly Average'])
color# Average lows
= p.line(x='Month', y='Average Low Curve', source=source,
al ='Average Low', width=4,
name=colors[scheme]['Average Low'])
color# Record lows
= p.scatter(x='Month', y='Record Low', source=source,
rl ='Record Low', size=7,
name=colors[scheme]['Record Low'])
color
config_plot(p)
# Tools
= bm.CrosshairTool(dimensions='height',
crosshair ='grey', line_alpha=0.5)
line_color= bm.HoverTool(mode='vline', renderers=[ma],
hover ={'@xdates': 'datetime'})
formatters= data.attrs[f"{var} units"]
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)= True
p.toolbar.autohide
# y-axis
if var == 'Water Level':
=f'{var} relative to {data.attrs["datum"].upper()} ({data.attrs[f"{var} units"]})'
p.yaxis.axis_labelelse:
=f'{var} ({data.attrs[f"{var} units"]})'
p.yaxis.axis_label
# Legend
= bm.Legend(items=[
legend '{} Record'.format(thisYear), [hr, lr]),
('Record High', [rh]),
('Average High', [ah]),
('Monthly Average', [ma]),
('Average Low', [al]),
('Record Low', [rl])],
(='#404040', border_line_color=None,
background_fill_color=colors[scheme]['Plot Light Color'],
label_text_color='center_right', click_policy='mute')
location'right')
p.add_layout(legend,
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'.
"""
= record_counts(data=stats, var=var)
data # Create histogram for each record
for col in data.columns:
if col != 'Year':
# Create plot
= figure(x_range=data['Year'], tooltips="@Year: @$name", height=400,
p ='pan, wheel_zoom, box_zoom, undo, reset, fullscreen',
tools=f'Distribution of {col.lower()}s'.upper())
title= p.vbar(x='Year', top=col, source=data,
bars =col, color=colors[scheme][col], alpha=0.85)
name=scheme)
config_plot(p, scheme= '20px'
p.title.text_font_size
# x-axis
= 45
p.xaxis.major_label_orientation = 0.05
p.x_range.range_padding
# y-axis
='Number of Records Set'
p.yaxis.axis_label= 0
p.y_range.start ='Number of Records'
p.yaxis.axis_labelif y_range is not None:
= bm.Range1d(min(y_range), max(y_range),
p.y_range =(min(y_range), max(y_range)))
bounds
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
= data.mon_daily_avgs(true_average=true_average)[var]
dailyAvgs = dailyAvgs.groupby(pd.Grouper(freq='M')).mean(numeric_only=True)
monthlyAvgs = pd.DataFrame(monthlyAvgs)
df = df.loc[df.first_valid_index():]
df
# Linearly interpret missing data
'rownum'] = np.arange(df.shape[0])
df[= df.columns.values[0]
col = df.dropna(subset = [col])
df_nona = interp1d(df_nona['rownum'], df_nona[col])
f 'linear_fill'] = f(df['rownum'])
df[
# Normalize time series to deseasonalize
= df['linear_fill'].min()
tsMin = df['linear_fill'].max()
tsMax = (df['linear_fill'] - tsMin) / (tsMax - tsMin)
tseries_norm
# Deseasonalize time series and un-normalize
= seasonal_decompose(tseries_norm, model='additive', period=4)
components = tseries_norm - components.seasonal
deseasoned 'deseasoned'] = (deseasoned * (tsMax - tsMin)) + tsMin
df[
# Apply linear regression
= np.polyfit(df['rownum'], df['deseasoned'], 1)
coef = coef[0] # height/mon
slope = np.poly1d(coef)
poly1d_fn 'linear_reg'] = poly1d_fn(df['rownum'])
df[= ~df[var].isna()
mask = np.ma.masked_where(df[var].isna(), df['deseasoned'])
masked
# Create plot
= 'xdates'
df.index.name = df.index.min().strftime('%-m/%-d/%Y')
ts_start = df.index.max().strftime('%-m/%-d/%Y')
ts_end = bm.ColumnDataSource(df.reset_index())
source = figure(title=f'RELATIVE {var.upper()} TREND: {ts_start} - {ts_end}\n'+
p f'{np.round(slope*12, 4)} {data.units[var]}/yr or {np.round(slope*12*100, 2)} {data.units[var]} in 100 years',
='#404040', border_fill_color='#404040',
background_fill_color=1000, height=400, x_axis_type='datetime',
width='pan, wheel_zoom, box_zoom, undo, reset, fullscreen',
tools=None, sizing_mode='scale_height')
outline_line_color
# Data with regression line
= p.line(x='xdates', y=var, source=source, name='Monthly Average',
sct =colors['mg']['Plot Light Color'], alpha=0.5)
color= 'overlay'
sct.level = p.line(x='xdates', y='linear_reg', source=source, name='Trend',
reg =colors['mg']['Record Low'], line_width=5)
color= 'overlay'
reg.level
config_plot(p)
# Tools
= bm.CrosshairTool(dimensions='height',
crosshair ='grey', line_alpha=0.5)
line_color= bm.HoverTool(mode='vline', renderers=[sct],
hover ={'@xdates': 'datetime'})
formatters= f"""
hover.tooltips <b> @xdates{{%b %Y}} </b> <br>
Monthly Average: @{{Water Level}}{{0.00}} {data.units[var]}
"""
p.add_tools(hover, crosshair)= True
p.toolbar.autohide
# y-axis
= f'Monthly average {var.lower()} relative to {data.datum}'
p.yaxis.axis_label if max(df[var]) < 0:
= bm.Range1d(-max(abs(df[var]))-0.5, 0.45,
p.y_range =(-max(abs(df[var]))-0.5, 0.45))
bounds
# 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.
= 'Virginia Key, FL' stationname
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.
= camel(stationname)
dirname = '../'+dirname
outdir
print(f"Station folder: {dirname}")
print(f"Full directory: {outdir}")
Station folder: virginiaKeyFl
Full directory: ../virginiaKeyFl
Next, load the data and metadata.
# Records
= xr.load_dataset(os.path.join(outdir, 'statistics-daily.nc'))
days = xr.load_dataset(os.path.join(outdir, 'statistics-monthly.nc')) mons
And finally, we can make some plots. Let’s look at daily and monthly climatology for Air Temperature.
Records
= 'Air Temperature'
var =days, var=var, flood_thresholds=flood_thresholds, scheme='cb') daily_climo(data
=mons, var=var, scheme='cb') monthly_climo(data
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
= days.sel(variable=var).to_dataframe()
stats = stats[stats.columns[stats.columns.str.endswith('Year')]]
stats
# Value counts for each year
= stats.stack().groupby(level=[1]).value_counts().unstack().T.fillna(0)
counts 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.reindex(range(min(counts.index), max(counts.index)+1), fill_value=0)
counts = counts[stats.columns[stats.columns.str.endswith('Year')]]
counts
# Restructure
= [i.replace(' Year', '') for i in counts.columns]
counts.columns = 'Year'
counts.index.name = counts.index.astype(str)
counts.index =True)
counts.reset_index(inplace 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
= figure(x_range=counts['Year'], tooltips="@Year: @$name", height=400,
p ='pan, wheel_zoom, box_zoom, undo, reset, fullscreen',
tools=f'Distribution of {col.lower()}s'.upper())
title= p.vbar(x='Year', top=col, source=counts,
bars =col, color=colors['cb'][col], alpha=0.85)
name='cb')
config_plot(p, scheme= '20px'
p.title.text_font_size
# x-axis
= 45
p.xaxis.major_label_orientation = 0.05
p.x_range.range_padding
# y-axis
='Number of Records Set'
p.yaxis.axis_label= 0
p.y_range.start ='Number of Records'
p.yaxis.axis_label 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:
= json.load(f)
stationlist
= climo.Data(stationname=stationname, stationid=stationlist[stationname]) data
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.
= 'Water Level'
var = data.mon_daily_avgs(true_average=False)[var]
dailyAvgs = dailyAvgs.groupby(pd.Grouper(freq='M')).mean(numeric_only=True)
monthlyAvgs = pd.DataFrame(monthlyAvgs)
df = df.loc[df.first_valid_index():df.last_valid_index()] df
Next, we fill in missing data gaps by applying a simple linear interpretation.
'rownum'] = np.arange(df.shape[0])
df[= df.columns.values[0]
col = df.dropna(subset = [col])
df_nona = interp1d(df_nona['rownum'], df_nona[col])
f 'linear_fill'] = f(df['rownum']) df[
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
= df['linear_fill'].min()
tsMin = df['linear_fill'].max()
tsMax = (df['linear_fill'] - tsMin) / (tsMax - tsMin) tseries_norm
# Deseasonalize time series and un-normalize
= seasonal_decompose(tseries_norm, model='additive', period=4)
components = tseries_norm - components.seasonal
deseasoned 'deseasoned'] = (deseasoned * (tsMax - tsMin)) + tsMin df[
# Apply linear regression
= np.polyfit(df['rownum'], df['deseasoned'], 1)
coef = coef[0] # height/mon
slope = np.poly1d(coef)
poly1d_fn 'linear_reg'] = poly1d_fn(df['rownum'])
df[= ~df[var].isna()
mask = np.ma.masked_where(df[var].isna(), df['deseasoned']) masked
Finally, let’s make the plot:
# Create plot
= 'xdates'
df.index.name = df.index.min().strftime('%-m/%-d/%Y')
ts_start = df.index.max().strftime('%-m/%-d/%Y')
ts_end = bm.ColumnDataSource(df.reset_index())
source = figure(title=f'RELATIVE {var.upper()} TREND: {ts_start} - {ts_end}\n'+
p f'{np.round(slope*12, 4)} {data.units[var]}/yr or {np.round(slope*12*100, 2)} {data.units[var]} in 100 years',
='#404040', border_fill_color='#404040',
background_fill_color=1000, height=600, x_axis_type='datetime',
width='pan, wheel_zoom, box_zoom, undo, reset, fullscreen',
tools=None, sizing_mode='scale_height')
outline_line_color
# Data with regression line
= p.line(x='xdates', y=var, source=source, name='Monthly Average',
sct =colors['mg']['Plot Light Color'], alpha=0.5)
color= 'overlay'
sct.level = p.line(x='xdates', y='linear_reg', source=source, name='Trend',
reg =colors['mg']['Record Low'], line_width=5)
color= 'overlay'
reg.level
config_plot(p)
# Tools
= bm.CrosshairTool(dimensions='height',
crosshair ='grey', line_alpha=0.5)
line_color= bm.HoverTool(mode='vline', renderers=[sct],
hover ={'@xdates': 'datetime'})
formatters= f"""
hover.tooltips <b> @xdates{{%b %Y}} </b> <br>
Monthly Average: @{{Water Level}}{{0.00}} {data.units[var]}
"""
p.add_tools(hover, crosshair)= True
p.toolbar.autohide
# y-axis
= f'Monthly average {var.lower()} relative to {data.datum}'
p.yaxis.axis_label if max(df[var]) < 0:
= bm.Range1d(-max(abs(df[var]))-0.5, 0.45,
p.y_range =(-max(abs(df[var]))-0.5, 0.45))
bounds
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):
= dt.today().year
thisyear return df[(df[record] == thisyear)].index.to_list()
def getcols(record):
return [record.replace(' Year', ''), record]
= mons.sel(variable=var.title()).to_dataframe().drop('variable', axis=1).round(2).reset_index() df
It is helpful to highly records set this year, so let’s find those in the dataframe.
# Data record
= dt.strptime(mons.attrs[f'{var} data range'][0], '%Y-%m-%d').strftime('%-m/%-d/%Y')
ts_start = dt.strptime(mons.attrs[f'{var} data range'][1], '%Y-%m-%d').strftime('%-m/%-d/%Y')
ts_end
# Records this year
= pd.to_datetime('today').year
thisYear = df.columns[df.columns.str.endswith('Year')]
cols = (df==thisYear)[cols].sum().sum()
thisYearRecords = (df==thisYear-1)[cols].sum().sum() lastYearRecords
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.
= GT(stats)
gtbl for column in stats.columns:
= gtbl.tab_style(style=[style.fill(color=colors['cb'][column]), style.text(v_align='middle')], locations=loc.body(columns=column)) gtbl
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 ='center')
.cols_align(align=[style.text(color='gainsboro', weight='bold'), style.fill(color='dimgray')], locations=loc.column_header())
.tab_style(style='13px',
.tab_options(table_font_size='white',
table_body_hlines_color='left',
heading_align='bold',
heading_title_font_weight='gainsboro')
heading_background_color="""As of today, {} {} record observations have been reported this year. Last year, {} records were reported.""".format(thisYearRecords, var.lower(), lastYearRecords),
.tab_header(title='DATA RECORD: {} - {}'.format(ts_start, ts_end))
subtitle
)
# Bolden records this year
for record in df.columns[df.columns.str.endswith('Year')]:
= gtbl.tab_style(
gtbl = style.text(weight='bold'),
style = loc.body(columns=getcols(record), rows=getrows(record)))
locations
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
= pd.date_range(start='2020-01-01',end='2020-12-31', freq='1D')
xdates = data.sel(variable=var)
df
# Color dictionary
= dict(
colors =dict({
mg'Record High Year': 'white',
'Record High': '#d26c6c',
'Average High': '#dc8d8d',
'Daily Average': '#F5F5F5',
'Average Low': '#a2bff4',
'Record Low': '#74a0ef',
'Record Low Year': 'white'}),
=dict({
bm'Record High Year': 'white',
'Record High': 'orange',
'Average High': 'red',
'Daily Average': 'grey',
'Average Low': 'purple',
'Record Low': 'white'}
))
# Create figure
= go.Figure()
fig
# Record highs
# High records this year
= pd.to_datetime('today').year
thisYear = (df==thisYear).to_dataframe().drop('variable', axis=1)[['Record High Year', 'Record Low Year']].sum().sum()
thisYearRecords = (df==thisYear-1).to_dataframe().drop('variable', axis=1)[['Record High Year', 'Record Low Year']].sum().sum()
lastYearRecords = df['Record High'].where(df['Record High Year'] == thisYear).to_dataframe()['Record High']
highRecords = pd.to_datetime(highRecords.index+'-2020')
highRecords.index = df['Record Low'].where(df['Record Low Year'] == thisYear).to_dataframe()['Record Low']
lowRecords = pd.to_datetime(lowRecords.index+'-2020')
lowRecords.index
= dt.strptime(df.attrs[f'{var} data range'][0], '%Y-%m-%d').strftime('%-m/%-d/%Y')
first_time = dt.strptime(df.attrs[f'{var} data range'][1], '%Y-%m-%d').strftime('%-m/%-d/%Y')
last_time
fig.add_trace(
go.Scatter(=highRecords.index, y=highRecords.values,
x=f'{pd.to_datetime("today").year} Record'.upper(),
name='markers',
mode=dict(size=6, color='white'),
marker=[f'{thisYear} Record: {i}' if not pd.isnull(i) else '' for i in highRecords.values],
hovertext='text'
hoverinfo
))
fig.add_trace(
go.Scatter(=lowRecords.index, y=lowRecords.values,
x='Low Record',
name='markers',
mode=dict(size=6, color='white'),
marker='none'
hoverinfo
))
fig.add_trace(
go.Scatter(=xdates, y=df['Record High'],
x='Record High'.upper(),
name='markers',
mode=dict(size=3, color=colors[scheme]['Record High'])
marker
))# Average highs
fig.add_trace(
go.Scatter(=xdates, y=cos_fit(df['Average High']).round(1),
x='Average High'.upper(),
name=dict(size=3, color=colors[scheme]['Average High'])
marker
))# Daily average
fig.add_trace(
go.Scatter(=xdates, y=cos_fit(df['Daily Average']).round(1),
x='Daily Average'.upper(),
name=dict(size=3, color=colors[scheme]['Daily Average'])
marker
))# Average lows
fig.add_trace(
go.Scatter(=xdates,
x=cos_fit(df['Average Low']).round(1),
y='Average Low'.upper(),
name=dict(size=3, color=colors[scheme]['Average Low'])
marker
))# Record lows
fig.add_trace(
go.Scatter(=xdates, y=df['Record Low'],
x='Record Low'.upper(),
name='markers',
mode=dict(size=3, color=colors[scheme]['Record Low'])
marker
))# Hover box
fig.update_traces(= dict(bordercolor='white')
hoverlabel
)# Plot settings
fig.update_layout(='plotly_dark',
template='#404040',
paper_bgcolor='#404040',
plot_bgcolor=600, width=1000,
height=dict(text='Daily {} records'.format(var.lower())+
title'<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
),=dict(size=20)),
font= dict(title=f'{var} ({data.attrs[f"{var} units"]})',
yaxis =True, gridcolor='grey'),
showgrid= dict(showgrid=False, showspikes=True,
xaxis ='M1', tickformat='%b %d'),
dtick='x unified',
hovermode=dict(itemsizing='constant'),
legend=dict(font_size=12)
hoverlabel
)for trace in fig['data']:
if trace['name'] == 'Low Record':
'showlegend'] = False
trace[ fig.show()
'Air Temperature', scheme='mg') daily_climo(days,
That concludes this climatology demonstration series.