Spaces:
Sleeping
Sleeping
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- app.py +19 -3
- nbm_client.py +33 -6
- 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 |
-
#
|
| 459 |
candidate: Optional[str] = None
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|