ahuang11 commited on
Commit
b0cc0a1
1 Parent(s): de3491e

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +474 -178
app.py CHANGED
@@ -1,186 +1,482 @@
1
- import re
2
- import os
3
  import panel as pn
4
- from io import StringIO
5
- from panel.io.mime_render import exec_with_return
6
- from llama_index import (
7
- VectorStoreIndex,
8
- SimpleDirectoryReader,
9
- ServiceContext,
10
- StorageContext,
11
- load_index_from_storage,
12
- )
13
- from llama_index.chat_engine import ContextChatEngine
14
- from llama_index.embeddings import OpenAIEmbedding
15
- from llama_index.llms import OpenAI
16
-
17
-
18
- SYSTEM_PROMPT = (
19
- "You are a data visualization pro and expert in HoloViz hvplot + holoviews. "
20
- "Your primary goal is to assist the user in editing based on user requests using best practices. "
21
- "Simply provide code in code fences (```python). You must have `hvplot_obj` as the last line of code. "
22
- "Note, data columns are ['sepal_length', 'sepal_width', 'petal_length', 'petal_width', 'species'] and "
23
- "hvplot is built on top of holoviews--anything you can do with holoviews, you can do "
24
- "with hvplot. First try to use hvplot **kwargs instead of opts, e.g. `legend='top_right'` "
25
- "instead of `opts(legend_position='top_right')`. If you need to use opts, you can use "
26
- "concise version, e.g. `opts(xlabel='Petal Length')` vs `opts(hv.Opts(xlabel='Petal Length'))`"
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
 
29
- USER_CONTENT_FORMAT = """
30
- Request:
31
- {content}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
 
33
- Code:
34
- ```python
35
- {code}
36
- ```
37
- """.strip()
38
 
39
- DEFAULT_HVPLOT = """
40
- import hvplot.pandas
41
- from bokeh.sampledata.iris import flowers
42
-
43
- hvplot_obj = flowers.hvplot(x='petal_length', y='petal_width', by='species', kind='scatter')
44
- hvplot_obj
45
- """.strip()
46
-
47
-
48
- def exception_handler(exc):
49
- if retries.value == 0:
50
- chat_interface.send(f"Can't figure this out: {exc}", respond=False)
51
- return
52
- chat_interface.send(f"Fix this error:\n```python\n{exc}\n```")
53
- retries.value = retries.value - 1
54
-
55
-
56
- def init_llm(event):
57
- api_key = event.new
58
- if not api_key:
59
- api_key = os.environ.get("OPENAI_API_KEY")
60
- if not api_key:
61
- return
62
- pn.state.cache["llm"] = OpenAI(api_key=api_key)
63
-
64
-
65
- def create_chat_engine(llm):
66
- try:
67
- storage_context = StorageContext.from_defaults(persist_dir="persisted/")
68
- index = load_index_from_storage(storage_context=storage_context)
69
- except Exception as exc:
70
- embed_model = OpenAIEmbedding()
71
- service_context = ServiceContext.from_defaults(llm=llm, embed_model=embed_model)
72
- documents = SimpleDirectoryReader(
73
- input_dir="hvplot_docs", required_exts=[".md"], recursive=True
74
- ).load_data()
75
- index = VectorStoreIndex.from_documents(
76
- documents, service_context=service_context, show_progress=True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  )
78
- index.storage_context.persist("persisted/")
79
-
80
- retriever = index.as_retriever()
81
- chat_engine = ContextChatEngine.from_defaults(
82
- system_prompt=SYSTEM_PROMPT,
83
- retriever=retriever,
84
- verbose=True,
85
- )
86
- return chat_engine
87
-
88
-
89
- def callback(content: str, user: str, instance: pn.chat.ChatInterface):
90
- if "llm" not in pn.state.cache:
91
- yield "Need to set OpenAI API key first"
92
- return
93
-
94
- if "engine" not in pn.state.cache:
95
- engine = pn.state.cache["engine"] = create_chat_engine(pn.state.cache["llm"])
96
- else:
97
- engine = pn.state.cache["engine"]
98
-
99
- # new user contents
100
- user_content = USER_CONTENT_FORMAT.format(
101
- content=content, code=code_editor.value
102
- )
103
-
104
- # send user content to chat engine
105
- agent_response = engine.stream_chat(user_content)
106
-
107
- message = None
108
- for chunk in agent_response.response_gen:
109
- message = instance.stream(chunk, message=message, user="OpenAI")
110
-
111
- # extract code
112
- llm_matches = re.findall(r"```python\n(.*)\n```", message.object, re.DOTALL)
113
- if llm_matches:
114
- llm_code = llm_matches[0]
115
- if llm_code.splitlines()[-1].strip() != "hvplot_obj":
116
- llm_code += "\nhvplot_obj"
117
- code_editor.value = llm_code
118
- retries.value = 2
119
-
120
-
121
- def update_plot(event):
122
- with StringIO() as buf:
123
- hvplot_pane.object = exec_with_return(event.new, stderr=buf)
124
- buf.seek(0)
125
- errors = buf.read()
126
- if errors:
127
- exception_handler(errors)
128
-
129
- pn.extension("codeeditor", sizing_mode="stretch_width", exception_handler=exception_handler)
130
-
131
-
132
- # instantiate widgets and panes
133
- api_key_input = pn.widgets.PasswordInput(
134
- placeholder=(
135
- "Currently subsidized by Andrew, "
136
- "but you can also pass your own OpenAI API Key"
137
- )
138
- )
139
- chat_interface = pn.chat.ChatInterface(
140
- callback=callback,
141
- show_clear=False,
142
- show_undo=False,
143
- show_button_name=False,
144
- message_params=dict(
145
- show_reaction_icons=False,
146
- show_copy_icon=False,
147
- ),
148
- height=650,
149
- callback_exception="verbose",
150
- )
151
- hvplot_pane = pn.pane.HoloViews(
152
- exec_with_return(DEFAULT_HVPLOT),
153
- sizing_mode="stretch_both",
154
- )
155
- code_editor = pn.widgets.CodeEditor(
156
- value=DEFAULT_HVPLOT,
157
- language="python",
158
- sizing_mode="stretch_both",
159
- )
160
- retries = pn.widgets.IntInput(value=2, visible=False)
161
- error = pn.widgets.StaticText(visible=False)
162
-
163
- # watch for code changes
164
- api_key_input.param.watch(init_llm, "value")
165
- code_editor.param.watch(update_plot, "value")
166
- api_key_input.param.trigger("value")
167
-
168
- # lay them out
169
- tabs = pn.Tabs(
170
- ("Plot", hvplot_pane),
171
- ("Code", code_editor),
172
- )
173
 
174
- sidebar = [api_key_input, chat_interface]
175
- main = [tabs]
176
- template = pn.template.FastListTemplate(
177
- sidebar=sidebar,
178
- main=main,
179
- sidebar_width=600,
180
- main_layout=None,
181
- accent_base_color="#fd7000",
182
- header_background="#fd7000",
183
- title="Chat with Plot"
184
- )
185
 
186
- template.servable()
 
1
+ import param
 
2
  import panel as pn
3
+ import numpy as np
4
+ import pandas as pd
5
+ import hvplot.pandas
6
+ import geoviews as gv
7
+ import holoviews as hv
8
+ from holoviews.streams import Tap
9
+ from bokeh.themes import Theme
10
+
11
+ VAR_OPTIONS = {
12
+ "Maximum Air Temperature [F]": "max_temp_f",
13
+ "Minimum Air Temperature [F]": "min_temp_f",
14
+ "Maximum Dew Point [F]": "max_dewpoint_f",
15
+ "Minimum Dew Point [F]": "min_dewpoint_f",
16
+ "Daily Precipitation [inch]": "precip_in",
17
+ "Average Wind Speed [knots]": "avg_wind_speed_kts",
18
+ "Average Wind Direction [deg]": "avg_wind_drct",
19
+ "Minimum Relative Humidity [%]": "min_rh",
20
+ "Average Relative Humidity [%]": "avg_rh",
21
+ "Maximum Relative Humidity [%]": "max_rh",
22
+ "NCEI 1991-2020 Daily High Temperature Climatology [F]": "climo_high_f",
23
+ "NCEI 1991-2020 Daily Low Temperature Climatology [F]": "climo_low_f",
24
+ "NCEI 1991-2020 Daily Precipitation Climatology [inch]": "climo_precip_in",
25
+ "Reported Snowfall [inch]": "snow_in",
26
+ "Reported Snow Depth [inch]": "snowd_in",
27
+ "Minimum 'Feels Like' Temperature [F]": "min_feel",
28
+ "Average 'Feels Like' Temperature [F]": "avg_feel",
29
+ "Maximum 'Feels Like' Temperature [F]": "max_feel",
30
+ "Maximum sustained wind speed [knots]": "max_wind_speed_kts",
31
+ "Maximum wind gust [knots]": "max_wind_gust_kts",
32
+ "Daily Solar Radiation MJ/m2": "srad_mj",
33
+ }
34
+ VAR_OPTIONS_R = {v: k for k, v in VAR_OPTIONS.items()}
35
+ NETWORKS_URL = "https://mesonet.agron.iastate.edu/sites/networks.php?network=_ALL_&format=csv&nohtml=on"
36
+ STATION_URL_FMT = (
37
+ "https://mesonet.agron.iastate.edu/cgi-bin/request/daily.py?network={network}&stations={station}"
38
+ "&year1=1928&month1=1&day1=1&year2=2023&month2=12&day2=31&var={var}&na=blank&format=csv"
39
  )
40
+ DARK_RED = "#FF5555"
41
+ DARK_BLUE = "#5588FF"
42
+ XTICKS = [
43
+ (1, "JAN"),
44
+ (31, "FEB"),
45
+ (59, "MAR"),
46
+ (90, "APR"),
47
+ (120, "MAY"),
48
+ (151, "JUN"),
49
+ (181, "JUL"),
50
+ (212, "AUG"),
51
+ (243, "SEP"),
52
+ (273, "OCT"),
53
+ (304, "NOV"),
54
+ (334, "DEC"),
55
+ ]
56
 
57
+ THEME_JSON = {
58
+ "attrs": {
59
+ "figure": {
60
+ "background_fill_color": "#1b1e23",
61
+ "border_fill_color": "#1b1e23",
62
+ "outline_line_alpha": 0,
63
+ },
64
+ "Grid": {
65
+ "grid_line_color": "#808080",
66
+ "grid_line_alpha": 0.1,
67
+ },
68
+ "Axis": {
69
+ # tick color and alpha
70
+ "major_tick_line_color": "#4d4f51",
71
+ "minor_tick_line_alpha": 0,
72
+ # tick labels
73
+ "major_label_text_font": "Courier New",
74
+ "major_label_text_color": "#808080",
75
+ "major_label_text_align": "left",
76
+ "major_label_text_font_size": "0.95em",
77
+ "major_label_text_font_style": "normal",
78
+ # axis labels
79
+ "axis_label_text_font": "Courier New",
80
+ "axis_label_text_font_style": "normal",
81
+ "axis_label_text_font_size": "1.15em",
82
+ "axis_label_text_color": "lightgrey",
83
+ "axis_line_color": "#4d4f51",
84
+ },
85
+ "Legend": {
86
+ "spacing": 8,
87
+ "glyph_width": 15,
88
+ "label_standoff": 8,
89
+ "label_text_color": "#808080",
90
+ "label_text_font": "Courier New",
91
+ "label_text_font_size": "0.95em",
92
+ "label_text_font_style": "bold",
93
+ "border_line_alpha": 0,
94
+ "background_fill_alpha": 0.25,
95
+ "background_fill_color": "#1b1e23",
96
+ },
97
+ "BaseColorBar": {
98
+ # axis labels
99
+ "title_text_color": "lightgrey",
100
+ "title_text_font": "Courier New",
101
+ "title_text_font_size": "0.95em",
102
+ "title_text_font_style": "normal",
103
+ # tick labels
104
+ "major_label_text_color": "#808080",
105
+ "major_label_text_font": "Courier New",
106
+ "major_label_text_font_size": "0.95em",
107
+ "major_label_text_font_style": "normal",
108
+ "background_fill_color": "#1b1e23",
109
+ "major_tick_line_alpha": 0,
110
+ "bar_line_alpha": 0,
111
+ },
112
+ "Title": {
113
+ "text_font": "Courier New",
114
+ "text_font_style": "normal",
115
+ "text_color": "lightgrey",
116
+ },
117
+ }
118
+ }
119
+ theme = Theme(json=THEME_JSON)
120
 
121
+ hv.renderer("bokeh").theme = theme
122
+ pn.extension(throttled=True)
 
 
 
123
 
124
+ class ClimateApp(pn.viewable.Viewer):
125
+ network = param.Selector(default="WA_ASOS")
126
+ station = param.Selector(default="SEA")
127
+ year = param.Integer(default=2023, bounds=(1928, 2023))
128
+ year_range = param.Range(default=(1990, 2020), bounds=(1928, 2023))
129
+ var = param.Selector(default="max_temp_f", objects=sorted(VAR_OPTIONS.values()))
130
+ stat = param.Selector(default="Mean", objects=["Mean", "Median"])
131
+
132
+ def __init__(self, **params):
133
+ super().__init__(**params)
134
+ pn.state.onload(self._onload)
135
+
136
+ def _onload(self):
137
+ self._networks_df = self._get_networks_df()
138
+ networks = sorted(self._networks_df["iem_network"].unique())
139
+ self.param["network"].objects = networks
140
+
141
+ network_select = pn.widgets.AutocompleteInput.from_param(
142
+ self.param.network, min_characters=0, case_sensitive=False
143
+ )
144
+ station_select = pn.widgets.AutocompleteInput.from_param(
145
+ self.param.station, min_characters=0, case_sensitive=False
146
+ )
147
+ var_select = pn.widgets.Select.from_param(self.param.var, options=VAR_OPTIONS)
148
+ year_slider = pn.widgets.IntSlider.from_param(self.param.year)
149
+ year_range_slider = pn.widgets.RangeSlider.from_param(self.param.year_range)
150
+ stat_select = pn.widgets.RadioButtonGroup.from_param(self.param.stat, sizing_mode="stretch_width")
151
+ self._sidebar = [
152
+ network_select,
153
+ station_select,
154
+ var_select,
155
+ year_slider,
156
+ year_range_slider,
157
+ stat_select,
158
+ ]
159
+
160
+ network_points = self._networks_df.hvplot.points(
161
+ "lon",
162
+ "lat",
163
+ legend=False,
164
+ cmap="category10",
165
+ color="iem_network",
166
+ hover_cols=["stid", "station_name", "iem_network"],
167
+ size=10,
168
+ geo=True,
169
+ ).opts(
170
+ "Points",
171
+ fill_alpha=0,
172
+ responsive=True,
173
+ tools=["tap", "hover"],
174
+ active_tools=["wheel_zoom"],
175
+ )
176
+
177
+ tap = Tap(source=network_points)
178
+ pn.bind(self._update_station, x=tap.param.x, y=tap.param.y, watch=True)
179
+ network_pane = pn.pane.HoloViews(
180
+ network_points * gv.tile_sources.CartoDark(),
181
+ sizing_mode="stretch_both",
182
+ max_height=625,
183
+ )
184
+ self._station_pane = pn.pane.HoloViews(sizing_mode="stretch_width", height=450)
185
+ main_tabs = pn.Tabs(
186
+ ("Climatology Plot", self._station_pane), ("Map Select", network_pane)
187
+ )
188
+ self._main = [self._station_pane]
189
+
190
+ self._update_var_station_dependents()
191
+ self._update_stations()
192
+ self._update_station_pane()
193
+
194
+ @pn.cache
195
+ def _get_networks_df(self):
196
+ networks_df = pd.read_csv(NETWORKS_URL)
197
+ return networks_df
198
+
199
+ @pn.depends("network", watch=True)
200
+ def _update_stations(self):
201
+ network_df_subset = self._networks_df.loc[
202
+ self._networks_df["iem_network"] == self.network,
203
+ ["stid", "station_name"],
204
+ ]
205
+ names = sorted(network_df_subset["station_name"].unique())
206
+ stids = sorted(network_df_subset["stid"].unique())
207
+ self.param["station"].objects = names + stids
208
+
209
+ def _update_station(self, x, y):
210
+ if x is None or y is None:
211
+ return
212
+
213
+ def haversine_vectorized(lon1, lat1, lon2, lat2):
214
+ R = 6371 # Radius of the Earth in kilometers
215
+ dlat = np.radians(lat2 - lat1)
216
+ dlon = np.radians(lon2 - lon1)
217
+ a = (
218
+ np.sin(dlat / 2.0) ** 2
219
+ + np.cos(np.radians(lat1))
220
+ * np.cos(np.radians(lat2))
221
+ * np.sin(dlon / 2.0) ** 2
222
+ )
223
+ c = 2 * np.arctan2(np.sqrt(a), np.sqrt(1 - a))
224
+ return R * c
225
+
226
+ distances = haversine_vectorized(
227
+ self._networks_df["lon"].values, self._networks_df["lat"].values, x, y
228
+ )
229
+
230
+ min_distance_index = np.argmin(distances)
231
+
232
+ closest_row = self._networks_df.iloc[min_distance_index]
233
+ with param.parameterized.batch_call_watchers(self):
234
+ self.network = closest_row["iem_network"]
235
+ self.station = closest_row["stid"]
236
+
237
+ @pn.cache
238
+ def _get_station_df(self, station, var):
239
+ if station in self._networks_df["station_name"].unique():
240
+ station = self._networks_df.loc[
241
+ self._networks_df["station_name"] == station, "stid"
242
+ ].iloc[0]
243
+ if station.startswith("K"):
244
+ station = station.lstrip("K")
245
+ station_url = STATION_URL_FMT.format(
246
+ network=self.network, station=station, var=var
247
+ )
248
+ station_df = (
249
+ pd.read_csv(
250
+ station_url,
251
+ parse_dates=True,
252
+ index_col="day",
253
+ )
254
+ .drop(columns=["station"])
255
+ .astype("float16")
256
+ .assign(
257
+ dayofyear=lambda df: df.index.dayofyear,
258
+ year=lambda df: df.index.year,
259
+ )
260
+ .dropna()
261
+ )
262
+ return station_df
263
+
264
+ @pn.depends("var", "station", watch=True)
265
+ def _update_var_station_dependents(self):
266
+ try:
267
+ self._station_pane.loading = True
268
+ self._station_df = self._get_station_df(self.station, self.var).dropna()
269
+ if len(self._station_df) == 0:
270
+ return
271
+
272
+ year_range_min = self._station_df["year"].min()
273
+ year_range_max = self._station_df["year"].max()
274
+ if self.year_range[0] < year_range_min:
275
+ self.year_range = (year_range_min, self.year_range[1])
276
+ if self.year_range[1] > year_range_max:
277
+ self.year_range = (self.year_range[0], year_range_max)
278
+
279
+ self.param["year_range"].bounds = (year_range_min, year_range_max)
280
+
281
+ self.param["year"].bounds = (year_range_min, year_range_max)
282
+ if self.year < year_range_min:
283
+ self.year = year_range_min
284
+ if self.year > year_range_max:
285
+ self.year = year_range_max
286
+ finally:
287
+ self._station_pane.loading = False
288
+
289
+ @pn.depends("var", "station", "year", "year_range", "stat", watch=True)
290
+ def _update_station_pane(self):
291
+ if len(self._station_df) == 0:
292
+ return
293
+
294
+ try:
295
+ self._station_pane.loading = True
296
+ df = self._station_df
297
+ if self.station not in self._networks_df["station_name"].unique():
298
+ station_name = self._networks_df.loc[
299
+ self._networks_df["stid"] == self.station, "station_name"
300
+ ].iloc[0]
301
+ else:
302
+ station_name = self.station
303
+
304
+ # get average and year
305
+ df_avg = (
306
+ df.loc[df["year"].between(*self.year_range)].groupby("dayofyear").mean()
307
+ )
308
+ df_year = df[df.year == self.year]
309
+ if self.stat == "Mean":
310
+ df_year_avg = df_year[self.var].mean()
311
+ else:
312
+ df_year_avg = df_year[self.var].median()
313
+ df_year_max = df_year[self.var].max()
314
+ df_year_min = df_year[self.var].min()
315
+
316
+ # preprocess below/above
317
+ df_above = df_year[["dayofyear", self.var]].merge(
318
+ df_avg.reset_index()[["dayofyear", self.var]],
319
+ on="dayofyear",
320
+ suffixes=("_year", "_avg"),
321
+ )
322
+ df_above[self.var] = df_above[f"{self.var}_avg"]
323
+ df_above[self.var] = df_above.loc[
324
+ df_above[f"{self.var}_year"] >= df_above[f"{self.var}_avg"],
325
+ f"{self.var}_year",
326
+ ]
327
+
328
+ df_below = df_year[["dayofyear", self.var]].merge(
329
+ df_avg.reset_index()[["dayofyear", self.var]],
330
+ on="dayofyear",
331
+ suffixes=("_year", "_avg"),
332
+ )
333
+ df_below[self.var] = df_below[f"{self.var}_avg"]
334
+ df_below[self.var] = df_below.loc[
335
+ df_below[f"{self.var}_year"] < df_below[f"{self.var}_avg"],
336
+ f"{self.var}_year",
337
+ ]
338
+
339
+ days_above = df_above.loc[
340
+ df_above[f"{self.var}_year"] >= df_above[f"{self.var}_avg"]
341
+ ].shape[0]
342
+ days_below = df_below.loc[
343
+ df_below[f"{self.var}_year"] < df_below[f"{self.var}_avg"]
344
+ ].shape[0]
345
+
346
+ # create plot elements
347
+ plot_kwargs = {
348
+ "x": "dayofyear",
349
+ "y": self.var,
350
+ "responsive": True,
351
+ "legend": False,
352
+ }
353
+ plot = df.hvplot(
354
+ by="year",
355
+ color="grey",
356
+ alpha=0.02,
357
+ hover=False,
358
+ **plot_kwargs,
359
+ )
360
+ plot_year = (
361
+ df_year.hvplot(color="black", hover="vline", **plot_kwargs)
362
+ .opts(alpha=0.2)
363
+ .redim.label(**{"dayofyear": "Julian Day", self.var: str(self.year)})
364
+ )
365
+ plot_avg = df_avg.hvplot(color="grey", **plot_kwargs).redim.label(
366
+ **{"dayofyear": "Julian Day", self.var: "Average"}
367
+ )
368
+
369
+ plot_year_avg = hv.HLine(df_year_avg).opts(
370
+ line_color="lightgrey", line_dash="dashed", line_width=0.5
371
+ )
372
+ plot_year_max = hv.HLine(df_year_max).opts(
373
+ line_color=DARK_RED, line_dash="dashed", line_width=0.5
374
+ )
375
+ plot_year_min = hv.HLine(df_year_min).opts(
376
+ line_color=DARK_BLUE, line_dash="dashed", line_width=0.5
377
+ )
378
+
379
+ text_year_opts = {
380
+ "text_align": "right",
381
+ "text_baseline": "bottom",
382
+ "text_alpha": 0.8,
383
+ }
384
+ text_year_label = "AVERAGE" if self.stat == "Mean" else "MEDIAN"
385
+ text_year_avg = hv.Text(
386
+ 360, df_year_avg + 3, f"{text_year_label} {df_year_avg:.0f}", fontsize=8
387
+ ).opts(
388
+ text_color="lightgrey",
389
+ **text_year_opts,
390
+ )
391
+ text_year_max = hv.Text(
392
+ 360, df_year_max + 3, f"MAX {df_year_max:.0f}", fontsize=8
393
+ ).opts(
394
+ text_color=DARK_RED,
395
+ **text_year_opts,
396
+ )
397
+ text_year_min = hv.Text(
398
+ 360, df_year_min + 3, f"MIN {df_year_min:.0f}", fontsize=8
399
+ ).opts(
400
+ text_color=DARK_BLUE,
401
+ **text_year_opts,
402
+ )
403
+
404
+ area_kwargs = {"fill_alpha": 0.2, "line_alpha": 0.8}
405
+ plot_above = df_above.hvplot.area(
406
+ x="dayofyear", y=f"{self.var}_avg", y2=self.var, hover=False
407
+ ).opts(line_color=DARK_RED, fill_color=DARK_RED, **area_kwargs)
408
+ plot_below = df_below.hvplot.area(
409
+ x="dayofyear", y=f"{self.var}_avg", y2=self.var, hover=False
410
+ ).opts(line_color=DARK_BLUE, fill_color=DARK_BLUE, **area_kwargs)
411
+
412
+ text_x = 25
413
+ text_y = df_year[self.var].max() + 10
414
+ text_days_above = hv.Text(text_x, text_y, f"{days_above}", fontsize=14).opts(
415
+ text_align="right",
416
+ text_baseline="bottom",
417
+ text_color=DARK_RED,
418
+ text_alpha=0.8,
419
+ )
420
+ text_days_below = hv.Text(text_x, text_y, f"{days_below}", fontsize=14).opts(
421
+ text_align="right",
422
+ text_baseline="top",
423
+ text_color=DARK_BLUE,
424
+ text_alpha=0.8,
425
+ )
426
+ text_above = hv.Text(text_x + 3, text_y, "DAYS ABOVE", fontsize=7).opts(
427
+ text_align="left",
428
+ text_baseline="bottom",
429
+ text_color="lightgrey",
430
+ text_alpha=0.8,
431
+ )
432
+ text_below = hv.Text(text_x + 3, text_y, "DAYS BELOW", fontsize=7).opts(
433
+ text_align="left",
434
+ text_baseline="top",
435
+ text_color="lightgrey",
436
+ text_alpha=0.8,
437
+ )
438
+
439
+ # overlay everything and save
440
+ station_overlay = (
441
+ plot
442
+ * plot_year
443
+ * plot_avg
444
+ * plot_year_avg
445
+ * plot_year_max
446
+ * plot_year_min
447
+ * text_year_avg
448
+ * text_year_max
449
+ * text_year_min
450
+ * plot_above
451
+ * plot_below
452
+ * text_days_above
453
+ * text_days_below
454
+ * text_above
455
+ * text_below
456
+ ).opts(
457
+ xlabel="TIME OF YEAR",
458
+ ylabel=VAR_OPTIONS_R[self.var],
459
+ title=f"{station_name} {self.year} vs AVERAGE ({self.year_range[0]}-{self.year_range[1]})",
460
+ gridstyle={"ygrid_line_alpha": 0},
461
+ xticks=XTICKS,
462
+ show_grid=True,
463
+ fontscale=1.18,
464
+ padding=(0, (0, 0.3))
465
+ )
466
+ self._station_pane.object = station_overlay
467
+ finally:
468
+ self._station_pane.loading = False
469
+
470
+ def __panel__(self):
471
+ return pn.template.FastListTemplate(
472
+ sidebar=self._sidebar,
473
+ main=self._main,
474
+ theme="dark",
475
+ theme_toggle=False,
476
+ main_layout=None,
477
+ title="Select Year vs Average Comparison",
478
+ accent="#2F4F4F",
479
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
480
 
 
 
 
 
 
 
 
 
 
 
 
481
 
482
+ ClimateApp().servable()