Spaces:
Sleeping
Sleeping
Add NBM Viewer (CSV) emulation: client + percentiles + PoE; integrate new tab
Browse files- .gitignore +4 -0
- README.md +5 -0
- app.py +172 -61
- nbm_viewer_client.py +75 -0
- nbm_viewer_emulation.py +217 -0
.gitignore
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.DS_Store
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.pyc
|
| 4 |
+
research/
|
README.md
CHANGED
|
@@ -19,6 +19,11 @@ How it works
|
|
| 19 |
- Retrieval: It opens the dataset via xarray+pydap and extracts a time series at the nearest grid point to your selected lat/lon.
|
| 20 |
- Output: A 24-hour table (configurable) showing temperature (F), dewpoint (F), wind/gust (mph), total cloud cover (%), and 1-hour precipitation (inches). Times are UTC.
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
Limitations
|
| 23 |
- Current dataset selection targets the hourly CONUS grid (approximately 19–57N, 138–59W). Points near or outside this domain will snap to the nearest grid edge. Alaska is generally outside the CONUS hourly grid; Hawaii is mostly covered.
|
| 24 |
- NOMADS availability varies during updates; if a run is in transition, try again in a few minutes.
|
|
|
|
| 19 |
- Retrieval: It opens the dataset via xarray+pydap and extracts a time series at the nearest grid point to your selected lat/lon.
|
| 20 |
- Output: A 24-hour table (configurable) showing temperature (F), dewpoint (F), wind/gust (mph), total cloud cover (%), and 1-hour precipitation (inches). Times are UTC.
|
| 21 |
|
| 22 |
+
NBM Viewer (CSV) emulation
|
| 23 |
+
- A separate tab “NBM Viewer (CSV)” uses the official NBM 1D Viewer’s public archive endpoints to fetch a per-location CSV (e.g., Bridgers.csv) for a chosen Year/Month/Day/Version/Hour.
|
| 24 |
+
- It renders Max/Min Temperature percentiles with shaded 10–90% whiskers and 25–75% box bands, plus deterministic TMAX/TMIN markers, matching the NBM Viewer’s style.
|
| 25 |
+
- It also computes a Probability-of-Exceedance time series for a chosen field, operator (>= or <=), and threshold (e.g., Tmax >= 40°F), using either ensemble std-dev where present or linear interpolation between available percentiles (mirroring the Viewer’s logic).
|
| 26 |
+
|
| 27 |
Limitations
|
| 28 |
- Current dataset selection targets the hourly CONUS grid (approximately 19–57N, 138–59W). Points near or outside this domain will snap to the nearest grid edge. Alaska is generally outside the CONUS hourly grid; Hawaii is mostly covered.
|
| 29 |
- NOMADS availability varies during updates; if a run is in transition, try again in a few minutes.
|
app.py
CHANGED
|
@@ -27,6 +27,20 @@ from plot_utils import (
|
|
| 27 |
make_wind_rose_fig,
|
| 28 |
make_wind_rose_grid,
|
| 29 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
|
| 32 |
INTRO = (
|
|
@@ -280,11 +294,13 @@ with gr.Blocks(title="NBM Point Forecast (NOAA NOMADS)") as demo:
|
|
| 280 |
gr.Markdown("# NBM Point Forecast (NOAA NOMADS)")
|
| 281 |
gr.Markdown(INTRO)
|
| 282 |
|
| 283 |
-
with gr.
|
| 284 |
-
with gr.
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
|
|
|
|
|
|
| 288 |
<div id=\"leaflet_map\" style=\"height:520px;border:1px solid #ccc;\"></div>
|
| 289 |
<link rel=\"stylesheet\" href=\"https://unpkg.com/leaflet@1.9.4/dist/leaflet.css\" crossorigin=\"\" />
|
| 290 |
<script src=\"https://unpkg.com/leaflet@1.9.4/dist/leaflet.js\" crossorigin=\"\"></script>
|
|
@@ -317,67 +333,162 @@ with gr.Blocks(title="NBM Point Forecast (NOAA NOMADS)") as demo:
|
|
| 317 |
</script>
|
| 318 |
""",
|
| 319 |
label="Map (click to set point)",
|
| 320 |
-
|
| 321 |
|
| 322 |
-
|
| 323 |
-
|
| 324 |
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 333 |
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
)
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
"dewpoint_F",
|
| 345 |
-
"wind_mph",
|
| 346 |
-
"gust_mph",
|
| 347 |
-
"cloud_cover_pct",
|
| 348 |
-
"precip_in",
|
| 349 |
-
],
|
| 350 |
-
label="NBM hourly forecast",
|
| 351 |
-
wrap=True,
|
| 352 |
-
row_count=(0, "dynamic"),
|
| 353 |
)
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 381 |
|
| 382 |
|
| 383 |
if __name__ == "__main__":
|
|
|
|
| 27 |
make_wind_rose_fig,
|
| 28 |
make_wind_rose_grid,
|
| 29 |
)
|
| 30 |
+
from nbm_viewer_client import (
|
| 31 |
+
list_years,
|
| 32 |
+
list_months,
|
| 33 |
+
list_days,
|
| 34 |
+
list_versions,
|
| 35 |
+
list_hours,
|
| 36 |
+
list_locations,
|
| 37 |
+
fetch_location_csv,
|
| 38 |
+
)
|
| 39 |
+
from nbm_viewer_emulation import (
|
| 40 |
+
make_temp_maxmin_percentile_figure,
|
| 41 |
+
prob_exceed_series,
|
| 42 |
+
make_prob_exceed_figure,
|
| 43 |
+
)
|
| 44 |
|
| 45 |
|
| 46 |
INTRO = (
|
|
|
|
| 294 |
gr.Markdown("# NBM Point Forecast (NOAA NOMADS)")
|
| 295 |
gr.Markdown(INTRO)
|
| 296 |
|
| 297 |
+
with gr.Tabs():
|
| 298 |
+
with gr.TabItem("NOMADS (1-hr/3-hr)"):
|
| 299 |
+
with gr.Row():
|
| 300 |
+
with gr.Column(scale=3):
|
| 301 |
+
# Leaflet map embedded via HTML; clicks update lat/lon inputs below.
|
| 302 |
+
map_html = gr.HTML(
|
| 303 |
+
value="""
|
| 304 |
<div id=\"leaflet_map\" style=\"height:520px;border:1px solid #ccc;\"></div>
|
| 305 |
<link rel=\"stylesheet\" href=\"https://unpkg.com/leaflet@1.9.4/dist/leaflet.css\" crossorigin=\"\" />
|
| 306 |
<script src=\"https://unpkg.com/leaflet@1.9.4/dist/leaflet.js\" crossorigin=\"\"></script>
|
|
|
|
| 333 |
</script>
|
| 334 |
""",
|
| 335 |
label="Map (click to set point)",
|
| 336 |
+
)
|
| 337 |
|
| 338 |
+
lat_in = gr.Number(label="Latitude", value=None, elem_id="lat_input")
|
| 339 |
+
lon_in = gr.Number(label="Longitude", value=None, elem_id="lon_input")
|
| 340 |
|
| 341 |
+
hours = gr.Slider(
|
| 342 |
+
minimum=6,
|
| 343 |
+
maximum=240,
|
| 344 |
+
value=24,
|
| 345 |
+
step=3,
|
| 346 |
+
label="Hours to fetch (1-hr <=36h, 3-hr beyond)",
|
| 347 |
+
)
|
| 348 |
+
btn = gr.Button("Fetch NBM Forecast")
|
| 349 |
+
|
| 350 |
+
with gr.Column(scale=5):
|
| 351 |
+
status = gr.Textbox(
|
| 352 |
+
label="Status",
|
| 353 |
+
value="Ready",
|
| 354 |
+
interactive=False,
|
| 355 |
+
)
|
| 356 |
+
table = gr.Dataframe(
|
| 357 |
+
headers=[
|
| 358 |
+
"time_utc",
|
| 359 |
+
"temp_F",
|
| 360 |
+
"dewpoint_F",
|
| 361 |
+
"wind_mph",
|
| 362 |
+
"gust_mph",
|
| 363 |
+
"cloud_cover_pct",
|
| 364 |
+
"precip_in",
|
| 365 |
+
],
|
| 366 |
+
label="NBM hourly forecast",
|
| 367 |
+
wrap=True,
|
| 368 |
+
row_count=(0, "dynamic"),
|
| 369 |
+
)
|
| 370 |
+
temp_wind_plot = gr.Plot(label="Temp/Dewpoint/Wind")
|
| 371 |
+
cloud_precip_plot = gr.Plot(label="Clouds and Precip")
|
| 372 |
+
snow_prob_plot = gr.Plot(label="Snow Probabilities (exceedance)")
|
| 373 |
+
snow6_plot = gr.Plot(label="6 hr Snow + Accum")
|
| 374 |
+
snow24_plot = gr.Plot(label="24 hr Snowfall")
|
| 375 |
+
snow48_plot = gr.Plot(label="48 hr Snowfall")
|
| 376 |
+
cloud_layers_plot = gr.Plot(label="Cloud Layers (%)")
|
| 377 |
+
precip_type_plot = gr.Plot(label="Precip Type Probabilities")
|
| 378 |
+
snow_level_plot = gr.Plot(label="Snow Level + Precip")
|
| 379 |
+
wind_rose_plot = gr.Plot(label="Wind Rose (10 m)")
|
| 380 |
|
| 381 |
+
# Triggers for NOMADS tab
|
| 382 |
+
btn.click(
|
| 383 |
+
run_forecast,
|
| 384 |
+
inputs=[lat_in, lon_in, hours],
|
| 385 |
+
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],
|
| 386 |
)
|
| 387 |
+
lat_in.change(
|
| 388 |
+
run_forecast,
|
| 389 |
+
inputs=[lat_in, lon_in, hours],
|
| 390 |
+
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],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 391 |
)
|
| 392 |
+
lon_in.change(
|
| 393 |
+
run_forecast,
|
| 394 |
+
inputs=[lat_in, lon_in, hours],
|
| 395 |
+
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],
|
| 396 |
+
)
|
| 397 |
+
|
| 398 |
+
with gr.TabItem("NBM Viewer (CSV)"):
|
| 399 |
+
gr.Markdown("Emulate the NBM 1D Viewer using its CSV archive: box/whisker and probability charts.")
|
| 400 |
+
with gr.Row():
|
| 401 |
+
with gr.Column(scale=3):
|
| 402 |
+
year = gr.Dropdown(label="Year", choices=[], value=None)
|
| 403 |
+
month = gr.Dropdown(label="Month", choices=[], value=None)
|
| 404 |
+
day = gr.Dropdown(label="Day", choices=[], value=None)
|
| 405 |
+
version = gr.Dropdown(label="Version", choices=[], value=None)
|
| 406 |
+
hour = gr.Dropdown(label="Hour (UTC)", choices=[], value=None)
|
| 407 |
+
location = gr.Dropdown(label="Location (NBM Viewer)", choices=[], value=None)
|
| 408 |
+
load_btn = gr.Button("Load Viewer CSV")
|
| 409 |
+
with gr.Column(scale=5):
|
| 410 |
+
viewer_status = gr.Textbox(label="Status", value="Ready", interactive=False)
|
| 411 |
+
maxmin_fig = gr.Plot(label="Max/Min T Percentiles")
|
| 412 |
+
with gr.Row():
|
| 413 |
+
prob_field = gr.Textbox(label="Prob Field (e.g., TMP_Max_2 m above ground)", value="TMP_Max_2 m above ground")
|
| 414 |
+
prob_op = gr.Radio(label="Operator", choices=[">=","<="], value=">=")
|
| 415 |
+
prob_value = gr.Number(label="Threshold (F)", value=40)
|
| 416 |
+
make_prob = gr.Button("Compute Probability")
|
| 417 |
+
prob_fig = gr.Plot(label="Probability of Exceedance")
|
| 418 |
+
|
| 419 |
+
# Populate cascading date selectors
|
| 420 |
+
def _init_years():
|
| 421 |
+
try:
|
| 422 |
+
ys = list_years()
|
| 423 |
+
except Exception as e:
|
| 424 |
+
ys = []
|
| 425 |
+
return gr.update(choices=ys, value=(ys[-1] if ys else None)), "Years loaded." if ys else "No years."
|
| 426 |
+
|
| 427 |
+
def _on_year(y):
|
| 428 |
+
ms = list_months(y) if y else []
|
| 429 |
+
return gr.update(choices=ms, value=(ms[-1] if ms else None))
|
| 430 |
+
|
| 431 |
+
def _on_month(y, m):
|
| 432 |
+
ds = list_days(y, m) if (y and m) else []
|
| 433 |
+
return gr.update(choices=ds, value=(ds[-1] if ds else None))
|
| 434 |
+
|
| 435 |
+
def _on_day(y, m, d):
|
| 436 |
+
vs = list_versions(y, m, d) if (y and m and d) else []
|
| 437 |
+
return gr.update(choices=vs, value=(vs[0] if vs else None))
|
| 438 |
+
|
| 439 |
+
def _on_version(y, m, d, v):
|
| 440 |
+
hs = list_hours(y, m, d, v) if (y and m and d and v) else []
|
| 441 |
+
# Use latest available hour by default
|
| 442 |
+
return gr.update(choices=hs, value=(hs[-1] if hs else None))
|
| 443 |
+
|
| 444 |
+
def _on_hour(y, m, d, v, h):
|
| 445 |
+
locs = list_locations(y, m, d, v, h) if (y and m and d and v and h) else []
|
| 446 |
+
# Keep list manageable
|
| 447 |
+
# Preselect a common mountainous example if present
|
| 448 |
+
sel = None
|
| 449 |
+
for cand in ("Bridgers", "Bridger", "Alta", "Aspen Highland Peak"):
|
| 450 |
+
if cand in locs:
|
| 451 |
+
sel = cand
|
| 452 |
+
break
|
| 453 |
+
if not sel and locs:
|
| 454 |
+
sel = locs[0]
|
| 455 |
+
return gr.update(choices=locs, value=sel)
|
| 456 |
+
|
| 457 |
+
def _load_csv(y, m, d, v, h, loc):
|
| 458 |
+
try:
|
| 459 |
+
df = fetch_location_csv(y, m, d, v, h, loc)
|
| 460 |
+
except Exception as e:
|
| 461 |
+
return (f"Failed to load CSV: {e}", None)
|
| 462 |
+
try:
|
| 463 |
+
fig = make_temp_maxmin_percentile_figure(df)
|
| 464 |
+
return (f"Loaded {loc}.csv at {y}/{m}/{d} {v} {h}Z", fig, df)
|
| 465 |
+
except Exception as e:
|
| 466 |
+
return (f"Loaded CSV but plot failed: {e}", None, df)
|
| 467 |
+
|
| 468 |
+
def _make_prob(df: pd.DataFrame, field: str, op: str, val: float):
|
| 469 |
+
if df is None or len(df) == 0:
|
| 470 |
+
return "Load CSV first.", None
|
| 471 |
+
poe = prob_exceed_series(df, field=field, operator=("<=" if op == "<=" else ">="), threshold_value=float(val), units='F')
|
| 472 |
+
fig = make_prob_exceed_figure(poe.index, poe, title=f"Prob {field} {op} {val}")
|
| 473 |
+
return "OK", fig
|
| 474 |
+
|
| 475 |
+
yr_init, msg = _init_years()
|
| 476 |
+
year.update(**yr_init)
|
| 477 |
+
viewer_status.value = msg
|
| 478 |
+
|
| 479 |
+
year.change(_on_year, inputs=[year], outputs=[month])
|
| 480 |
+
month.change(_on_month, inputs=[year, month], outputs=[day])
|
| 481 |
+
day.change(_on_day, inputs=[year, month, day], outputs=[version])
|
| 482 |
+
version.change(_on_version, inputs=[year, month, day, version], outputs=[hour])
|
| 483 |
+
hour.change(_on_hour, inputs=[year, month, day, version, hour], outputs=[location])
|
| 484 |
+
|
| 485 |
+
csv_state = gr.State(value=None)
|
| 486 |
+
def _load_and_store(y, m, d, v, h, loc):
|
| 487 |
+
status, fig, df = _load_csv(y, m, d, v, h, loc)
|
| 488 |
+
return status, fig, df
|
| 489 |
+
|
| 490 |
+
load_btn.click(_load_and_store, inputs=[year, month, day, version, hour, location], outputs=[viewer_status, maxmin_fig, csv_state])
|
| 491 |
+
make_prob.click(_make_prob, inputs=[csv_state, prob_field, prob_op, prob_value], outputs=[viewer_status, prob_fig])
|
| 492 |
|
| 493 |
|
| 494 |
if __name__ == "__main__":
|
nbm_viewer_client.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
from dataclasses import dataclass
|
| 3 |
+
from typing import Dict, List, Tuple, Optional
|
| 4 |
+
|
| 5 |
+
import pandas as pd
|
| 6 |
+
import requests
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
BASE_VIEWER = "https://apps.gsl.noaa.gov/nbmviewer"
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def _get_json(url: str):
|
| 13 |
+
r = requests.get(url, timeout=20)
|
| 14 |
+
r.raise_for_status()
|
| 15 |
+
return r.json()
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def list_years() -> List[str]:
|
| 19 |
+
return _get_json(f"{BASE_VIEWER}/getdates?prefix=/")
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def list_months(year: str) -> List[str]:
|
| 23 |
+
return _get_json(f"{BASE_VIEWER}/getdates?prefix=/{year}")
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def list_days(year: str, month: str) -> List[str]:
|
| 27 |
+
return _get_json(f"{BASE_VIEWER}/getdates?prefix=/{year}/{month}")
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def list_versions(year: str, month: str, day: str) -> List[str]:
|
| 31 |
+
return _get_json(f"{BASE_VIEWER}/getdates?prefix=/{year}/{month}/{day}")
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def list_hours(year: str, month: str, day: str, version: str) -> List[str]:
|
| 35 |
+
return _get_json(f"{BASE_VIEWER}/getdates?prefix=/{year}/{month}/{day}/{version}")
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def list_locations(year: str, month: str, day: str, version: str, hour: str) -> List[str]:
|
| 39 |
+
# Returns a list of csv filenames; strip .csv before returning
|
| 40 |
+
arr = _get_json(f"{BASE_VIEWER}/getdates?prefix=/{year}/{month}/{day}/{version}/{hour}")
|
| 41 |
+
arr = [x for x in arr if x.endswith('.csv') and not x.strip().startswith('.')]
|
| 42 |
+
return [x[:-4] for x in arr]
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def fetch_location_csv(year: str, month: str, day: str, version: str, hour: str, location: str) -> pd.DataFrame:
|
| 46 |
+
url = f"{BASE_VIEWER}/data/archive/{year}/{month}/{day}/{version}/{hour}/{location}.csv"
|
| 47 |
+
df = pd.read_csv(url)
|
| 48 |
+
# Ensure time index
|
| 49 |
+
if 'ValidTime' in df.columns:
|
| 50 |
+
# strings like YYYYMMDDHH
|
| 51 |
+
t = pd.to_datetime(df['ValidTime'].astype(str), format='%Y%m%d%H', utc=True, errors='coerce')
|
| 52 |
+
df.insert(0, 'time_utc', t)
|
| 53 |
+
return df
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def available_percentiles(df: pd.DataFrame, base_field: str) -> List[int]:
|
| 57 |
+
# Detect columns like "<base>_10% level"
|
| 58 |
+
patt = re.compile(re.escape(base_field) + r"_(\d+)% level$")
|
| 59 |
+
ps: List[int] = []
|
| 60 |
+
for c in df.columns:
|
| 61 |
+
m = patt.search(c)
|
| 62 |
+
if m:
|
| 63 |
+
try:
|
| 64 |
+
ps.append(int(m.group(1)))
|
| 65 |
+
except Exception:
|
| 66 |
+
pass
|
| 67 |
+
return sorted(list(set(ps)))
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def get_series(df: pd.DataFrame, col: str) -> Optional[pd.Series]:
|
| 71 |
+
if col in df.columns:
|
| 72 |
+
return pd.Series(df[col].values, index=pd.DatetimeIndex(df['time_utc']))
|
| 73 |
+
return None
|
| 74 |
+
|
| 75 |
+
|
nbm_viewer_emulation.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import math
|
| 4 |
+
import re
|
| 5 |
+
from typing import Dict, List, Optional, Tuple
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
import pandas as pd
|
| 9 |
+
import plotly.graph_objects as go
|
| 10 |
+
from scipy.stats import norm
|
| 11 |
+
|
| 12 |
+
from nbm_viewer_client import available_percentiles, get_series
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def k_to_f(x: pd.Series | np.ndarray) -> pd.Series:
|
| 16 |
+
v = pd.Series(x, copy=False).astype(float)
|
| 17 |
+
return v * 9.0 / 5.0 - 459.67
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def mps_to_mph(x: pd.Series | np.ndarray) -> pd.Series:
|
| 21 |
+
return pd.Series(x, copy=False).astype(float) * 2.23693629
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def mm_to_in(x: pd.Series | np.ndarray) -> pd.Series:
|
| 25 |
+
return pd.Series(x, copy=False).astype(float) / 25.4
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def make_temp_maxmin_percentile_figure(
|
| 29 |
+
df: pd.DataFrame,
|
| 30 |
+
whiskers: Tuple[int, int] = (10, 90),
|
| 31 |
+
box: Tuple[int, int] = (25, 75),
|
| 32 |
+
) -> go.Figure:
|
| 33 |
+
"""Emulate the NBM Viewer Max/Min temperature percentile panel.
|
| 34 |
+
|
| 35 |
+
- Uses TMP_Max_2 m above ground and TMP_Min_2 m above ground percentiles
|
| 36 |
+
- Overlays deterministic TMAX12hr and TMIN12hr circles
|
| 37 |
+
- Default whiskers=10/90, box=25/75
|
| 38 |
+
"""
|
| 39 |
+
t = pd.DatetimeIndex(df['time_utc'])
|
| 40 |
+
fig = go.Figure()
|
| 41 |
+
|
| 42 |
+
def add_field(field: str, color: str, det_field: str, show_legend_prefix: str):
|
| 43 |
+
p_avail = available_percentiles(df, field)
|
| 44 |
+
if not p_avail:
|
| 45 |
+
return
|
| 46 |
+
p_lo_w, p_hi_w = whiskers
|
| 47 |
+
p_lo_b, p_hi_b = box
|
| 48 |
+
lo_w = get_series(df, f"{field}_{p_lo_w}% level")
|
| 49 |
+
hi_w = get_series(df, f"{field}_{p_hi_w}% level")
|
| 50 |
+
lo_b = get_series(df, f"{field}_{p_lo_b}% level")
|
| 51 |
+
hi_b = get_series(df, f"{field}_{p_hi_b}% level")
|
| 52 |
+
p50 = get_series(df, f"{field}_50% level")
|
| 53 |
+
# Convert K->F
|
| 54 |
+
if lo_w is not None and hi_w is not None:
|
| 55 |
+
fig.add_trace(go.Scatter(x=t, y=k_to_f(hi_w), line=dict(color=color, width=0), showlegend=False))
|
| 56 |
+
fig.add_trace(go.Scatter(x=t, y=k_to_f(lo_w), line=dict(color=color, width=0), fill='tonexty', name=f"{show_legend_prefix} {p_lo_w}-{p_hi_w}%", opacity=0.15))
|
| 57 |
+
if lo_b is not None and hi_b is not None:
|
| 58 |
+
fig.add_trace(go.Scatter(x=t, y=k_to_f(hi_b), line=dict(color=color, width=1, dash='dot'), showlegend=False))
|
| 59 |
+
fig.add_trace(go.Scatter(x=t, y=k_to_f(lo_b), line=dict(color=color, width=1, dash='dot'), fill='tonexty', name=f"{show_legend_prefix} {p_lo_b}-{p_hi_b}%", opacity=0.25))
|
| 60 |
+
if p50 is not None:
|
| 61 |
+
fig.add_trace(go.Scatter(x=t, y=k_to_f(p50), name=f"{show_legend_prefix} 50%", mode='lines', line=dict(color=color, width=3, shape='hv')))
|
| 62 |
+
det = get_series(df, det_field)
|
| 63 |
+
if det is not None:
|
| 64 |
+
fig.add_trace(go.Scatter(x=t, y=k_to_f(det), name=f"{show_legend_prefix} Deterministic", mode='markers', marker=dict(size=4, color=color)))
|
| 65 |
+
|
| 66 |
+
add_field("TMP_Max_2 m above ground", color="rgba(255,59,58,1.0)", det_field="TMAX12hr_2 m above ground", show_legend_prefix="Max T")
|
| 67 |
+
add_field("TMP_Min_2 m above ground", color="rgba(115,197,243,1.0)", det_field="TMIN12hr_2 m above ground", show_legend_prefix="Min T")
|
| 68 |
+
|
| 69 |
+
fig.update_layout(
|
| 70 |
+
margin=dict(l=40, r=40, t=30, b=40),
|
| 71 |
+
xaxis=dict(title="Time (UTC)"),
|
| 72 |
+
yaxis=dict(title="Temperature (F)", rangemode="tozero"),
|
| 73 |
+
legend=dict(orientation='h', yanchor='bottom', y=1.02, xanchor='right', x=1),
|
| 74 |
+
)
|
| 75 |
+
return fig
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _std_prob_exceed(mean: float, std: float, threshold: float, operator: str) -> float:
|
| 79 |
+
if not math.isfinite(mean) or not math.isfinite(std) or std <= 0:
|
| 80 |
+
return float('nan')
|
| 81 |
+
z = (threshold - mean) / std
|
| 82 |
+
c = norm.cdf(z)
|
| 83 |
+
return 100.0 * (c if operator == '<=' else (1.0 - c))
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _interp_percentile_for_threshold(values: List[Tuple[int, float]], threshold: float, operator: str) -> float:
|
| 87 |
+
"""Mirror the viewer's linear interpolation between percentile points to find P(X <= thr).
|
| 88 |
+
values: list of (percentile, value) pairs sorted by percentile.
|
| 89 |
+
Returns percentile (0..100)."""
|
| 90 |
+
if not values:
|
| 91 |
+
return float('nan')
|
| 92 |
+
vals = sorted(values, key=lambda x: x[0])
|
| 93 |
+
ps = [p for p, _ in vals]
|
| 94 |
+
xs = [x for _, x in vals]
|
| 95 |
+
# Handle monotonicity quirks by enforcing nondecreasing sequence (as in JS fix)
|
| 96 |
+
last = xs[0]
|
| 97 |
+
inc = None
|
| 98 |
+
for i in range(1, len(xs)):
|
| 99 |
+
if inc is None:
|
| 100 |
+
if xs[i] > last:
|
| 101 |
+
inc = True
|
| 102 |
+
elif xs[i] < last:
|
| 103 |
+
inc = False
|
| 104 |
+
if inc is True and xs[i] < last:
|
| 105 |
+
xs[i] = last
|
| 106 |
+
if inc is False and xs[i] > last:
|
| 107 |
+
xs[i] = last
|
| 108 |
+
last = xs[i]
|
| 109 |
+
|
| 110 |
+
# If threshold below min, percentile near first two
|
| 111 |
+
if threshold <= xs[0]:
|
| 112 |
+
p = ps[0]
|
| 113 |
+
elif threshold >= xs[-1]:
|
| 114 |
+
p = ps[-1]
|
| 115 |
+
else:
|
| 116 |
+
# Find bracketing segment and linearly interpolate in x between percentiles
|
| 117 |
+
p = ps[-1]
|
| 118 |
+
for i in range(len(xs) - 1):
|
| 119 |
+
x0, x1 = xs[i], xs[i + 1]
|
| 120 |
+
if x0 <= threshold <= x1 or x1 <= threshold <= x0:
|
| 121 |
+
# Line in percentile-x space: (p, x)
|
| 122 |
+
p0, p1 = ps[i], ps[i + 1]
|
| 123 |
+
if x1 == x0:
|
| 124 |
+
p = p1
|
| 125 |
+
else:
|
| 126 |
+
frac = (threshold - x0) / (x1 - x0)
|
| 127 |
+
p = p0 + frac * (p1 - p0)
|
| 128 |
+
break
|
| 129 |
+
# Return probability of exceed/under
|
| 130 |
+
return p if operator == '<=' else (100.0 - p)
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def prob_exceed_series(
|
| 134 |
+
df: pd.DataFrame,
|
| 135 |
+
field: str,
|
| 136 |
+
operator: str,
|
| 137 |
+
threshold_value: float,
|
| 138 |
+
units: str = 'auto', # 'K','F','mps','mph','mm','in','%' or 'auto'
|
| 139 |
+
) -> pd.Series:
|
| 140 |
+
"""Compute probability of exceedance like the viewer.
|
| 141 |
+
|
| 142 |
+
If a std-dev column exists, use Gaussian with mean/std.
|
| 143 |
+
Otherwise, interpolate among available percentiles.
|
| 144 |
+
"""
|
| 145 |
+
idx = pd.DatetimeIndex(df['time_utc'])
|
| 146 |
+
# Unit normalization
|
| 147 |
+
thr = threshold_value
|
| 148 |
+
# Temperature fields use Kelvin in CSV
|
| 149 |
+
if 'TMP_' in field or field.endswith('2 m above ground') or field.endswith('_ level'):
|
| 150 |
+
# Heuristic: convert F->K if range suggests F
|
| 151 |
+
if units in ('auto', 'F'):
|
| 152 |
+
if threshold_value > 180: # it's almost certainly F
|
| 153 |
+
thr = (threshold_value + 459.67) * 5.0 / 9.0
|
| 154 |
+
else:
|
| 155 |
+
# If user passed C or K, do nothing for now
|
| 156 |
+
pass
|
| 157 |
+
elif units == 'F':
|
| 158 |
+
thr = (threshold_value + 459.67) * 5.0 / 9.0
|
| 159 |
+
|
| 160 |
+
out = []
|
| 161 |
+
# Use std-dev if present (ens std dev)
|
| 162 |
+
mean_col = None
|
| 163 |
+
std_col = None
|
| 164 |
+
|
| 165 |
+
# Common std-dev naming
|
| 166 |
+
if f"{field}_ens std dev" in df.columns:
|
| 167 |
+
std_col = f"{field}_ens std dev"
|
| 168 |
+
if f"{field}_ens mean" in df.columns:
|
| 169 |
+
mean_col = f"{field}_ens mean"
|
| 170 |
+
elif f"{field}" in df.columns and std_col:
|
| 171 |
+
mean_col = field
|
| 172 |
+
|
| 173 |
+
if mean_col and std_col:
|
| 174 |
+
m = df[mean_col].astype(float).values
|
| 175 |
+
s = df[std_col].astype(float).values
|
| 176 |
+
for i in range(len(idx)):
|
| 177 |
+
out.append(_std_prob_exceed(m[i], s[i], thr, operator))
|
| 178 |
+
return pd.Series(np.asarray(out, dtype=float), index=idx)
|
| 179 |
+
|
| 180 |
+
# Else interpolate in percentiles
|
| 181 |
+
ps = available_percentiles(df, field)
|
| 182 |
+
if len(ps) < 2:
|
| 183 |
+
return pd.Series(np.full(len(idx), np.nan), index=idx)
|
| 184 |
+
# Collect the series for each percentile
|
| 185 |
+
series_map: Dict[int, pd.Series] = {}
|
| 186 |
+
for p in ps:
|
| 187 |
+
s = get_series(df, f"{field}_{p}% level")
|
| 188 |
+
if s is not None:
|
| 189 |
+
series_map[p] = s.astype(float)
|
| 190 |
+
ps_sorted = sorted(series_map.keys())
|
| 191 |
+
for i, t in enumerate(idx):
|
| 192 |
+
vals: List[Tuple[int, float]] = []
|
| 193 |
+
for p in ps_sorted:
|
| 194 |
+
try:
|
| 195 |
+
val = float(series_map[p].loc[t])
|
| 196 |
+
except Exception:
|
| 197 |
+
val = float('nan')
|
| 198 |
+
if math.isfinite(val):
|
| 199 |
+
vals.append((p, val))
|
| 200 |
+
out.append(_interp_percentile_for_threshold(vals, thr, operator))
|
| 201 |
+
return pd.Series(np.asarray(out, dtype=float), index=idx)
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def make_prob_exceed_figure(
|
| 205 |
+
idx: pd.DatetimeIndex, poe: pd.Series, title: Optional[str] = None
|
| 206 |
+
) -> go.Figure:
|
| 207 |
+
fig = go.Figure()
|
| 208 |
+
fig.add_trace(go.Scatter(x=idx, y=poe.values, mode='lines', line=dict(width=4)))
|
| 209 |
+
fig.update_layout(
|
| 210 |
+
margin=dict(l=40, r=40, t=30, b=40),
|
| 211 |
+
xaxis=dict(title="Time (UTC)"),
|
| 212 |
+
yaxis=dict(title="Prob. Exceedance (%)", range=[0, 100]),
|
| 213 |
+
title=title or None,
|
| 214 |
+
)
|
| 215 |
+
return fig
|
| 216 |
+
|
| 217 |
+
|