nakas commited on
Commit
571cfc8
·
1 Parent(s): 0bf8a7c

feat: add wind rose (10m) using wind direction var detected by long_name; ensure snow level fetch prefers snowlvl_0m; include wind rose plot in UI

Browse files
Files changed (3) hide show
  1. app.py +19 -3
  2. nbm_client.py +33 -6
  3. plot_utils.py +50 -0
app.py CHANGED
@@ -23,6 +23,7 @@ from plot_utils import (
23
  make_cloud_layers_fig,
24
  make_precip_type_fig,
25
  make_snow_level_fig,
 
26
  )
27
 
28
 
@@ -45,6 +46,7 @@ def run_forecast(lat, lon, hours=24):
45
  cloud_layers_fig = None
46
  precip_type_fig = None
47
  snow_level_fig = None
 
48
 
49
  def y(msg):
50
  print(msg, flush=True)
@@ -61,6 +63,7 @@ def run_forecast(lat, lon, hours=24):
61
  cloud_layers_fig,
62
  precip_type_fig,
63
  snow_level_fig,
 
64
  )
65
 
66
  if lat is None or lon is None:
@@ -162,6 +165,17 @@ def run_forecast(lat, lon, hours=24):
162
  except Exception as e:
163
  print(f"Snow level plot error: {e}")
164
 
 
 
 
 
 
 
 
 
 
 
 
165
  # Deterministic snowfall derivations if available
166
  snow6_fig = None
167
  snow24_fig = None
@@ -217,6 +231,7 @@ def run_forecast(lat, lon, hours=24):
217
  cloud_layers_fig,
218
  precip_type_fig,
219
  snow_level_fig,
 
220
  )
221
  return
222
 
@@ -305,22 +320,23 @@ with gr.Blocks(title="NBM Point Forecast (NOAA NOMADS)") as demo:
305
  cloud_layers_plot = gr.Plot(label="Cloud Layers (%)")
306
  precip_type_plot = gr.Plot(label="Precip Type Probabilities")
307
  snow_level_plot = gr.Plot(label="Snow Level + Precip")
 
308
 
309
  # Trigger when clicking Fetch, or when lat/lon are edited (e.g., via map)
310
  btn.click(
311
  run_forecast,
312
  inputs=[lat_in, lon_in, hours],
313
- outputs=[status, table, temp_wind_plot, cloud_precip_plot, snow_prob_plot, snow6_plot, snow24_plot, snow48_plot, cloud_layers_plot, precip_type_plot, snow_level_plot],
314
  )
315
  lat_in.change(
316
  run_forecast,
317
  inputs=[lat_in, lon_in, hours],
318
- outputs=[status, table, temp_wind_plot, cloud_precip_plot, snow_prob_plot, snow6_plot, snow24_plot, snow48_plot, cloud_layers_plot, precip_type_plot, snow_level_plot],
319
  )
320
  lon_in.change(
321
  run_forecast,
322
  inputs=[lat_in, lon_in, hours],
323
- outputs=[status, table, temp_wind_plot, cloud_precip_plot, snow_prob_plot, snow6_plot, snow24_plot, snow48_plot, cloud_layers_plot, precip_type_plot, snow_level_plot],
324
  )
325
 
326
 
 
23
  make_cloud_layers_fig,
24
  make_precip_type_fig,
25
  make_snow_level_fig,
26
+ make_wind_rose_fig,
27
  )
28
 
29
 
 
46
  cloud_layers_fig = None
47
  precip_type_fig = None
48
  snow_level_fig = None
49
+ wind_rose_fig = None
50
 
51
  def y(msg):
52
  print(msg, flush=True)
 
63
  cloud_layers_fig,
64
  precip_type_fig,
65
  snow_level_fig,
66
+ wind_rose_fig,
67
  )
68
 
69
  if lat is None or lon is None:
 
165
  except Exception as e:
166
  print(f"Snow level plot error: {e}")
167
 
168
+ # Wind rose (if direction present)
169
+ try:
170
+ import pandas as _pd
171
+ if "wdir_deg" in df.columns and "wind_mph" in df.columns:
172
+ x = _pd.to_datetime(df["time_utc"], utc=True, errors="coerce")
173
+ wdir = _pd.Series(df["wdir_deg"].astype(float).values, index=x)
174
+ wspd = _pd.Series(df["wind_mph"].astype(float).values, index=x)
175
+ wind_rose_fig = make_wind_rose_fig(wdir, wspd)
176
+ except Exception as e:
177
+ print(f"Wind rose plot error: {e}")
178
+
179
  # Deterministic snowfall derivations if available
180
  snow6_fig = None
181
  snow24_fig = None
 
231
  cloud_layers_fig,
232
  precip_type_fig,
233
  snow_level_fig,
234
+ wind_rose_fig,
235
  )
236
  return
237
 
 
320
  cloud_layers_plot = gr.Plot(label="Cloud Layers (%)")
321
  precip_type_plot = gr.Plot(label="Precip Type Probabilities")
322
  snow_level_plot = gr.Plot(label="Snow Level + Precip")
323
+ wind_rose_plot = gr.Plot(label="Wind Rose (10 m)")
324
 
325
  # Trigger when clicking Fetch, or when lat/lon are edited (e.g., via map)
326
  btn.click(
327
  run_forecast,
328
  inputs=[lat_in, lon_in, hours],
329
+ outputs=[status, table, temp_wind_plot, cloud_precip_plot, snow_prob_plot, snow6_plot, snow24_plot, snow48_plot, cloud_layers_plot, precip_type_plot, snow_level_plot, wind_rose_plot],
330
  )
331
  lat_in.change(
332
  run_forecast,
333
  inputs=[lat_in, lon_in, hours],
334
+ outputs=[status, table, temp_wind_plot, cloud_precip_plot, snow_prob_plot, snow6_plot, snow24_plot, snow48_plot, cloud_layers_plot, precip_type_plot, snow_level_plot, wind_rose_plot],
335
  )
336
  lon_in.change(
337
  run_forecast,
338
  inputs=[lat_in, lon_in, hours],
339
+ outputs=[status, table, temp_wind_plot, cloud_precip_plot, snow_prob_plot, snow6_plot, snow24_plot, snow48_plot, cloud_layers_plot, precip_type_plot, snow_level_plot, wind_rose_plot],
340
  )
341
 
342
 
nbm_client.py CHANGED
@@ -218,6 +218,15 @@ def fetch_point_forecast_df(
218
  )
219
  t_fetch = time.perf_counter()
220
  subset = subset.isel(time=slice(0, n)).load()
 
 
 
 
 
 
 
 
 
221
  logger.info(f"Fetched subset data in {time.perf_counter()-t_fetch:.2f}s")
222
  t_index = t_index[:n]
223
 
@@ -273,6 +282,8 @@ def fetch_point_forecast_df(
273
  data["snow_in"] = np.round(snow_in, 2)
274
  if snow_est_in is not None and (snow_in is None or np.nanmax(snow_in) <= 0):
275
  data["snow_est_in"] = np.round(snow_est_in, 2)
 
 
276
  df = pd.DataFrame(data)
277
 
278
  meta = {
@@ -455,13 +466,17 @@ def fetch_snow_level_kft(
455
  lon_vals = ds["lon"].values
456
  ilat = _nearest_index(lat_vals, lat)
457
  ilon = _nearest_index(lon_vals, lon)
458
- # find first var whose long_name mentions 'snow level'
459
  candidate: Optional[str] = None
460
- for v in ds.variables:
461
- ln = str(ds[v].attrs.get("long_name", "")).lower()
462
- if "snow level" in ln:
463
- candidate = v
464
- break
 
 
 
 
465
  if candidate is None:
466
  return pd.DatetimeIndex([]), None
467
  t_full = _to_datetime_index(ds["time"]) # assume aligned
@@ -473,3 +488,15 @@ def fetch_snow_level_kft(
473
  # convert meters to kft
474
  kft = (vals_m / 0.3048) / 1000.0
475
  return t, pd.Series(np.round(kft.astype(float), 2), index=t)
 
 
 
 
 
 
 
 
 
 
 
 
 
218
  )
219
  t_fetch = time.perf_counter()
220
  subset = subset.isel(time=slice(0, n)).load()
221
+ # Attempt to locate 10m wind direction variable by metadata
222
+ wdir_deg = None
223
+ try:
224
+ wdir_name = _find_10m_wind_dir_var(ds)
225
+ if wdir_name:
226
+ arr = ds[wdir_name].isel(lat=ilat, lon=ilon, time=slice(0, n)).load().values
227
+ wdir_deg = _mask_fill(arr)
228
+ except Exception:
229
+ wdir_deg = None
230
  logger.info(f"Fetched subset data in {time.perf_counter()-t_fetch:.2f}s")
231
  t_index = t_index[:n]
232
 
 
282
  data["snow_in"] = np.round(snow_in, 2)
283
  if snow_est_in is not None and (snow_in is None or np.nanmax(snow_in) <= 0):
284
  data["snow_est_in"] = np.round(snow_est_in, 2)
285
+ if wdir_deg is not None:
286
+ data["wdir_deg"] = np.round(wdir_deg.astype(float), 1)
287
  df = pd.DataFrame(data)
288
 
289
  meta = {
 
466
  lon_vals = ds["lon"].values
467
  ilat = _nearest_index(lat_vals, lat)
468
  ilon = _nearest_index(lon_vals, lon)
469
+ # Prefer canonical name if present
470
  candidate: Optional[str] = None
471
+ if "snowlvl_0m" in ds.variables:
472
+ candidate = "snowlvl_0m"
473
+ else:
474
+ # find first var whose long_name mentions 'snow level'
475
+ for v in ds.variables:
476
+ ln = str(ds[v].attrs.get("long_name", "")).lower()
477
+ if "snow level" in ln:
478
+ candidate = v
479
+ break
480
  if candidate is None:
481
  return pd.DatetimeIndex([]), None
482
  t_full = _to_datetime_index(ds["time"]) # assume aligned
 
488
  # convert meters to kft
489
  kft = (vals_m / 0.3048) / 1000.0
490
  return t, pd.Series(np.round(kft.astype(float), 2), index=t)
491
+
492
+
493
+ def _find_10m_wind_dir_var(ds: xr.Dataset) -> Optional[str]:
494
+ for v in ds.variables:
495
+ ln = str(ds[v].attrs.get("long_name", "")).lower()
496
+ if "10 m" in ln and "wind direction" in ln and "blowing" in ln:
497
+ return v
498
+ # heuristic fallback
499
+ for name in ("wdir10m", "wd10m", "winddir10m"):
500
+ if name in ds.variables:
501
+ return name
502
+ return None
plot_utils.py CHANGED
@@ -288,3 +288,53 @@ def make_snow_level_fig(
288
  legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
289
  )
290
  return fig
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
288
  legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
289
  )
290
  return fig
291
+
292
+
293
+ def make_wind_rose_fig(dir_deg: pd.Series, spd_mph: pd.Series) -> go.Figure:
294
+ """Aggregate a wind rose with 16 direction bins and 4 speed classes.
295
+
296
+ Speed bins: 0-5, 5-10, 10-20, >20 mph
297
+ """
298
+ import numpy as np
299
+
300
+ valid = (~dir_deg.isna()) & (~spd_mph.isna())
301
+ d = dir_deg[valid].astype(float)
302
+ s = spd_mph[valid].astype(float)
303
+ if len(d) == 0:
304
+ return go.Figure()
305
+
306
+ dir_bins = np.arange(-11.25, 360 + 22.5, 22.5)
307
+ dir_labels = (dir_bins[:-1] + dir_bins[1:]) / 2.0
308
+
309
+ speed_edges = [0, 5, 10, 20, 1e6]
310
+ speed_labels = ["0-5", "5-10", "10-20", ">20"]
311
+ colors = ["#d0f0fd", "#86c5da", "#2ca02c", "#d62728"]
312
+
313
+ traces = []
314
+ for i in range(len(speed_edges) - 1):
315
+ mask = (s >= speed_edges[i]) & (s < speed_edges[i + 1])
316
+ if mask.sum() == 0:
317
+ counts = np.zeros(len(dir_labels))
318
+ else:
319
+ hist, _ = np.histogram(d[mask] % 360.0, bins=dir_bins)
320
+ counts = hist
321
+ traces.append(
322
+ go.Barpolar(
323
+ r=counts,
324
+ theta=dir_labels,
325
+ name=speed_labels[i],
326
+ marker_color=colors[i],
327
+ opacity=0.85,
328
+ )
329
+ )
330
+
331
+ fig = go.Figure(traces)
332
+ fig.update_layout(
333
+ margin=dict(l=40, r=40, t=30, b=40),
334
+ legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
335
+ polar=dict(
336
+ angularaxis=dict(direction="clockwise"),
337
+ radialaxis=dict(visible=True, ticks=""),
338
+ ),
339
+ )
340
+ return fig