from datetime import datetime 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 pn.extension(throttled=True, notifications=True) 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", } WELCOME_MESSAGE = """ ### Welcome to the Climaviewer! This app allows you to compare a single year of data from a weather station to the average of a range of years. 1. Select a network station from the dropdowns; alternatively you can click on the map to select the nearest station. 2. Choose the desired variable and year to plot, and change the average range if desired. 3. Hover over the plot to see the value for each day; use the mouse wheel to zoom in/out. 4. Tap on the top plot to see the value for a specific day, compared to all other years in the average range. """ FOOTER_MESSAGE = """ Made entirely with OSS packages: [Panel](https://panel.holoviz.org/), [Holoviews](https://holoviews.org/), [GeoViews](https://geoviews.org/), [Bokeh](https://bokeh.org/), [Pandas](https://pandas.pydata.org/), [Numpy](https://numpy.org/) Data sourced from the [Iowa Environmental Mesonet](https://mesonet.agron.iastate.edu/). """ VAR_OPTIONS_R = {v: k for k, v in VAR_OPTIONS.items()} ONI_URL = "https://raw.githubusercontent.com/ahuang11/oni/master/oni.csv" 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=2024&month2=12&day2=31&var={var}&na=blank&format=csv" ) DARK_RED = "#FF5555" DARK_BLUE = "#5588FF" SEASON_TO_MONTH = { "DJF": "JAN", "JFM": "FEB", "FMA": "MAR", "MAM": "APR", "AMJ": "MAY", "MJJ": "JUN", "JJA": "JUL", "JAS": "AUG", "ASO": "SEP", "SON": "OCT", "OND": "NOV", "NDJ": "DEC", } 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"), ] MONTH_TO_JULIAN_DAY = {month: day for day, month in XTICKS} ONI_COLORS = { "El Nino": DARK_RED, "Neutral": "grey", "La Nina": DARK_BLUE, } 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) this_year = datetime.now().year hv.renderer("bokeh").theme = theme class ClimateApp(pn.viewable.Viewer): network = param.Selector( default="WA_ASOS", label="Network (delete & type to search)" ) station = param.Selector(default="SEA", label="Station (delete & type to search)") year = param.Integer(default=this_year - 1, bounds=(1928, this_year)) year_range = param.Range( default=(1990, 2020), bounds=(1928, this_year), label="Average Range" ) var = param.Selector(default="max_temp_f", objects=sorted(VAR_OPTIONS.values())) stat = param.Selector(default="Mean", objects=["Mean", "Median"]) _title = param.String() _ylabel = param.String() def __init__(self, **params): super().__init__(**params) self._sidebar = pn.Column(sizing_mode="stretch_both") self._main = pn.Column( pn.indicators.LoadingSpinner( value=True, width=25, height=25, name="Loading, please wait a moment..." ), sizing_mode="stretch_both", ) self._modal = pn.Column(width=850, height=500, align="center") self._template = pn.template.FastListTemplate( sidebar=[self._sidebar], main=[self._main], modal=[self._modal], theme="dark", theme_toggle=False, main_layout=None, title="Weather Station Year vs Climatology", accent="grey", header_background="#1b1e23", ) pn.state.onload(self._onload) def _onload(self): try: self._sidebar.loading = True self._populate_sidebar() self._populate_main() self._populate_modal() finally: self._sidebar.loading = False def _populate_sidebar(self): self._network_df = self._get_network_df() self._oni_df = self._get_oni_df() networks = sorted(self._network_df["iem_network"].unique()) self.param["network"].objects = networks open_button = pn.widgets.Button( name="Select station from map", button_type="primary", sizing_mode="stretch_width", ) open_button.on_click(self._open_modal) 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.objects = [ pn.pane.Markdown(WELCOME_MESSAGE), open_button, network_select, station_select, var_select, year_slider, year_range_slider, stat_select, pn.pane.Markdown(FOOTER_MESSAGE), ] def _populate_main(self): self._tap_x = Tap() self._station_pane = pn.pane.HoloViews( sizing_mode="stretch_both", min_width=800, min_height=350, ) self._day_pane = pn.pane.HoloViews( sizing_mode="stretch_both", min_width=800, min_height=350, ) self._update_stations() self._update_var_station_dependents() self._day_pane.object = pn.bind( self._update_day_plot, self.param.var, self.param.station, self.param.year, self.param.year_range, self._tap_x.param.x, ) pointer_vline = hv.DynamicMap(self._update_vline, streams=[self._tap_x]) oni_plot = hv.DynamicMap( self._update_oni_plot ) station_plot = hv.DynamicMap( pn.bind( self._update_station_plot, self.param.var, self.param.station, self.param.year, self.param.year_range, self.param.stat, ), ) self._station_pane.object = ( (oni_plot * station_plot * pointer_vline) .opts( xlabel="Time of Year", gridstyle={"ygrid_line_alpha": 0}, xticks=XTICKS, show_grid=True, padding=(0, (0, 0.45)), responsive=True, shared_axes=False, legend_position="top_right" ) .apply.opts(title=self.param._title, ylabel=self.param._ylabel) ) self._update_ylabel() self._update_title() self._main.objects = [self._station_pane, self._day_pane] def _populate_modal(self): network_points = self._network_df.hvplot.points( "lon", "lat", legend=False, cmap="category10", color="iem_network", hover_cols=["stid", "station_name", "iem_network"], size=10, geo=True, responsive=True, xlabel="Longitude", ylabel="Latitude", ).opts( "Points", fill_alpha=0, 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) instructions = pn.pane.Markdown( "#### The nearest station will be selected when you click on the map." ) network_pane = pn.pane.HoloViews( network_points * gv.tile_sources.CartoDark(), ) self._modal.objects = [instructions, network_pane] def _open_modal(self, event): self._template.open_modal() @pn.cache def _get_oni_df(self): df = pd.read_csv(ONI_URL) df["month"] = df["season"].map(SEASON_TO_MONTH) df["julian_day"] = df["month"].map(MONTH_TO_JULIAN_DAY) df["julian_day_end"] = df["julian_day"].shift(-1).fillna(365) df["oni"] = df["oni"].str.replace("_", " ").str.title() return df @pn.cache def _get_network_df(self): network_df = pd.read_csv(NETWORKS_URL) return network_df.loc[network_df["iem_network"].str.contains("ASOS")] @pn.depends("network", watch=True) def _update_stations(self): self._template.close_modal() network_df_subset = self._network_df.loc[ self._network_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._network_df["lon"].values, self._network_df["lat"].values, x, y ) min_distance_index = np.argmin(distances) closest_row = self._network_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._network_df["station_name"].unique(): station = self._network_df.loc[ self._network_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._main.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() 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._main.loading = False def _update_vline(self, x, y): if x is None: x = 0 if y is None: y = 0 vline = hv.VLine(x).opts(line_width=0.9, color="lightgrey") text = hv.Text( x, y, f"Julian Day {int(x)}", ).opts( text_color="lightgrey", text_align="left", text_baseline="bottom", text_alpha=0.8, ) return vline * text @param.depends("year", watch=True) def _update_oni_plot(self): df = self._oni_df df_year = df.loc[df["year"] == self.year] try: df_year.iloc[-1, -1] = 365 except: pass overlay = hv.Overlay([]) for oni in df_year["oni"].unique(): df_subset = df_year.loc[df_year["oni"] == oni, ["julian_day", "julian_day_end"]] overlay *= hv.VSpans( df_subset, ["julian_day", "julian_day_end"], label=oni, ).opts(color=ONI_COLORS[oni], alpha=0.18, line_alpha=0) return overlay def _update_day_plot(self, var, station, year, year_range, x): df = self._station_df if not x: x = 1 x = int(x) df_day_year = df.query(f"year == {year}") if x > df_day_year["dayofyear"].max(): x = df_day_year["dayofyear"].max() day_year = df_day_year.loc[df_day_year["dayofyear"] == x, self.var].iloc[0] df_subset = df.loc[df["year"].between(*year_range)] df_day_climo = df_subset.loc[df_subset["dayofyear"] == x] df_day_climo = df_day_climo.assign( above_or_below=df_day_climo[self.var] >= day_year ) title = ( f"{VAR_OPTIONS_R[self.var]} across {year_range[0]}-{year_range[1]} on " + df_day_climo.index.strftime("%B %d")[0] + f" (Julian Day {x}) " ) days_above = df_day_climo.loc[df_day_climo["above_or_below"] == True].shape[0] days_below = df_day_climo.loc[df_day_climo["above_or_below"] == False].shape[0] min_x = df[self.var].min() plot = hv.Overlay([]) plot *= df_day_climo.hvplot.hist( self.var, responsive=True, by="above_or_below", bins=11, legend=False, color=hv.Cycle([DARK_BLUE, DARK_RED]), ).opts("Histogram", fill_alpha=0.7, line_alpha=0) plot *= hv.VLine(day_year).opts(line_width=0.9, color="lightgrey") plot *= hv.Text( day_year, 0.1, f"{year}", ).opts( text_color="lightgrey", text_align="left", text_baseline="bottom", text_alpha=0.8, ) plot *= self._create_text_days_labels( df, days_above, days_below, text_x=min_x + 5, text_y=4, spacing=1, suffix=f"YEARS", ) return plot.opts( xlabel=VAR_OPTIONS_R[self.var], ylabel="Number of Days", title=title, shared_axes=False, show_grid=True, gridstyle={"xgrid_line_alpha": 0}, xlim=(min_x, df[self.var].max()), ) def _update_station_plot(self, var, station, year, year_range, stat): if len(self._station_df) == 0: return # base dataframes df = self._station_df df_subset = df.loc[df["year"].between(*year_range)] df_avg = df_subset.groupby("dayofyear").mean() df_year = df[df.year == year] # above/below df_year = df_year[["dayofyear", self.var]].merge( df_avg.reset_index()[["dayofyear", self.var]], on="dayofyear", suffixes=("", "_avg"), ) df_year["above_or_below"] = df_year[self.var] >= df_year[f"{self.var}_avg"] days_above = df_year.loc[df_year["above_or_below"] == True].shape[0] days_below = df_year.loc[df_year["above_or_below"] == False].shape[0] # stats if stat == "Mean": year_avg = df_year[self.var].mean() else: year_avg = df_year[self.var].median() year_max = df_year[self.var].max() year_min = df_year[self.var].min() plots = self._create_line_plots(df, df_year, df_avg) lines = self._create_hlines(year_avg, year_max, year_min) texts = self._create_text_labels(year_avg, year_max, year_min) text_days = self._create_text_days_labels(df, days_above, days_below) # Overlay all elements station_overlay = plots * lines * texts * text_days return station_overlay @pn.depends("var", watch=True) def _update_ylabel(self): self._ylabel = VAR_OPTIONS_R[self.var] @pn.depends("station", "year", "year_range", watch=True) def _update_title(self): df = self._station_df df_subset = df.loc[df["year"].between(*self.year_range)] # hack to get the title and ylabel to update year_min = df_subset["year"].min() if self.year_range[0] > year_min: year_min = self.year_range[0] year_max = df_subset["year"].max() if self.year_range[1] < year_max: year_max = self.year_range[1] year_range_label = f"{year_min}-{year_max}" self._title = f"{self._get_station_label()} - {self.year} vs Average ({year_range_label})" def _create_line_plots(self, df, df_year, df_avg): plot_kwargs = { "x": "dayofyear", "y": self.var, "legend": False, "responsive": True, } plot = df.hvplot( by="year", color="grey", alpha=0.02, hover=False, **plot_kwargs, ) df_above = df_year.copy() df_above.loc[df_above["above_or_below"]] = np.nan df_below = df_year.copy() df_below.loc[~df_below["above_or_below"]] = np.nan plot_year = df_year.hvplot( color="lightgrey", hover="vline", alpha=0.5, **plot_kwargs ).redim.label(**{"dayofyear": "Julian Day", self.var: str(self.year)}) plot_above = df_above.hvplot( hover="vline", color=DARK_BLUE, **plot_kwargs ).redim.label(**{"dayofyear": "Julian Day", self.var: str(self.year)}) plot_below = df_below.hvplot( hover="vline", color=DARK_RED, **plot_kwargs ).redim.label(**{"dayofyear": "Julian Day", self.var: str(self.year)}) plot_avg = df_avg.hvplot( color="lightgrey", hover="vline", **plot_kwargs ).redim.label(**{"dayofyear": "Julian Day", self.var: "Average"}) return plot * plot_year * plot_above * plot_below * plot_avg def _create_hlines(self, year_avg, year_max, year_min): # Create horizontal lines plot_year_avg = hv.HLine(year_avg).opts( line_color="lightgrey", line_dash="dashed", line_width=0.5 ) plot_year_max = hv.HLine(year_max).opts( line_color=DARK_RED, line_dash="dashed", line_width=0.5 ) plot_year_min = hv.HLine(year_min).opts( line_color=DARK_BLUE, line_dash="dashed", line_width=0.5 ) return plot_year_avg * plot_year_max * plot_year_min def _create_text_labels(self, year_avg, year_max, year_min): 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, year_avg + 3, f"{text_year_label} {year_avg:.1f}", fontsize=8 ).opts( text_color="lightgrey", **text_year_opts, ) text_year_max = hv.Text( 360, year_max + 3, f"MAX {year_max:.1f}", fontsize=8 ).opts( text_color=DARK_RED, **text_year_opts, ) text_year_min = hv.Text( 360, year_min + 3, f"MIN {year_min:.1f}", fontsize=8 ).opts( text_color=DARK_BLUE, **text_year_opts, ) return text_year_avg * text_year_max * text_year_min def _create_areas(self, df_above, df_below): area_kwargs = { "x": "dayofyear", "y": f"{self.var}_avg", "y2": self.var, "hover": False, "responsive": True, } area_opts = {"fill_alpha": 0.2, "line_alpha": 0.8} plot_above = df_above.hvplot.area(**area_kwargs).opts( line_color=DARK_RED, fill_color=DARK_RED, **area_opts ) plot_below = df_below.hvplot.area(**area_kwargs).opts( line_color=DARK_BLUE, fill_color=DARK_BLUE, **area_opts ) return plot_above * plot_below def _create_text_days_labels( self, df, days_above, days_below, text_x=None, text_y=None, spacing=None, suffix=None, ): text_x = text_x or 30 text_y = text_y or df[self.var].max() + 3 spacing = spacing or 2 suffix = suffix or "DAYS" 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 + spacing, text_y, f"{suffix} ABOVE", fontsize=7 ).opts( text_align="left", text_baseline="bottom", text_color="lightgrey", text_alpha=0.8, ) text_below = hv.Text( text_x + spacing, text_y, f"{suffix} BELOW", fontsize=7 ).opts( text_align="left", text_baseline="top", text_color="lightgrey", text_alpha=0.8, ) return text_days_above * text_days_below * text_above * text_below def _get_station_label(self): if self.station not in self._network_df["station_name"].unique(): stid = self.station station_name = self._network_df.loc[ self._network_df["stid"] == self.station, "station_name" ].iloc[0] else: stid = self._network_df.loc[ self._network_df["station_name"] == self.station, "stid" ].iloc[0] station_name = self.station station_label = f"{station_name.title()} ({stid})" return station_label def __panel__(self): return self._template ClimateApp().servable()