ahuang11's picture
Update app.py
b0cc0a1 verified
raw history blame
No virus
17.8 kB
import param
import panel as pn
import numpy as np
import pandas as pd
import hvplot.pandas
import geoviews as gv
import holoviews as hv
from holoviews.streams import Tap
from bokeh.themes import Theme
VAR_OPTIONS = {
"Maximum Air Temperature [F]": "max_temp_f",
"Minimum Air Temperature [F]": "min_temp_f",
"Maximum Dew Point [F]": "max_dewpoint_f",
"Minimum Dew Point [F]": "min_dewpoint_f",
"Daily Precipitation [inch]": "precip_in",
"Average Wind Speed [knots]": "avg_wind_speed_kts",
"Average Wind Direction [deg]": "avg_wind_drct",
"Minimum Relative Humidity [%]": "min_rh",
"Average Relative Humidity [%]": "avg_rh",
"Maximum Relative Humidity [%]": "max_rh",
"NCEI 1991-2020 Daily High Temperature Climatology [F]": "climo_high_f",
"NCEI 1991-2020 Daily Low Temperature Climatology [F]": "climo_low_f",
"NCEI 1991-2020 Daily Precipitation Climatology [inch]": "climo_precip_in",
"Reported Snowfall [inch]": "snow_in",
"Reported Snow Depth [inch]": "snowd_in",
"Minimum 'Feels Like' Temperature [F]": "min_feel",
"Average 'Feels Like' Temperature [F]": "avg_feel",
"Maximum 'Feels Like' Temperature [F]": "max_feel",
"Maximum sustained wind speed [knots]": "max_wind_speed_kts",
"Maximum wind gust [knots]": "max_wind_gust_kts",
"Daily Solar Radiation MJ/m2": "srad_mj",
}
VAR_OPTIONS_R = {v: k for k, v in VAR_OPTIONS.items()}
NETWORKS_URL = "https://mesonet.agron.iastate.edu/sites/networks.php?network=_ALL_&format=csv&nohtml=on"
STATION_URL_FMT = (
"https://mesonet.agron.iastate.edu/cgi-bin/request/daily.py?network={network}&stations={station}"
"&year1=1928&month1=1&day1=1&year2=2023&month2=12&day2=31&var={var}&na=blank&format=csv"
)
DARK_RED = "#FF5555"
DARK_BLUE = "#5588FF"
XTICKS = [
(1, "JAN"),
(31, "FEB"),
(59, "MAR"),
(90, "APR"),
(120, "MAY"),
(151, "JUN"),
(181, "JUL"),
(212, "AUG"),
(243, "SEP"),
(273, "OCT"),
(304, "NOV"),
(334, "DEC"),
]
THEME_JSON = {
"attrs": {
"figure": {
"background_fill_color": "#1b1e23",
"border_fill_color": "#1b1e23",
"outline_line_alpha": 0,
},
"Grid": {
"grid_line_color": "#808080",
"grid_line_alpha": 0.1,
},
"Axis": {
# tick color and alpha
"major_tick_line_color": "#4d4f51",
"minor_tick_line_alpha": 0,
# tick labels
"major_label_text_font": "Courier New",
"major_label_text_color": "#808080",
"major_label_text_align": "left",
"major_label_text_font_size": "0.95em",
"major_label_text_font_style": "normal",
# axis labels
"axis_label_text_font": "Courier New",
"axis_label_text_font_style": "normal",
"axis_label_text_font_size": "1.15em",
"axis_label_text_color": "lightgrey",
"axis_line_color": "#4d4f51",
},
"Legend": {
"spacing": 8,
"glyph_width": 15,
"label_standoff": 8,
"label_text_color": "#808080",
"label_text_font": "Courier New",
"label_text_font_size": "0.95em",
"label_text_font_style": "bold",
"border_line_alpha": 0,
"background_fill_alpha": 0.25,
"background_fill_color": "#1b1e23",
},
"BaseColorBar": {
# axis labels
"title_text_color": "lightgrey",
"title_text_font": "Courier New",
"title_text_font_size": "0.95em",
"title_text_font_style": "normal",
# tick labels
"major_label_text_color": "#808080",
"major_label_text_font": "Courier New",
"major_label_text_font_size": "0.95em",
"major_label_text_font_style": "normal",
"background_fill_color": "#1b1e23",
"major_tick_line_alpha": 0,
"bar_line_alpha": 0,
},
"Title": {
"text_font": "Courier New",
"text_font_style": "normal",
"text_color": "lightgrey",
},
}
}
theme = Theme(json=THEME_JSON)
hv.renderer("bokeh").theme = theme
pn.extension(throttled=True)
class ClimateApp(pn.viewable.Viewer):
network = param.Selector(default="WA_ASOS")
station = param.Selector(default="SEA")
year = param.Integer(default=2023, bounds=(1928, 2023))
year_range = param.Range(default=(1990, 2020), bounds=(1928, 2023))
var = param.Selector(default="max_temp_f", objects=sorted(VAR_OPTIONS.values()))
stat = param.Selector(default="Mean", objects=["Mean", "Median"])
def __init__(self, **params):
super().__init__(**params)
pn.state.onload(self._onload)
def _onload(self):
self._networks_df = self._get_networks_df()
networks = sorted(self._networks_df["iem_network"].unique())
self.param["network"].objects = networks
network_select = pn.widgets.AutocompleteInput.from_param(
self.param.network, min_characters=0, case_sensitive=False
)
station_select = pn.widgets.AutocompleteInput.from_param(
self.param.station, min_characters=0, case_sensitive=False
)
var_select = pn.widgets.Select.from_param(self.param.var, options=VAR_OPTIONS)
year_slider = pn.widgets.IntSlider.from_param(self.param.year)
year_range_slider = pn.widgets.RangeSlider.from_param(self.param.year_range)
stat_select = pn.widgets.RadioButtonGroup.from_param(self.param.stat, sizing_mode="stretch_width")
self._sidebar = [
network_select,
station_select,
var_select,
year_slider,
year_range_slider,
stat_select,
]
network_points = self._networks_df.hvplot.points(
"lon",
"lat",
legend=False,
cmap="category10",
color="iem_network",
hover_cols=["stid", "station_name", "iem_network"],
size=10,
geo=True,
).opts(
"Points",
fill_alpha=0,
responsive=True,
tools=["tap", "hover"],
active_tools=["wheel_zoom"],
)
tap = Tap(source=network_points)
pn.bind(self._update_station, x=tap.param.x, y=tap.param.y, watch=True)
network_pane = pn.pane.HoloViews(
network_points * gv.tile_sources.CartoDark(),
sizing_mode="stretch_both",
max_height=625,
)
self._station_pane = pn.pane.HoloViews(sizing_mode="stretch_width", height=450)
main_tabs = pn.Tabs(
("Climatology Plot", self._station_pane), ("Map Select", network_pane)
)
self._main = [self._station_pane]
self._update_var_station_dependents()
self._update_stations()
self._update_station_pane()
@pn.cache
def _get_networks_df(self):
networks_df = pd.read_csv(NETWORKS_URL)
return networks_df
@pn.depends("network", watch=True)
def _update_stations(self):
network_df_subset = self._networks_df.loc[
self._networks_df["iem_network"] == self.network,
["stid", "station_name"],
]
names = sorted(network_df_subset["station_name"].unique())
stids = sorted(network_df_subset["stid"].unique())
self.param["station"].objects = names + stids
def _update_station(self, x, y):
if x is None or y is None:
return
def haversine_vectorized(lon1, lat1, lon2, lat2):
R = 6371 # Radius of the Earth in kilometers
dlat = np.radians(lat2 - lat1)
dlon = np.radians(lon2 - lon1)
a = (
np.sin(dlat / 2.0) ** 2
+ np.cos(np.radians(lat1))
* np.cos(np.radians(lat2))
* np.sin(dlon / 2.0) ** 2
)
c = 2 * np.arctan2(np.sqrt(a), np.sqrt(1 - a))
return R * c
distances = haversine_vectorized(
self._networks_df["lon"].values, self._networks_df["lat"].values, x, y
)
min_distance_index = np.argmin(distances)
closest_row = self._networks_df.iloc[min_distance_index]
with param.parameterized.batch_call_watchers(self):
self.network = closest_row["iem_network"]
self.station = closest_row["stid"]
@pn.cache
def _get_station_df(self, station, var):
if station in self._networks_df["station_name"].unique():
station = self._networks_df.loc[
self._networks_df["station_name"] == station, "stid"
].iloc[0]
if station.startswith("K"):
station = station.lstrip("K")
station_url = STATION_URL_FMT.format(
network=self.network, station=station, var=var
)
station_df = (
pd.read_csv(
station_url,
parse_dates=True,
index_col="day",
)
.drop(columns=["station"])
.astype("float16")
.assign(
dayofyear=lambda df: df.index.dayofyear,
year=lambda df: df.index.year,
)
.dropna()
)
return station_df
@pn.depends("var", "station", watch=True)
def _update_var_station_dependents(self):
try:
self._station_pane.loading = True
self._station_df = self._get_station_df(self.station, self.var).dropna()
if len(self._station_df) == 0:
return
year_range_min = self._station_df["year"].min()
year_range_max = self._station_df["year"].max()
if self.year_range[0] < year_range_min:
self.year_range = (year_range_min, self.year_range[1])
if self.year_range[1] > year_range_max:
self.year_range = (self.year_range[0], year_range_max)
self.param["year_range"].bounds = (year_range_min, year_range_max)
self.param["year"].bounds = (year_range_min, year_range_max)
if self.year < year_range_min:
self.year = year_range_min
if self.year > year_range_max:
self.year = year_range_max
finally:
self._station_pane.loading = False
@pn.depends("var", "station", "year", "year_range", "stat", watch=True)
def _update_station_pane(self):
if len(self._station_df) == 0:
return
try:
self._station_pane.loading = True
df = self._station_df
if self.station not in self._networks_df["station_name"].unique():
station_name = self._networks_df.loc[
self._networks_df["stid"] == self.station, "station_name"
].iloc[0]
else:
station_name = self.station
# get average and year
df_avg = (
df.loc[df["year"].between(*self.year_range)].groupby("dayofyear").mean()
)
df_year = df[df.year == self.year]
if self.stat == "Mean":
df_year_avg = df_year[self.var].mean()
else:
df_year_avg = df_year[self.var].median()
df_year_max = df_year[self.var].max()
df_year_min = df_year[self.var].min()
# preprocess below/above
df_above = df_year[["dayofyear", self.var]].merge(
df_avg.reset_index()[["dayofyear", self.var]],
on="dayofyear",
suffixes=("_year", "_avg"),
)
df_above[self.var] = df_above[f"{self.var}_avg"]
df_above[self.var] = df_above.loc[
df_above[f"{self.var}_year"] >= df_above[f"{self.var}_avg"],
f"{self.var}_year",
]
df_below = df_year[["dayofyear", self.var]].merge(
df_avg.reset_index()[["dayofyear", self.var]],
on="dayofyear",
suffixes=("_year", "_avg"),
)
df_below[self.var] = df_below[f"{self.var}_avg"]
df_below[self.var] = df_below.loc[
df_below[f"{self.var}_year"] < df_below[f"{self.var}_avg"],
f"{self.var}_year",
]
days_above = df_above.loc[
df_above[f"{self.var}_year"] >= df_above[f"{self.var}_avg"]
].shape[0]
days_below = df_below.loc[
df_below[f"{self.var}_year"] < df_below[f"{self.var}_avg"]
].shape[0]
# create plot elements
plot_kwargs = {
"x": "dayofyear",
"y": self.var,
"responsive": True,
"legend": False,
}
plot = df.hvplot(
by="year",
color="grey",
alpha=0.02,
hover=False,
**plot_kwargs,
)
plot_year = (
df_year.hvplot(color="black", hover="vline", **plot_kwargs)
.opts(alpha=0.2)
.redim.label(**{"dayofyear": "Julian Day", self.var: str(self.year)})
)
plot_avg = df_avg.hvplot(color="grey", **plot_kwargs).redim.label(
**{"dayofyear": "Julian Day", self.var: "Average"}
)
plot_year_avg = hv.HLine(df_year_avg).opts(
line_color="lightgrey", line_dash="dashed", line_width=0.5
)
plot_year_max = hv.HLine(df_year_max).opts(
line_color=DARK_RED, line_dash="dashed", line_width=0.5
)
plot_year_min = hv.HLine(df_year_min).opts(
line_color=DARK_BLUE, line_dash="dashed", line_width=0.5
)
text_year_opts = {
"text_align": "right",
"text_baseline": "bottom",
"text_alpha": 0.8,
}
text_year_label = "AVERAGE" if self.stat == "Mean" else "MEDIAN"
text_year_avg = hv.Text(
360, df_year_avg + 3, f"{text_year_label} {df_year_avg:.0f}", fontsize=8
).opts(
text_color="lightgrey",
**text_year_opts,
)
text_year_max = hv.Text(
360, df_year_max + 3, f"MAX {df_year_max:.0f}", fontsize=8
).opts(
text_color=DARK_RED,
**text_year_opts,
)
text_year_min = hv.Text(
360, df_year_min + 3, f"MIN {df_year_min:.0f}", fontsize=8
).opts(
text_color=DARK_BLUE,
**text_year_opts,
)
area_kwargs = {"fill_alpha": 0.2, "line_alpha": 0.8}
plot_above = df_above.hvplot.area(
x="dayofyear", y=f"{self.var}_avg", y2=self.var, hover=False
).opts(line_color=DARK_RED, fill_color=DARK_RED, **area_kwargs)
plot_below = df_below.hvplot.area(
x="dayofyear", y=f"{self.var}_avg", y2=self.var, hover=False
).opts(line_color=DARK_BLUE, fill_color=DARK_BLUE, **area_kwargs)
text_x = 25
text_y = df_year[self.var].max() + 10
text_days_above = hv.Text(text_x, text_y, f"{days_above}", fontsize=14).opts(
text_align="right",
text_baseline="bottom",
text_color=DARK_RED,
text_alpha=0.8,
)
text_days_below = hv.Text(text_x, text_y, f"{days_below}", fontsize=14).opts(
text_align="right",
text_baseline="top",
text_color=DARK_BLUE,
text_alpha=0.8,
)
text_above = hv.Text(text_x + 3, text_y, "DAYS ABOVE", fontsize=7).opts(
text_align="left",
text_baseline="bottom",
text_color="lightgrey",
text_alpha=0.8,
)
text_below = hv.Text(text_x + 3, text_y, "DAYS BELOW", fontsize=7).opts(
text_align="left",
text_baseline="top",
text_color="lightgrey",
text_alpha=0.8,
)
# overlay everything and save
station_overlay = (
plot
* plot_year
* plot_avg
* plot_year_avg
* plot_year_max
* plot_year_min
* text_year_avg
* text_year_max
* text_year_min
* plot_above
* plot_below
* text_days_above
* text_days_below
* text_above
* text_below
).opts(
xlabel="TIME OF YEAR",
ylabel=VAR_OPTIONS_R[self.var],
title=f"{station_name} {self.year} vs AVERAGE ({self.year_range[0]}-{self.year_range[1]})",
gridstyle={"ygrid_line_alpha": 0},
xticks=XTICKS,
show_grid=True,
fontscale=1.18,
padding=(0, (0, 0.3))
)
self._station_pane.object = station_overlay
finally:
self._station_pane.loading = False
def __panel__(self):
return pn.template.FastListTemplate(
sidebar=self._sidebar,
main=self._main,
theme="dark",
theme_toggle=False,
main_layout=None,
title="Select Year vs Average Comparison",
accent="#2F4F4F",
)
ClimateApp().servable()