nakas commited on
Commit
2ccb60b
·
1 Parent(s): e23a304

feat: heuristic fallbacks for Snow PoE (lognormal from snowfall mean) and Precip Type probs (wet-bulb from T/Td); expand cloud-layer candidates; add per-step wind rose small-multiples grid

Browse files
Files changed (3) hide show
  1. app.py +21 -3
  2. nbm_client.py +93 -4
  3. plot_utils.py +51 -0
app.py CHANGED
@@ -134,8 +134,22 @@ def run_forecast(lat, lon, hours=24):
134
  else:
135
  src_url = get_latest_hourly_dataset_url()
136
  t_idx, prob_map = fetch_point_probabilities(src_url, lat, lon, hours=min(hours, 36))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  if len(prob_map) > 0:
138
- snow_prob_fig = make_snow_prob_fig(t_idx, prob_map)
139
  except Exception as e:
140
  print(f"Probability fetch/plot error: {e}")
141
  snow_prob_fig = None
@@ -156,9 +170,12 @@ def run_forecast(lat, lon, hours=24):
156
  print(f"Cloud layers plot error: {e}")
157
 
158
  try:
159
- # Precip type probs generally available on 1-hr and 3-hr; use active dataset for full coverage
160
  ptype_url = dataset_url if hours <= 36 else get_latest_3hr_dataset_url()
161
  t_ptype, ptype = fetch_precip_type_probs(ptype_url, lat, lon, hours=hours)
 
 
 
162
  if len(ptype) > 0:
163
  precip_type_fig = make_precip_type_fig(t_ptype, ptype)
164
  except Exception as e:
@@ -193,7 +210,8 @@ def run_forecast(lat, lon, hours=24):
193
  x = _pd.to_datetime(df["time_utc"], utc=True, errors="coerce")
194
  wdir = _pd.Series(df["wdir_deg"].astype(float).values, index=x)
195
  wspd = _pd.Series(df["wind_mph"].astype(float).values, index=x)
196
- wind_rose_fig = make_wind_rose_fig(wdir, wspd)
 
197
  except Exception as e:
198
  print(f"Wind rose plot error: {e}")
199
 
 
134
  else:
135
  src_url = get_latest_hourly_dataset_url()
136
  t_idx, prob_map = fetch_point_probabilities(src_url, lat, lon, hours=min(hours, 36))
137
+ if len(prob_map) == 0:
138
+ # Heuristic fallback from snowfall series (deterministic or estimate)
139
+ import pandas as _pd
140
+ x = _pd.to_datetime(df["time_utc"], utc=True, errors="coerce")
141
+ snow_series = None
142
+ if "snow_in" in df.columns:
143
+ snow_series = _pd.Series(df["snow_in"].astype(float).values, index=x)
144
+ elif "snow_est_in" in df.columns:
145
+ snow_series = _pd.Series(df["snow_est_in"].astype(float).values, index=x)
146
+ if snow_series is not None:
147
+ from nbm_client import estimate_snow_exceedance_from_series
148
+ thresholds = [0.1, 0.3, 0.5, 1.5, 2.0, 2.5, 4.0]
149
+ temp_k_vals = (df["temp_F"].astype(float).values - 32.0) * 5.0/9.0 + 273.15
150
+ prob_map = estimate_snow_exceedance_from_series(x, snow_series.values, temp_k_vals, thresholds)
151
  if len(prob_map) > 0:
152
+ snow_prob_fig = make_snow_prob_fig(t_idx if len(t_idx)>0 else x, prob_map)
153
  except Exception as e:
154
  print(f"Probability fetch/plot error: {e}")
155
  snow_prob_fig = None
 
170
  print(f"Cloud layers plot error: {e}")
171
 
172
  try:
173
+ # Precip type probs available on 1-hr and often on 3-hr; try fetch, else fallback heuristic
174
  ptype_url = dataset_url if hours <= 36 else get_latest_3hr_dataset_url()
175
  t_ptype, ptype = fetch_precip_type_probs(ptype_url, lat, lon, hours=hours)
176
+ if len(ptype) == 0:
177
+ from nbm_client import estimate_precip_type_probs_from_surface
178
+ t_ptype, ptype = estimate_precip_type_probs_from_surface(ptype_url, lat, lon, hours=min(hours, 36))
179
  if len(ptype) > 0:
180
  precip_type_fig = make_precip_type_fig(t_ptype, ptype)
181
  except Exception as e:
 
210
  x = _pd.to_datetime(df["time_utc"], utc=True, errors="coerce")
211
  wdir = _pd.Series(df["wdir_deg"].astype(float).values, index=x)
212
  wspd = _pd.Series(df["wind_mph"].astype(float).values, index=x)
213
+ # Render per-step roses as small multiples
214
+ wind_rose_fig = make_wind_rose_grid(x, wdir, wspd, step_hours=3.0 if hours>36 else 1.0)
215
  except Exception as e:
216
  print(f"Wind rose plot error: {e}")
217
 
nbm_client.py CHANGED
@@ -421,7 +421,11 @@ def fetch_cloud_layers(
421
  lon_vals = ds["lon"].values
422
  ilat = _nearest_index(lat_vals, lat)
423
  ilon = _nearest_index(lon_vals, lon)
424
- candidates = ["tcdcl195", "tcdcl196", "tcdcl197", "tcdchcll"]
 
 
 
 
425
  present = [v for v in candidates if v in ds.variables]
426
  if not present:
427
  return pd.DatetimeIndex([]), {}
@@ -437,11 +441,11 @@ def fetch_cloud_layers(
437
  vals = np.clip(arr, 0, 100)
438
  # Label
439
  ln = str(ds[v].attrs.get("long_name", v))
440
- if "high cloud" in ln.lower() or v == "tcdchcll":
441
  name = "High Cloud (%)"
442
- elif v.endswith("195"):
443
  name = "Layer 195 (%)"
444
- elif v.endswith("196"):
445
  name = "Layer 196 (%)"
446
  elif v.endswith("197"):
447
  name = "Layer 197 (%)"
@@ -453,6 +457,91 @@ def fetch_cloud_layers(
453
  return t, out
454
 
455
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
456
  def fetch_precip_type_probs(
457
  dataset_url: str, lat: float, lon: float, hours: int = 24
458
  ) -> Tuple[pd.DatetimeIndex, Dict[str, pd.Series]]:
 
421
  lon_vals = ds["lon"].values
422
  ilat = _nearest_index(lat_vals, lat)
423
  ilon = _nearest_index(lon_vals, lon)
424
+ # Include additional conventional names if present
425
+ candidates = [
426
+ "tcdcl195", "tcdcl196", "tcdcl197", "tcdchcll",
427
+ "tcdccll", "tcdcclm", "tcdcclh", # low/mid/high conventional
428
+ ]
429
  present = [v for v in candidates if v in ds.variables]
430
  if not present:
431
  return pd.DatetimeIndex([]), {}
 
441
  vals = np.clip(arr, 0, 100)
442
  # Label
443
  ln = str(ds[v].attrs.get("long_name", v))
444
+ if "high cloud" in ln.lower() or v in ("tcdchcll", "tcdcclh"):
445
  name = "High Cloud (%)"
446
+ elif v.endswith("195") or v == "tcdccll":
447
  name = "Layer 195 (%)"
448
+ elif v.endswith("196") or v == "tcdcclm":
449
  name = "Layer 196 (%)"
450
  elif v.endswith("197"):
451
  name = "Layer 197 (%)"
 
457
  return t, out
458
 
459
 
460
+ def _estimate_wetbulb_c(temp_k: np.ndarray, dpt_k: np.ndarray) -> np.ndarray:
461
+ """Approximate wet-bulb temperature in C from T and Td using Stull (2011) approximation."""
462
+ T = temp_k - 273.15
463
+ Td = dpt_k - 273.15
464
+ # relative humidity estimate
465
+ RH = 100.0 * np.clip(np.exp((17.625*Td)/(243.04+Td)) / np.exp((17.625*T)/(243.04+T)), 0.0, 1.2)
466
+ RH = np.clip(RH, 1.0, 100.0)
467
+ Tw = T*np.arctan(0.151977*(RH+8.313659)**0.5) + np.arctan(T+Td) - np.arctan(Td-1.676331) + 0.00391838*RH**1.5*np.arctan(0.023101*RH) - 4.686035
468
+ return Tw
469
+
470
+
471
+ def estimate_precip_type_probs_from_surface(
472
+ ds_url: str, lat: float, lon: float, hours: int
473
+ ) -> Tuple[pd.DatetimeIndex, Dict[str, pd.Series]]:
474
+ """Heuristic precip type probabilities from surface variables (T, Td, precip).
475
+
476
+ This is a fallback when categorical ptype vars are absent. It uses wet-bulb temperature
477
+ thresholds to assign soft probabilities among Rain, Snow, Freezing Rain, Sleet.
478
+ """
479
+ ds = _open_ds(ds_url)
480
+ lat_vals = ds["lat"].values
481
+ lon_vals = ds["lon"].values
482
+ ilat = _nearest_index(lat_vals, lat)
483
+ ilon = _nearest_index(lon_vals, lon)
484
+ t_full = _to_datetime_index(ds["time"]) # type: ignore
485
+ step = _infer_step_hours(t_full)
486
+ n_req = int(np.ceil(max(1, float(hours)) / step))
487
+ n = min(len(t_full), n_req)
488
+ t = t_full[:n]
489
+ T = _mask_fill(ds["tmp2m"].isel(lat=ilat, lon=ilon, time=slice(0, n)).values)
490
+ Td = _mask_fill(ds["dpt2m"].isel(lat=ilat, lon=ilon, time=slice(0, n)).values)
491
+ Tw = _estimate_wetbulb_c(T, Td)
492
+ # Probabilities from Tw in degC using smooth logistic transitions
493
+ import numpy as _np
494
+ def sigmoid(x):
495
+ return 1.0 / (1.0 + _np.exp(-x))
496
+ # Snow prob high when Tw <= -1C, rain when Tw >= 1.5C, transitions in between
497
+ p_snow = sigmoid((-Tw - 0.5) / 0.8) # shift/scale for smoothness
498
+ p_rain = sigmoid((Tw - 0.5) / 0.8)
499
+ # allocate remaining between sleet and freezing rain near 0C
500
+ mix = 1.0 - np.clip(p_snow + p_rain, 0, 1)
501
+ # split mix by T below/above 0C
502
+ p_fzra = mix * sigmoid((-Tw) / 0.7) # colder side
503
+ p_sleet = np.clip(mix - p_fzra, 0, 1)
504
+ out = {
505
+ "Snow": pd.Series(np.round(100.0 * np.clip(p_snow, 0, 1), 1), index=t),
506
+ "Rain": pd.Series(np.round(100.0 * np.clip(p_rain, 0, 1), 1), index=t),
507
+ "Freezing Rain": pd.Series(np.round(100.0 * np.clip(p_fzra, 0, 1), 1), index=t),
508
+ "Sleet": pd.Series(np.round(100.0 * np.clip(p_sleet, 0, 1), 1), index=t),
509
+ }
510
+ return t, out
511
+
512
+
513
+ def estimate_snow_exceedance_from_series(
514
+ time_index: pd.DatetimeIndex,
515
+ snowfall_in: np.ndarray,
516
+ temp_k: np.ndarray,
517
+ thresholds_in: List[float],
518
+ ) -> Dict[str, pd.Series]:
519
+ """Heuristic probability of exceedance for snowfall from mean +/- uncertainty.
520
+
521
+ Assumes lognormal uncertainty with coefficient of variation based on temperature.
522
+ """
523
+ import numpy as _np
524
+ S = np.asarray(snowfall_in, dtype=float)
525
+ T = np.asarray(temp_k, dtype=float)
526
+ # CV higher near freezing, lower when colder
527
+ Tc = T - 273.15
528
+ cv = np.clip(0.9 - 0.02 * (-(Tc - 0)), 0.4, 1.0) # ~0.9 near 0C -> ~0.5 when much colder
529
+ # Avoid negative/NaN
530
+ m = np.clip(S, 0, None)
531
+ # Lognormal parameters from mean and cv
532
+ sigma = np.sqrt(np.log(1.0 + cv**2))
533
+ # Avoid zeros: add small epsilon
534
+ eps = 1e-6
535
+ mu = np.log(np.maximum(m, eps)) - 0.5 * sigma**2
536
+ from scipy.stats import lognorm
537
+ out: Dict[str, pd.Series] = {}
538
+ for thr in thresholds_in:
539
+ cdf = lognorm.cdf(thr, s=sigma, scale=np.exp(mu))
540
+ poe = 100.0 * (1.0 - cdf)
541
+ out[f">= {thr:g} in"] = pd.Series(np.round(poe, 1), index=time_index)
542
+ return out
543
+
544
+
545
  def fetch_precip_type_probs(
546
  dataset_url: str, lat: float, lon: float, hours: int = 24
547
  ) -> Tuple[pd.DatetimeIndex, Dict[str, pd.Series]]:
plot_utils.py CHANGED
@@ -338,3 +338,54 @@ def make_wind_rose_fig(dir_deg: pd.Series, spd_mph: pd.Series) -> go.Figure:
338
  ),
339
  )
340
  return fig
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
338
  ),
339
  )
340
  return fig
341
+
342
+
343
+ def make_wind_rose_grid(
344
+ times: pd.DatetimeIndex,
345
+ dir_deg: pd.Series,
346
+ spd_mph: pd.Series,
347
+ step_hours: float,
348
+ max_panels: int = 24,
349
+ ) -> go.Figure:
350
+ """Render small-multiples wind roses per native time step.
351
+
352
+ Caps at `max_panels` panels for readability.
353
+ """
354
+ import numpy as np
355
+ from plotly.subplots import make_subplots
356
+
357
+ n = len(times)
358
+ if n == 0:
359
+ return go.Figure()
360
+ panels = min(n, max_panels)
361
+ cols = min(6, panels)
362
+ rows = int(np.ceil(panels / cols))
363
+ fig = make_subplots(rows=rows, cols=cols, specs=[[{"type": "polar"}]*cols for _ in range(rows)], subplot_titles=[t.strftime("%b %d %HZ") for t in times[:panels]])
364
+ # Binning config reused
365
+ dir_bins = np.arange(-11.25, 360 + 22.5, 22.5)
366
+ dir_labels = (dir_bins[:-1] + dir_bins[1:]) / 2.0
367
+ speed_edges = [0, 5, 10, 20, 1e6]
368
+ speed_labels = ["0-5", "5-10", "10-20", ">20"]
369
+ colors = ["#d0f0fd", "#86c5da", "#2ca02c", "#d62728"]
370
+ import numpy as _np
371
+ for idx in range(panels):
372
+ d = float(dir_deg.iloc[idx]) if _np.isfinite(dir_deg.iloc[idx]) else _np.nan
373
+ s = float(spd_mph.iloc[idx]) if _np.isfinite(spd_mph.iloc[idx]) else _np.nan
374
+ # Build a rose with single observation -> show as counts
375
+ hist = _np.histogram([d % 360.0] if _np.isfinite(d) else [], bins=dir_bins)[0]
376
+ # Place into one of the speed bins
377
+ bin_idx = 0
378
+ for i in range(len(speed_edges)-1):
379
+ if speed_edges[i] <= s < speed_edges[i+1]:
380
+ bin_idx = i
381
+ break
382
+ r_counts = [hist if i == bin_idx else _np.zeros_like(hist) for i in range(4)]
383
+ r = (idx // cols) + 1
384
+ c = (idx % cols) + 1
385
+ for i in range(4):
386
+ fig.add_trace(
387
+ go.Barpolar(r=r_counts[i], theta=dir_labels, name=speed_labels[i], marker_color=colors[i], opacity=0.9, showlegend=(idx==0)),
388
+ row=r, col=c,
389
+ )
390
+ fig.update_layout(margin=dict(l=30, r=30, t=40, b=30))
391
+ return fig