nakas commited on
Commit
ac94fd3
·
1 Parent(s): 1a25290

feat: add cloud layers plot (tcdcl195/196/197,tcdchcll), precip type probabilities (Rain/Snow/FZRA/Sleet), and snow level + precip panel; show these when vars are present

Browse files
Files changed (3) hide show
  1. app.py +81 -5
  2. nbm_client.py +118 -0
  3. plot_utils.py +89 -0
app.py CHANGED
@@ -10,6 +10,9 @@ from nbm_client import (
10
  fetch_point_probabilities,
11
  get_latest_hourly_dataset_url,
12
  get_latest_3hr_dataset_url,
 
 
 
13
  )
14
  from plot_utils import (
15
  make_temp_dew_wind_fig,
@@ -17,6 +20,9 @@ from plot_utils import (
17
  make_snow_prob_fig,
18
  make_snow_6h_accum_fig,
19
  make_window_snow_fig,
 
 
 
20
  )
21
 
22
 
@@ -36,11 +42,26 @@ def run_forecast(lat, lon, hours=24):
36
  temp_wind_fig = None
37
  cloud_precip_fig = None
38
  snow_prob_fig = None
 
 
 
39
 
40
  def y(msg):
41
  print(msg, flush=True)
42
  elapsed = time.perf_counter() - t0
43
- return gr.update(value=f"{msg} (elapsed {elapsed:.1f}s)"), table_df, temp_wind_fig, cloud_precip_fig, snow_prob_fig, None, None, None
 
 
 
 
 
 
 
 
 
 
 
 
44
 
45
  if lat is None or lon is None:
46
  yield y("Click map or enter lat/lon.")
@@ -101,6 +122,46 @@ def run_forecast(lat, lon, hours=24):
101
  print(f"Probability fetch/plot error: {e}")
102
  snow_prob_fig = None
103
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  # Deterministic snowfall derivations if available
105
  snow6_fig = None
106
  snow24_fig = None
@@ -141,7 +202,19 @@ def run_forecast(lat, lon, hours=24):
141
  f"{meta['lat']:.3f}, {meta['lon']:.3f} (grid: lat[{meta['ilat']}], lon[{meta['ilon']}])\n"
142
  f"Dataset: {dataset_url} | total time {time.perf_counter()-t0:.1f}s"
143
  )
144
- yield gr.update(value=header), table_df, temp_wind_fig, cloud_precip_fig, snow_prob_fig, snow6_fig, snow24_fig, snow48_fig
 
 
 
 
 
 
 
 
 
 
 
 
145
  return
146
 
147
 
@@ -226,22 +299,25 @@ with gr.Blocks(title="NBM Point Forecast (NOAA NOMADS)") as demo:
226
  snow6_plot = gr.Plot(label="6 hr Snow + Accum")
227
  snow24_plot = gr.Plot(label="24 hr Snowfall")
228
  snow48_plot = gr.Plot(label="48 hr Snowfall")
 
 
 
229
 
230
  # Trigger when clicking Fetch, or when lat/lon are edited (e.g., via map)
231
  btn.click(
232
  run_forecast,
233
  inputs=[lat_in, lon_in, hours],
234
- outputs=[status, table, temp_wind_plot, cloud_precip_plot, snow_prob_plot, snow6_plot, snow24_plot, snow48_plot],
235
  )
236
  lat_in.change(
237
  run_forecast,
238
  inputs=[lat_in, lon_in, hours],
239
- outputs=[status, table, temp_wind_plot, cloud_precip_plot, snow_prob_plot, snow6_plot, snow24_plot, snow48_plot],
240
  )
241
  lon_in.change(
242
  run_forecast,
243
  inputs=[lat_in, lon_in, hours],
244
- outputs=[status, table, temp_wind_plot, cloud_precip_plot, snow_prob_plot, snow6_plot, snow24_plot, snow48_plot],
245
  )
246
 
247
 
 
10
  fetch_point_probabilities,
11
  get_latest_hourly_dataset_url,
12
  get_latest_3hr_dataset_url,
13
+ fetch_cloud_layers,
14
+ fetch_precip_type_probs,
15
+ fetch_snow_level_kft,
16
  )
17
  from plot_utils import (
18
  make_temp_dew_wind_fig,
 
20
  make_snow_prob_fig,
21
  make_snow_6h_accum_fig,
22
  make_window_snow_fig,
23
+ make_cloud_layers_fig,
24
+ make_precip_type_fig,
25
+ make_snow_level_fig,
26
  )
27
 
28
 
 
42
  temp_wind_fig = None
43
  cloud_precip_fig = None
44
  snow_prob_fig = None
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)
51
  elapsed = time.perf_counter() - t0
52
+ return (
53
+ gr.update(value=f"{msg} (elapsed {elapsed:.1f}s)"),
54
+ table_df,
55
+ temp_wind_fig,
56
+ cloud_precip_fig,
57
+ snow_prob_fig,
58
+ None,
59
+ None,
60
+ None,
61
+ cloud_layers_fig,
62
+ precip_type_fig,
63
+ snow_level_fig,
64
+ )
65
 
66
  if lat is None or lon is None:
67
  yield y("Click map or enter lat/lon.")
 
122
  print(f"Probability fetch/plot error: {e}")
123
  snow_prob_fig = None
124
 
125
+ # Cloud layers and precip type probabilities
126
+ try:
127
+ t_layers, layers = fetch_cloud_layers(dataset_url, lat, lon, hours=hours)
128
+ if len(layers) > 0:
129
+ import pandas as _pd
130
+ x = _pd.to_datetime(df["time_utc"], utc=True, errors="coerce")
131
+ total = _pd.Series(df["cloud_cover_pct"].astype(float).values, index=x) if "cloud_cover_pct" in df.columns else None
132
+ # preserve input order
133
+ layers_ordered = {k: layers[k] for k in layers}
134
+ cloud_layers_fig = make_cloud_layers_fig(t_layers, layers_ordered, total)
135
+ except Exception as e:
136
+ print(f"Cloud layers plot error: {e}")
137
+
138
+ try:
139
+ t_ptype, ptype = fetch_precip_type_probs(dataset_url, lat, lon, hours=hours)
140
+ if len(ptype) > 0:
141
+ precip_type_fig = make_precip_type_fig(t_ptype, ptype)
142
+ except Exception as e:
143
+ print(f"Precip type plot error: {e}")
144
+
145
+ # Snow level with precip overlay
146
+ try:
147
+ t_sl, snow_kft = fetch_snow_level_kft(dataset_url, lat, lon, hours=hours)
148
+ if snow_kft is not None and len(snow_kft) > 0:
149
+ import pandas as _pd
150
+ x = _pd.to_datetime(df["time_utc"], utc=True, errors="coerce")
151
+ # Compute 6h precip window from available precip
152
+ if "precip_in" in df.columns:
153
+ # estimate step
154
+ step_hours = 1.0
155
+ if len(x) > 1:
156
+ step_hours = max(1.0, (x[1] - x[0]).total_seconds() / 3600.0)
157
+ w6 = max(1, int(round(6.0 / step_hours)))
158
+ p6 = _pd.Series(df["precip_in"].astype(float).values, index=x).rolling(window=w6, min_periods=1).sum()
159
+ else:
160
+ p6 = None
161
+ snow_level_fig = make_snow_level_fig(t_sl, snow_kft, p6)
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
 
202
  f"{meta['lat']:.3f}, {meta['lon']:.3f} (grid: lat[{meta['ilat']}], lon[{meta['ilon']}])\n"
203
  f"Dataset: {dataset_url} | total time {time.perf_counter()-t0:.1f}s"
204
  )
205
+ yield (
206
+ gr.update(value=header),
207
+ table_df,
208
+ temp_wind_fig,
209
+ cloud_precip_fig,
210
+ snow_prob_fig,
211
+ snow6_fig,
212
+ snow24_fig,
213
+ snow48_fig,
214
+ cloud_layers_fig,
215
+ precip_type_fig,
216
+ snow_level_fig,
217
+ )
218
  return
219
 
220
 
 
299
  snow6_plot = gr.Plot(label="6 hr Snow + Accum")
300
  snow24_plot = gr.Plot(label="24 hr Snowfall")
301
  snow48_plot = gr.Plot(label="48 hr Snowfall")
302
+ cloud_layers_plot = gr.Plot(label="Cloud Layers (%)")
303
+ precip_type_plot = gr.Plot(label="Precip Type Probabilities")
304
+ snow_level_plot = gr.Plot(label="Snow Level + Precip")
305
 
306
  # Trigger when clicking Fetch, or when lat/lon are edited (e.g., via map)
307
  btn.click(
308
  run_forecast,
309
  inputs=[lat_in, lon_in, hours],
310
+ 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],
311
  )
312
  lat_in.change(
313
  run_forecast,
314
  inputs=[lat_in, lon_in, hours],
315
+ 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],
316
  )
317
  lon_in.change(
318
  run_forecast,
319
  inputs=[lat_in, lon_in, hours],
320
+ 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],
321
  )
322
 
323
 
nbm_client.py CHANGED
@@ -328,3 +328,121 @@ def fetch_point_probabilities(
328
  logger.warning(f"Skipping {v} due to read/convert error: {ex}")
329
 
330
  return t_index, out
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
328
  logger.warning(f"Skipping {v} due to read/convert error: {ex}")
329
 
330
  return t_index, out
331
+
332
+
333
+ def _open_ds(dataset_url: str) -> xr.Dataset:
334
+ return xr.open_dataset(_to_dap2_url(dataset_url), engine="pydap", decode_cf=True)
335
+
336
+
337
+ def fetch_cloud_layers(
338
+ dataset_url: str, lat: float, lon: float, hours: int = 24
339
+ ) -> Tuple[pd.DatetimeIndex, Dict[str, pd.Series]]:
340
+ """Return available cloud layer cover percentages as time series.
341
+
342
+ Looks for variables among: tcdcl195, tcdcl196, tcdcl197, tcdchcll.
343
+ Labels are derived from long_name attributes when present.
344
+ """
345
+ logger = logging.getLogger(__name__)
346
+ ds = _open_ds(dataset_url)
347
+ lat_vals = ds["lat"].values
348
+ lon_vals = ds["lon"].values
349
+ ilat = _nearest_index(lat_vals, lat)
350
+ ilon = _nearest_index(lon_vals, lon)
351
+ candidates = ["tcdcl195", "tcdcl196", "tcdcl197", "tcdchcll"]
352
+ present = [v for v in candidates if v in ds.variables]
353
+ if not present:
354
+ return pd.DatetimeIndex([]), {}
355
+ t_full = _to_datetime_index(ds["time"])
356
+ step = _infer_step_hours(t_full)
357
+ n_req = int(np.ceil(max(1, float(hours)) / step))
358
+ n = min(len(t_full), n_req)
359
+ t = t_full[:n]
360
+ out: Dict[str, pd.Series] = {}
361
+ for v in present:
362
+ try:
363
+ arr = _mask_fill(ds[v].isel(lat=ilat, lon=ilon, time=slice(0, n)).values)
364
+ vals = np.clip(arr, 0, 100)
365
+ # Label
366
+ ln = str(ds[v].attrs.get("long_name", v))
367
+ if "high cloud" in ln.lower() or v == "tcdchcll":
368
+ name = "High Cloud (%)"
369
+ elif v.endswith("195"):
370
+ name = "Layer 195 (%)"
371
+ elif v.endswith("196"):
372
+ name = "Layer 196 (%)"
373
+ elif v.endswith("197"):
374
+ name = "Layer 197 (%)"
375
+ else:
376
+ name = v
377
+ out[name] = pd.Series(np.round(vals.astype(float), 1), index=t)
378
+ except Exception as ex:
379
+ logger.warning(f"Skipping cloud layer {v}: {ex}")
380
+ return t, out
381
+
382
+
383
+ def fetch_precip_type_probs(
384
+ dataset_url: str, lat: float, lon: float, hours: int = 24
385
+ ) -> Tuple[pd.DatetimeIndex, Dict[str, pd.Series]]:
386
+ """Return precipitation type probabilities (%): Rain, Snow, Freezing Rain, Sleet.
387
+
388
+ Uses variables ptype1to2sfc (Rain), ptype3to4sfc (Snow), ptype5to7sfc (Freezing Rain), ptype8to9sfc (Sleet).
389
+ """
390
+ ds = _open_ds(dataset_url)
391
+ lat_vals = ds["lat"].values
392
+ lon_vals = ds["lon"].values
393
+ ilat = _nearest_index(lat_vals, lat)
394
+ ilon = _nearest_index(lon_vals, lon)
395
+ mapping = {
396
+ "ptype1to2sfc": "Rain",
397
+ "ptype3to4sfc": "Snow",
398
+ "ptype5to7sfc": "Freezing Rain",
399
+ "ptype8to9sfc": "Sleet",
400
+ }
401
+ present = [k for k in mapping if k in ds.variables]
402
+ if not present:
403
+ return pd.DatetimeIndex([]), {}
404
+ t_full = _to_datetime_index(ds["time"]) # time is global
405
+ step = _infer_step_hours(t_full)
406
+ n_req = int(np.ceil(max(1, float(hours)) / step))
407
+ n = min(len(t_full), n_req)
408
+ t = t_full[:n]
409
+ out: Dict[str, pd.Series] = {}
410
+ for k in present:
411
+ vals = _mask_fill(ds[k].isel(lat=ilat, lon=ilon, time=slice(0, n)).values)
412
+ # Normalize 0..1 to percent when needed
413
+ mx = float(np.nanmax(vals)) if np.isfinite(vals).any() else 0.0
414
+ if mx <= 1.0:
415
+ vals = vals * 100.0
416
+ out[mapping[k]] = pd.Series(np.round(vals.astype(float), 1), index=t)
417
+ return t, out
418
+
419
+
420
+ def fetch_snow_level_kft(
421
+ dataset_url: str, lat: float, lon: float, hours: int = 24
422
+ ) -> Tuple[pd.DatetimeIndex, Optional[pd.Series]]:
423
+ """Return snow level above mean sea level in kft if available (1-hr datasets typically).
424
+ Searches variable attributes for 'snow level'.
425
+ """
426
+ ds = _open_ds(dataset_url)
427
+ lat_vals = ds["lat"].values
428
+ lon_vals = ds["lon"].values
429
+ ilat = _nearest_index(lat_vals, lat)
430
+ ilon = _nearest_index(lon_vals, lon)
431
+ # find first var whose long_name mentions 'snow level'
432
+ candidate: Optional[str] = None
433
+ for v in ds.variables:
434
+ ln = str(ds[v].attrs.get("long_name", "")).lower()
435
+ if "snow level" in ln:
436
+ candidate = v
437
+ break
438
+ if candidate is None:
439
+ return pd.DatetimeIndex([]), None
440
+ t_full = _to_datetime_index(ds["time"]) # assume aligned
441
+ step = _infer_step_hours(t_full)
442
+ n_req = int(np.ceil(max(1, float(hours)) / step))
443
+ n = min(len(t_full), n_req)
444
+ t = t_full[:n]
445
+ vals_m = _mask_fill(ds[candidate].isel(lat=ilat, lon=ilon, time=slice(0, n)).values)
446
+ # convert meters to kft
447
+ kft = (vals_m / 0.3048) / 1000.0
448
+ return t, pd.Series(np.round(kft.astype(float), 2), index=t)
plot_utils.py CHANGED
@@ -199,3 +199,92 @@ def make_window_snow_fig(x: pd.DatetimeIndex, snow_win: pd.Series, window_label:
199
  legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
200
  )
201
  return fig
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
199
  legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
200
  )
201
  return fig
202
+
203
+
204
+ def make_cloud_layers_fig(
205
+ x: pd.DatetimeIndex,
206
+ layers: Dict[str, pd.Series],
207
+ total: pd.Series | None = None,
208
+ ) -> go.Figure:
209
+ fig = go.Figure()
210
+ palette = ["#c7c7c7", "#8c8c8c", "#525252", "#a0a0ff"]
211
+ # Plot each layer as filled area
212
+ for i, (name, series) in enumerate(layers.items()):
213
+ fig.add_trace(
214
+ go.Scatter(
215
+ x=x,
216
+ y=series.values,
217
+ name=name,
218
+ mode="lines",
219
+ line=dict(color=palette[i % len(palette)]),
220
+ fill="tozeroy",
221
+ opacity=0.4,
222
+ )
223
+ )
224
+ if total is not None:
225
+ fig.add_trace(
226
+ go.Scatter(
227
+ x=x,
228
+ y=total.values,
229
+ name="Total Cloud (%)",
230
+ mode="lines",
231
+ line=dict(color="#444444", width=2),
232
+ )
233
+ )
234
+ fig.update_layout(
235
+ margin=dict(l=40, r=20, t=30, b=40),
236
+ xaxis=dict(title="Time (UTC)"),
237
+ yaxis=dict(title="Cloud Cover (%)", range=[0, 100]),
238
+ legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
239
+ )
240
+ return fig
241
+
242
+
243
+ def make_precip_type_fig(x: pd.DatetimeIndex, probs: Dict[str, pd.Series]) -> go.Figure:
244
+ fig = go.Figure()
245
+ colors = {
246
+ "Rain": "#2ca02c",
247
+ "Freezing Rain": "#e377c2",
248
+ "Snow": "#1f77b4",
249
+ "Sleet": "#9467bd",
250
+ }
251
+ for name, s in probs.items():
252
+ fig.add_trace(
253
+ go.Scatter(x=x, y=s.values, name=name, mode="lines", line=dict(color=colors.get(name, None), width=2))
254
+ )
255
+ fig.update_layout(
256
+ margin=dict(l=40, r=20, t=30, b=40),
257
+ xaxis=dict(title="Time (UTC)"),
258
+ yaxis=dict(title="Precip Type Prob (%)", range=[0, 100]),
259
+ legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
260
+ )
261
+ return fig
262
+
263
+
264
+ def make_snow_level_fig(
265
+ x: pd.DatetimeIndex,
266
+ snow_level_kft: pd.Series,
267
+ precip_window_in: pd.Series | None = None,
268
+ ) -> go.Figure:
269
+ fig = go.Figure()
270
+ fig.add_trace(
271
+ go.Scatter(x=x, y=snow_level_kft.values, name="Snow level (kft)", mode="lines", line=dict(color="#1f77b4", width=3))
272
+ )
273
+ if precip_window_in is not None:
274
+ fig.add_trace(
275
+ go.Bar(
276
+ x=x,
277
+ y=precip_window_in.values,
278
+ name="Precip (in)",
279
+ marker_color="rgba(0,128,0,0.35)",
280
+ yaxis="y2",
281
+ )
282
+ )
283
+ fig.update_layout(
284
+ margin=dict(l=40, r=40, t=30, b=40),
285
+ xaxis=dict(title="Time (UTC)"),
286
+ yaxis=dict(title="Snow level (kft)", rangemode="tozero"),
287
+ yaxis2=dict(title="Precip (in)", overlaying="y", side="right", rangemode="tozero"),
288
+ legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
289
+ )
290
+ return fig