Spaces:
Sleeping
Sleeping
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- app.py +21 -3
- nbm_client.py +93 -4
- 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
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|