Spaces:
Running
Running
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() | |
def _get_networks_df(self): | |
networks_df = pd.read_csv(NETWORKS_URL) | |
return networks_df | |
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"] | |
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 | |
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 | |
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() |