ahuang11 commited on
Commit
f1debc7
1 Parent(s): 2f21c6a

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +307 -115
app.py CHANGED
@@ -7,7 +7,7 @@ import pandas as pd
7
  import hvplot.pandas
8
  import geoviews as gv
9
  import holoviews as hv
10
- from holoviews.streams import Tap, PointerX
11
  from bokeh.themes import Theme
12
 
13
  pn.extension(throttled=True, notifications=True)
@@ -52,6 +52,7 @@ Data sourced from the [Iowa Environmental Mesonet](https://mesonet.agron.iastate
52
  """
53
 
54
  VAR_OPTIONS_R = {v: k for k, v in VAR_OPTIONS.items()}
 
55
  NETWORKS_URL = "https://mesonet.agron.iastate.edu/sites/networks.php?network=_ALL_&format=csv&nohtml=on"
56
  STATION_URL_FMT = (
57
  "https://mesonet.agron.iastate.edu/cgi-bin/request/daily.py?network={network}&stations={station}"
@@ -59,6 +60,20 @@ STATION_URL_FMT = (
59
  )
60
  DARK_RED = "#FF5555"
61
  DARK_BLUE = "#5588FF"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  XTICKS = [
63
  (1, "JAN"),
64
  (31, "FEB"),
@@ -73,6 +88,12 @@ XTICKS = [
73
  (304, "NOV"),
74
  (334, "DEC"),
75
  ]
 
 
 
 
 
 
76
 
77
  THEME_JSON = {
78
  "attrs": {
@@ -143,18 +164,23 @@ hv.renderer("bokeh").theme = theme
143
 
144
 
145
  class ClimateApp(pn.viewable.Viewer):
146
- network = param.Selector(default="WA_ASOS", label="Network (type to search)")
147
- station = param.Selector(default="SEA", label="Station (type to search)")
148
- year = param.Integer(default=this_year, bounds=(1928, this_year))
 
 
149
  year_range = param.Range(
150
  default=(1990, 2020), bounds=(1928, this_year), label="Average Range"
151
  )
152
  var = param.Selector(default="max_temp_f", objects=sorted(VAR_OPTIONS.values()))
153
  stat = param.Selector(default="Mean", objects=["Mean", "Median"])
154
 
 
 
 
155
  def __init__(self, **params):
156
  super().__init__(**params)
157
- self._sidebar = pn.Column(sizing_mode="stretch_both", loading=True)
158
  self._main = pn.Column(
159
  pn.indicators.LoadingSpinner(
160
  value=True, width=25, height=25, name="Loading, please wait a moment..."
@@ -162,8 +188,6 @@ class ClimateApp(pn.viewable.Viewer):
162
  sizing_mode="stretch_both",
163
  )
164
  self._modal = pn.Column(width=850, height=500, align="center")
165
- pn.state.onload(self._onload)
166
-
167
  self._template = pn.template.FastListTemplate(
168
  sidebar=[self._sidebar],
169
  main=[self._main],
@@ -171,12 +195,14 @@ class ClimateApp(pn.viewable.Viewer):
171
  theme="dark",
172
  theme_toggle=False,
173
  main_layout=None,
174
- title="Select Year vs Average Comparison",
175
  accent="grey",
176
  )
 
177
 
178
  def _onload(self):
179
  try:
 
180
  self._populate_sidebar()
181
  self._populate_main()
182
  self._populate_modal()
@@ -185,6 +211,7 @@ class ClimateApp(pn.viewable.Viewer):
185
 
186
  def _populate_sidebar(self):
187
  self._network_df = self._get_network_df()
 
188
  networks = sorted(self._network_df["iem_network"].unique())
189
  self.param["network"].objects = networks
190
 
@@ -207,7 +234,7 @@ class ClimateApp(pn.viewable.Viewer):
207
  self.param.stat, sizing_mode="stretch_width"
208
  )
209
  self._sidebar.objects = [
210
- WELCOME_MESSAGE,
211
  open_button,
212
  network_select,
213
  station_select,
@@ -215,17 +242,63 @@ class ClimateApp(pn.viewable.Viewer):
215
  year_slider,
216
  year_range_slider,
217
  stat_select,
218
- FOOTER_MESSAGE,
219
  ]
220
 
221
  def _populate_main(self):
 
222
  self._station_pane = pn.pane.HoloViews(
223
- sizing_mode="stretch_both", min_width=800, min_height=400
 
 
 
 
 
 
 
224
  )
225
  self._update_stations()
226
  self._update_var_station_dependents()
227
- self._update_station_pane()
228
- self._main.objects = [self._station_pane]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
229
 
230
  def _populate_modal(self):
231
  network_points = self._network_df.hvplot.points(
@@ -249,7 +322,9 @@ class ClimateApp(pn.viewable.Viewer):
249
 
250
  tap = Tap(source=network_points)
251
  pn.bind(self._update_station, x=tap.param.x, y=tap.param.y, watch=True)
252
- instructions = "#### The nearest station will be selected when you click on the map."
 
 
253
  network_pane = pn.pane.HoloViews(
254
  network_points * gv.tile_sources.CartoDark(),
255
  )
@@ -258,6 +333,15 @@ class ClimateApp(pn.viewable.Viewer):
258
  def _open_modal(self, event):
259
  self._template.open_modal()
260
 
 
 
 
 
 
 
 
 
 
261
  @pn.cache
262
  def _get_network_df(self):
263
  network_df = pd.read_csv(NETWORKS_URL)
@@ -265,6 +349,7 @@ class ClimateApp(pn.viewable.Viewer):
265
 
266
  @pn.depends("network", watch=True)
267
  def _update_stations(self):
 
268
  network_df_subset = self._network_df.loc[
269
  self._network_df["iem_network"] == self.network,
270
  ["stid", "station_name"],
@@ -272,7 +357,6 @@ class ClimateApp(pn.viewable.Viewer):
272
  names = sorted(network_df_subset["station_name"].unique())
273
  stids = sorted(network_df_subset["stid"].unique())
274
  self.param["station"].objects = names + stids
275
- self._template.close_modal()
276
 
277
  def _update_station(self, x, y):
278
  if x is None or y is None:
@@ -332,101 +416,181 @@ class ClimateApp(pn.viewable.Viewer):
332
  @pn.depends("var", "station", watch=True)
333
  def _update_var_station_dependents(self):
334
  try:
335
- self._station_pane.loading = True
336
  self._station_df = self._get_station_df(self.station, self.var).dropna()
337
  if len(self._station_df) == 0:
338
  return
339
 
340
  year_range_min = self._station_df["year"].min()
341
  year_range_max = self._station_df["year"].max()
342
- if self.year_range[0] < year_range_min:
343
- self.year_range = (year_range_min, self.year_range[1])
344
- if self.year_range[1] > year_range_max:
345
- self.year_range = (self.year_range[0], year_range_max)
346
-
347
- self.param["year_range"].bounds = (year_range_min, year_range_max)
348
-
349
  self.param["year"].bounds = (year_range_min, year_range_max)
350
  if self.year < year_range_min:
351
  self.year = year_range_min
352
  if self.year > year_range_max:
353
  self.year = year_range_max
354
  finally:
355
- self._station_pane.loading = False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
356
 
357
- @pn.depends("var", "station", "year", "year_range", "stat", watch=True)
358
- def _update_station_pane(self):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
359
  if len(self._station_df) == 0:
360
  return
361
 
362
- try:
363
- self._station_pane.loading = True
364
-
365
- # base dataframes
366
- df = self._station_df
367
- df_avg = (
368
- df.loc[df["year"].between(*self.year_range)].groupby("dayofyear").mean()
369
- )
370
- df_year = df[df.year == self.year]
 
 
 
 
 
 
371
 
372
- # above/below
373
- df_above = df_year[["dayofyear", self.var]].merge(
374
- df_avg.reset_index()[["dayofyear", self.var]],
375
- on="dayofyear",
376
- suffixes=("_year", "_avg"),
377
- )
378
- df_above[self.var] = df_above[f"{self.var}_avg"]
379
- df_above[self.var] = df_above.loc[
380
- df_above[f"{self.var}_year"] >= df_above[f"{self.var}_avg"],
381
- f"{self.var}_year",
382
- ]
383
-
384
- df_below = df_year[["dayofyear", self.var]].merge(
385
- df_avg.reset_index()[["dayofyear", self.var]],
386
- on="dayofyear",
387
- suffixes=("_year", "_avg"),
388
- )
389
- df_below[self.var] = df_below[f"{self.var}_avg"]
390
- df_below[self.var] = df_below.loc[
391
- df_below[f"{self.var}_year"] < df_below[f"{self.var}_avg"],
392
- f"{self.var}_year",
393
- ]
394
-
395
- # stats
396
- if self.stat == "Mean":
397
- year_avg = df_year[self.var].mean()
398
- else:
399
- year_avg = df_year[self.var].median()
400
- year_max = df_year[self.var].max()
401
- year_min = df_year[self.var].min()
402
-
403
- plots = self._create_line_plots(df, df_year, df_avg)
404
- lines = self._create_hlines(year_avg, year_max, year_min)
405
- texts = self._create_text_labels(year_avg, year_max, year_min)
406
- areas = self._create_areas(df_above, df_below)
407
- text_days = self._create_text_days_labels(df, df_above, df_below)
408
-
409
- # Overlay all elements
410
- title = f"{self._get_station_name()} {self.year} vs AVERAGE ({self.year_range[0]}-{self.year_range[1]})"
411
- station_overlay = (plots * lines * texts * areas * text_days).opts(
412
- xlabel="TIME OF YEAR",
413
- ylabel=VAR_OPTIONS_R[self.var],
414
- title=title,
415
- gridstyle={"ygrid_line_alpha": 0},
416
- xticks=XTICKS,
417
- show_grid=True,
418
- fontscale=1.18,
419
- padding=(0, (0, 0.3)),
420
- )
421
- self._station_pane.object = station_overlay
422
- finally:
423
- self._station_pane.loading = False
424
 
425
  def _create_line_plots(self, df, df_year, df_avg):
426
  plot_kwargs = {
427
  "x": "dayofyear",
428
  "y": self.var,
429
  "legend": False,
 
430
  }
431
  plot = df.hvplot(
432
  by="year",
@@ -435,15 +599,23 @@ class ClimateApp(pn.viewable.Viewer):
435
  hover=False,
436
  **plot_kwargs,
437
  )
438
- plot_year = (
439
- df_year.hvplot(color="black", hover="vline", **plot_kwargs)
440
- .opts(alpha=0.2)
441
- .redim.label(**{"dayofyear": "Julian Day", self.var: str(self.year)})
442
- )
 
 
 
 
 
 
 
 
443
  plot_avg = df_avg.hvplot(
444
- color="grey", hover="vline", **plot_kwargs
445
  ).redim.label(**{"dayofyear": "Julian Day", self.var: "Average"})
446
- return plot * plot_year * plot_avg
447
 
448
  def _create_hlines(self, year_avg, year_max, year_min):
449
  # Create horizontal lines
@@ -486,25 +658,36 @@ class ClimateApp(pn.viewable.Viewer):
486
  return text_year_avg * text_year_max * text_year_min
487
 
488
  def _create_areas(self, df_above, df_below):
489
- area_kwargs = {"fill_alpha": 0.2, "line_alpha": 0.8}
490
- plot_above = df_above.hvplot.area(
491
- x="dayofyear", y=f"{self.var}_avg", y2=self.var, hover=False
492
- ).opts(line_color=DARK_RED, fill_color=DARK_RED, **area_kwargs)
493
- plot_below = df_below.hvplot.area(
494
- x="dayofyear", y=f"{self.var}_avg", y2=self.var, hover=False
495
- ).opts(line_color=DARK_BLUE, fill_color=DARK_BLUE, **area_kwargs)
 
 
 
 
 
 
 
496
  return plot_above * plot_below
497
 
498
- def _create_text_days_labels(self, df, df_above, df_below):
499
- days_above = df_above.loc[
500
- df_above[f"{self.var}_year"] >= df_above[f"{self.var}_avg"]
501
- ].shape[0]
502
- days_below = df_below.loc[
503
- df_below[f"{self.var}_year"] < df_below[f"{self.var}_avg"]
504
- ].shape[0]
505
-
506
- text_x = 30
507
- text_y = df[self.var].max() + 10
 
 
 
 
508
  text_days_above = hv.Text(text_x, text_y, f"{days_above}", fontsize=14).opts(
509
  text_align="right",
510
  text_baseline="bottom",
@@ -517,13 +700,17 @@ class ClimateApp(pn.viewable.Viewer):
517
  text_color=DARK_BLUE,
518
  text_alpha=0.8,
519
  )
520
- text_above = hv.Text(text_x + 3, text_y, "DAYS ABOVE", fontsize=7).opts(
 
 
521
  text_align="left",
522
  text_baseline="bottom",
523
  text_color="lightgrey",
524
  text_alpha=0.8,
525
  )
526
- text_below = hv.Text(text_x + 3, text_y, "DAYS BELOW", fontsize=7).opts(
 
 
527
  text_align="left",
528
  text_baseline="top",
529
  text_color="lightgrey",
@@ -532,14 +719,19 @@ class ClimateApp(pn.viewable.Viewer):
532
 
533
  return text_days_above * text_days_below * text_above * text_below
534
 
535
- def _get_station_name(self):
536
  if self.station not in self._network_df["station_name"].unique():
 
537
  station_name = self._network_df.loc[
538
  self._network_df["stid"] == self.station, "station_name"
539
  ].iloc[0]
540
  else:
 
 
 
541
  station_name = self.station
542
- return station_name
 
543
 
544
  def __panel__(self):
545
  return self._template
 
7
  import hvplot.pandas
8
  import geoviews as gv
9
  import holoviews as hv
10
+ from holoviews.streams import Tap
11
  from bokeh.themes import Theme
12
 
13
  pn.extension(throttled=True, notifications=True)
 
52
  """
53
 
54
  VAR_OPTIONS_R = {v: k for k, v in VAR_OPTIONS.items()}
55
+ ONI_URL = "https://raw.githubusercontent.com/ahuang11/oni/master/oni.csv"
56
  NETWORKS_URL = "https://mesonet.agron.iastate.edu/sites/networks.php?network=_ALL_&format=csv&nohtml=on"
57
  STATION_URL_FMT = (
58
  "https://mesonet.agron.iastate.edu/cgi-bin/request/daily.py?network={network}&stations={station}"
 
60
  )
61
  DARK_RED = "#FF5555"
62
  DARK_BLUE = "#5588FF"
63
+ SEASON_TO_MONTH = {
64
+ "DJF": "JAN",
65
+ "JFM": "FEB",
66
+ "FMA": "MAR",
67
+ "MAM": "APR",
68
+ "AMJ": "MAY",
69
+ "MJJ": "JUN",
70
+ "JJA": "JUL",
71
+ "JAS": "AUG",
72
+ "ASO": "SEP",
73
+ "SON": "OCT",
74
+ "OND": "NOV",
75
+ "NDJ": "DEC",
76
+ }
77
  XTICKS = [
78
  (1, "JAN"),
79
  (31, "FEB"),
 
88
  (304, "NOV"),
89
  (334, "DEC"),
90
  ]
91
+ MONTH_TO_JULIAN_DAY = {month: day for day, month in XTICKS}
92
+ ONI_COLORS = {
93
+ "El Nino": DARK_RED,
94
+ "Neutral": "grey",
95
+ "La Nina": DARK_BLUE,
96
+ }
97
 
98
  THEME_JSON = {
99
  "attrs": {
 
164
 
165
 
166
  class ClimateApp(pn.viewable.Viewer):
167
+ network = param.Selector(
168
+ default="WA_ASOS", label="Network (delete & type to search)"
169
+ )
170
+ station = param.Selector(default="SEA", label="Station (delete & type to search)")
171
+ year = param.Integer(default=this_year - 1, bounds=(1928, this_year))
172
  year_range = param.Range(
173
  default=(1990, 2020), bounds=(1928, this_year), label="Average Range"
174
  )
175
  var = param.Selector(default="max_temp_f", objects=sorted(VAR_OPTIONS.values()))
176
  stat = param.Selector(default="Mean", objects=["Mean", "Median"])
177
 
178
+ _title = param.String()
179
+ _ylabel = param.String()
180
+
181
  def __init__(self, **params):
182
  super().__init__(**params)
183
+ self._sidebar = pn.Column(sizing_mode="stretch_both")
184
  self._main = pn.Column(
185
  pn.indicators.LoadingSpinner(
186
  value=True, width=25, height=25, name="Loading, please wait a moment..."
 
188
  sizing_mode="stretch_both",
189
  )
190
  self._modal = pn.Column(width=850, height=500, align="center")
 
 
191
  self._template = pn.template.FastListTemplate(
192
  sidebar=[self._sidebar],
193
  main=[self._main],
 
195
  theme="dark",
196
  theme_toggle=False,
197
  main_layout=None,
198
+ title="Year vs Climatology",
199
  accent="grey",
200
  )
201
+ pn.state.onload(self._onload)
202
 
203
  def _onload(self):
204
  try:
205
+ self._sidebar.loading = True
206
  self._populate_sidebar()
207
  self._populate_main()
208
  self._populate_modal()
 
211
 
212
  def _populate_sidebar(self):
213
  self._network_df = self._get_network_df()
214
+ self._oni_df = self._get_oni_df()
215
  networks = sorted(self._network_df["iem_network"].unique())
216
  self.param["network"].objects = networks
217
 
 
234
  self.param.stat, sizing_mode="stretch_width"
235
  )
236
  self._sidebar.objects = [
237
+ pn.pane.Markdown(WELCOME_MESSAGE),
238
  open_button,
239
  network_select,
240
  station_select,
 
242
  year_slider,
243
  year_range_slider,
244
  stat_select,
245
+ pn.pane.Markdown(FOOTER_MESSAGE),
246
  ]
247
 
248
  def _populate_main(self):
249
+ self._tap_x = Tap()
250
  self._station_pane = pn.pane.HoloViews(
251
+ sizing_mode="stretch_both",
252
+ min_width=800,
253
+ min_height=350,
254
+ )
255
+ self._day_pane = pn.pane.HoloViews(
256
+ sizing_mode="stretch_both",
257
+ min_width=800,
258
+ min_height=350,
259
  )
260
  self._update_stations()
261
  self._update_var_station_dependents()
262
+ self._day_pane.object = pn.bind(
263
+ self._update_day_plot,
264
+ self.param.var,
265
+ self.param.station,
266
+ self.param.year,
267
+ self.param.year_range,
268
+ self._tap_x.param.x,
269
+ )
270
+ pointer_vline = hv.DynamicMap(self._update_vline, streams=[self._tap_x])
271
+ oni_plot = hv.DynamicMap(
272
+ self._update_oni_plot
273
+ )
274
+ station_plot = hv.DynamicMap(
275
+ pn.bind(
276
+ self._update_station_plot,
277
+ self.param.var,
278
+ self.param.station,
279
+ self.param.year,
280
+ self.param.year_range,
281
+ self.param.stat,
282
+ ),
283
+ )
284
+ self._station_pane.object = (
285
+ (oni_plot * station_plot * pointer_vline)
286
+ .opts(
287
+ xlabel="Time of Year",
288
+ gridstyle={"ygrid_line_alpha": 0},
289
+ xticks=XTICKS,
290
+ show_grid=True,
291
+ padding=(0, (0, 0.45)),
292
+ responsive=True,
293
+ shared_axes=False,
294
+ legend_position="top_right"
295
+ )
296
+ .apply.opts(title=self.param._title, ylabel=self.param._ylabel)
297
+ )
298
+ self._update_ylabel()
299
+ self._update_title()
300
+
301
+ self._main.objects = [self._station_pane, self._day_pane]
302
 
303
  def _populate_modal(self):
304
  network_points = self._network_df.hvplot.points(
 
322
 
323
  tap = Tap(source=network_points)
324
  pn.bind(self._update_station, x=tap.param.x, y=tap.param.y, watch=True)
325
+ instructions = pn.pane.Markdown(
326
+ "#### The nearest station will be selected when you click on the map."
327
+ )
328
  network_pane = pn.pane.HoloViews(
329
  network_points * gv.tile_sources.CartoDark(),
330
  )
 
333
  def _open_modal(self, event):
334
  self._template.open_modal()
335
 
336
+ @pn.cache
337
+ def _get_oni_df(self):
338
+ df = pd.read_csv(ONI_URL)
339
+ df["month"] = df["season"].map(SEASON_TO_MONTH)
340
+ df["julian_day"] = df["month"].map(MONTH_TO_JULIAN_DAY)
341
+ df["julian_day_end"] = df["julian_day"].shift(-1).fillna(365)
342
+ df["oni"] = df["oni"].str.replace("_", " ").str.title()
343
+ return df
344
+
345
  @pn.cache
346
  def _get_network_df(self):
347
  network_df = pd.read_csv(NETWORKS_URL)
 
349
 
350
  @pn.depends("network", watch=True)
351
  def _update_stations(self):
352
+ self._template.close_modal()
353
  network_df_subset = self._network_df.loc[
354
  self._network_df["iem_network"] == self.network,
355
  ["stid", "station_name"],
 
357
  names = sorted(network_df_subset["station_name"].unique())
358
  stids = sorted(network_df_subset["stid"].unique())
359
  self.param["station"].objects = names + stids
 
360
 
361
  def _update_station(self, x, y):
362
  if x is None or y is None:
 
416
  @pn.depends("var", "station", watch=True)
417
  def _update_var_station_dependents(self):
418
  try:
419
+ self._main.loading = True
420
  self._station_df = self._get_station_df(self.station, self.var).dropna()
421
  if len(self._station_df) == 0:
422
  return
423
 
424
  year_range_min = self._station_df["year"].min()
425
  year_range_max = self._station_df["year"].max()
 
 
 
 
 
 
 
426
  self.param["year"].bounds = (year_range_min, year_range_max)
427
  if self.year < year_range_min:
428
  self.year = year_range_min
429
  if self.year > year_range_max:
430
  self.year = year_range_max
431
  finally:
432
+ self._main.loading = False
433
+
434
+ def _update_vline(self, x, y):
435
+ if x is None:
436
+ x = 0
437
+ if y is None:
438
+ y = 0
439
+ vline = hv.VLine(x).opts(line_width=0.9, color="lightgrey")
440
+ text = hv.Text(
441
+ x,
442
+ y,
443
+ f"Julian Day {int(x)}",
444
+ ).opts(
445
+ text_color="lightgrey",
446
+ text_align="left",
447
+ text_baseline="bottom",
448
+ text_alpha=0.8,
449
+ )
450
+ return vline * text
451
+
452
+ @param.depends("year", watch=True)
453
+ def _update_oni_plot(self):
454
+ df = self._oni_df
455
+ df_year = df.loc[df["year"] == 1998]
456
+ df_year.iloc[-1, -1] = 365
457
+
458
+ overlay = hv.Overlay([])
459
+ for oni in df_year["oni"].unique():
460
+ df_subset = df_year.loc[df_year["oni"] == oni, ["julian_day", "julian_day_end"]]
461
+ overlay *= hv.VSpans(
462
+ df_subset,
463
+ ["julian_day", "julian_day_end"],
464
+ label=oni,
465
+ ).opts(color=ONI_COLORS[oni], alpha=0.18, line_alpha=0)
466
+ return overlay
467
+
468
+ def _update_day_plot(self, var, station, year, year_range, x):
469
+ df = self._station_df
470
+ if not x:
471
+ x = 1
472
+ x = int(x)
473
+ df_day_year = df.query(f"year == {year}")
474
+ if x > df_day_year["dayofyear"].max():
475
+ x = df_day_year["dayofyear"].max()
476
+ day_year = df_day_year.loc[df_day_year["dayofyear"] == x, self.var].iloc[0]
477
+ df_subset = df.loc[df["year"].between(*year_range)]
478
+ df_day_climo = df_subset.loc[df_subset["dayofyear"] == x]
479
+ df_day_climo = df_day_climo.assign(
480
+ above_or_below=df_day_climo[self.var] >= day_year
481
+ )
482
+ title = (
483
+ f"{VAR_OPTIONS_R[self.var]} across {year_range[0]}-{year_range[1]} on "
484
+ + df_day_climo.index.strftime("%B %d")[0]
485
+ + f" (Julian Day {x}) "
486
+ )
487
 
488
+ days_above = df_day_climo.loc[df_day_climo["above_or_below"] == True].shape[0]
489
+ days_below = df_day_climo.loc[df_day_climo["above_or_below"] == False].shape[0]
490
+
491
+ min_x = df[self.var].min()
492
+
493
+ plot = hv.Overlay([])
494
+ plot *= df_day_climo.hvplot.hist(
495
+ self.var,
496
+ responsive=True,
497
+ by="above_or_below",
498
+ bins=11,
499
+ legend=False,
500
+ color=hv.Cycle([DARK_BLUE, DARK_RED]),
501
+ ).opts("Histogram", fill_alpha=0.7, line_alpha=0)
502
+ plot *= hv.VLine(day_year).opts(line_width=0.9, color="lightgrey")
503
+ plot *= hv.Text(
504
+ day_year,
505
+ 0.1,
506
+ f"{year}",
507
+ ).opts(
508
+ text_color="lightgrey",
509
+ text_align="left",
510
+ text_baseline="bottom",
511
+ text_alpha=0.8,
512
+ )
513
+ plot *= self._create_text_days_labels(
514
+ df,
515
+ days_above,
516
+ days_below,
517
+ text_x=min_x + 5,
518
+ text_y=4,
519
+ spacing=1,
520
+ suffix=f"YEARS",
521
+ )
522
+
523
+ return plot.opts(
524
+ xlabel=VAR_OPTIONS_R[self.var],
525
+ ylabel="Number of Days",
526
+ title=title,
527
+ shared_axes=False,
528
+ show_grid=True,
529
+ gridstyle={"xgrid_line_alpha": 0},
530
+ xlim=(min_x, df[self.var].max()),
531
+ )
532
+
533
+ def _update_station_plot(self, var, station, year, year_range, stat):
534
  if len(self._station_df) == 0:
535
  return
536
 
537
+ # base dataframes
538
+ df = self._station_df
539
+ df_subset = df.loc[df["year"].between(*year_range)]
540
+ df_avg = df_subset.groupby("dayofyear").mean()
541
+ df_year = df[df.year == year]
542
+
543
+ # above/below
544
+ df_year = df_year[["dayofyear", self.var]].merge(
545
+ df_avg.reset_index()[["dayofyear", self.var]],
546
+ on="dayofyear",
547
+ suffixes=("", "_avg"),
548
+ )
549
+ df_year["above_or_below"] = df_year[self.var] >= df_year[f"{self.var}_avg"]
550
+ days_above = df_year.loc[df_year["above_or_below"] == True].shape[0]
551
+ days_below = df_year.loc[df_year["above_or_below"] == False].shape[0]
552
 
553
+ # stats
554
+ if stat == "Mean":
555
+ year_avg = df_year[self.var].mean()
556
+ else:
557
+ year_avg = df_year[self.var].median()
558
+ year_max = df_year[self.var].max()
559
+ year_min = df_year[self.var].min()
560
+
561
+ plots = self._create_line_plots(df, df_year, df_avg)
562
+ lines = self._create_hlines(year_avg, year_max, year_min)
563
+ texts = self._create_text_labels(year_avg, year_max, year_min)
564
+ text_days = self._create_text_days_labels(df, days_above, days_below)
565
+
566
+ # Overlay all elements
567
+ station_overlay = plots * lines * texts * text_days
568
+ return station_overlay
569
+
570
+ @pn.depends("var", watch=True)
571
+ def _update_ylabel(self):
572
+ self._ylabel = VAR_OPTIONS_R[self.var]
573
+
574
+ @pn.depends("station", "year", "year_range", watch=True)
575
+ def _update_title(self):
576
+ df = self._station_df
577
+ df_subset = df.loc[df["year"].between(*self.year_range)]
578
+ # hack to get the title and ylabel to update
579
+ year_min = df_subset["year"].min()
580
+ if self.year_range[0] > year_min:
581
+ year_min = self.year_range[0]
582
+ year_max = df_subset["year"].max()
583
+ if self.year_range[1] < year_max:
584
+ year_max = self.year_range[1]
585
+ year_range_label = f"{year_min}-{year_max}"
586
+ self._title = f"{self._get_station_label()} - {self.year} vs Average ({year_range_label})"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
587
 
588
  def _create_line_plots(self, df, df_year, df_avg):
589
  plot_kwargs = {
590
  "x": "dayofyear",
591
  "y": self.var,
592
  "legend": False,
593
+ "responsive": True,
594
  }
595
  plot = df.hvplot(
596
  by="year",
 
599
  hover=False,
600
  **plot_kwargs,
601
  )
602
+ df_above = df_year.copy()
603
+ df_above.loc[df_above["above_or_below"]] = np.nan
604
+ df_below = df_year.copy()
605
+ df_below.loc[~df_below["above_or_below"]] = np.nan
606
+ plot_year = df_year.hvplot(
607
+ color="lightgrey", hover="vline", alpha=0.5, **plot_kwargs
608
+ ).redim.label(**{"dayofyear": "Julian Day", self.var: str(self.year)})
609
+ plot_above = df_above.hvplot(
610
+ hover="vline", color=DARK_BLUE, **plot_kwargs
611
+ ).redim.label(**{"dayofyear": "Julian Day", self.var: str(self.year)})
612
+ plot_below = df_below.hvplot(
613
+ hover="vline", color=DARK_RED, **plot_kwargs
614
+ ).redim.label(**{"dayofyear": "Julian Day", self.var: str(self.year)})
615
  plot_avg = df_avg.hvplot(
616
+ color="lightgrey", hover="vline", **plot_kwargs
617
  ).redim.label(**{"dayofyear": "Julian Day", self.var: "Average"})
618
+ return plot * plot_year * plot_above * plot_below * plot_avg
619
 
620
  def _create_hlines(self, year_avg, year_max, year_min):
621
  # Create horizontal lines
 
658
  return text_year_avg * text_year_max * text_year_min
659
 
660
  def _create_areas(self, df_above, df_below):
661
+ area_kwargs = {
662
+ "x": "dayofyear",
663
+ "y": f"{self.var}_avg",
664
+ "y2": self.var,
665
+ "hover": False,
666
+ "responsive": True,
667
+ }
668
+ area_opts = {"fill_alpha": 0.2, "line_alpha": 0.8}
669
+ plot_above = df_above.hvplot.area(**area_kwargs).opts(
670
+ line_color=DARK_RED, fill_color=DARK_RED, **area_opts
671
+ )
672
+ plot_below = df_below.hvplot.area(**area_kwargs).opts(
673
+ line_color=DARK_BLUE, fill_color=DARK_BLUE, **area_opts
674
+ )
675
  return plot_above * plot_below
676
 
677
+ def _create_text_days_labels(
678
+ self,
679
+ df,
680
+ days_above,
681
+ days_below,
682
+ text_x=None,
683
+ text_y=None,
684
+ spacing=None,
685
+ suffix=None,
686
+ ):
687
+ text_x = text_x or 30
688
+ text_y = text_y or df[self.var].max() + 3
689
+ spacing = spacing or 2
690
+ suffix = suffix or "DAYS"
691
  text_days_above = hv.Text(text_x, text_y, f"{days_above}", fontsize=14).opts(
692
  text_align="right",
693
  text_baseline="bottom",
 
700
  text_color=DARK_BLUE,
701
  text_alpha=0.8,
702
  )
703
+ text_above = hv.Text(
704
+ text_x + spacing, text_y, f"{suffix} ABOVE", fontsize=7
705
+ ).opts(
706
  text_align="left",
707
  text_baseline="bottom",
708
  text_color="lightgrey",
709
  text_alpha=0.8,
710
  )
711
+ text_below = hv.Text(
712
+ text_x + spacing, text_y, f"{suffix} BELOW", fontsize=7
713
+ ).opts(
714
  text_align="left",
715
  text_baseline="top",
716
  text_color="lightgrey",
 
719
 
720
  return text_days_above * text_days_below * text_above * text_below
721
 
722
+ def _get_station_label(self):
723
  if self.station not in self._network_df["station_name"].unique():
724
+ stid = self.station
725
  station_name = self._network_df.loc[
726
  self._network_df["stid"] == self.station, "station_name"
727
  ].iloc[0]
728
  else:
729
+ stid = self._network_df.loc[
730
+ self._network_df["station_name"] == self.station, "stid"
731
+ ].iloc[0]
732
  station_name = self.station
733
+ station_label = f"{station_name.title()} ({stid})"
734
+ return station_label
735
 
736
  def __panel__(self):
737
  return self._template