nakas commited on
Commit
59289c3
·
1 Parent(s): 5470d16

Add NBM Viewer (CSV) emulation: client + percentiles + PoE; integrate new tab

Browse files
Files changed (5) hide show
  1. .gitignore +4 -0
  2. README.md +5 -0
  3. app.py +172 -61
  4. nbm_viewer_client.py +75 -0
  5. 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.Row():
284
- with gr.Column(scale=3):
285
- # Leaflet map embedded via HTML; clicks update lat/lon inputs below.
286
- map_html = gr.HTML(
287
- value="""
 
 
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
- lat_in = gr.Number(label="Latitude", value=None, elem_id="lat_input")
323
- lon_in = gr.Number(label="Longitude", value=None, elem_id="lon_input")
324
 
325
- hours = gr.Slider(
326
- minimum=6,
327
- maximum=240,
328
- value=24,
329
- step=3,
330
- label="Hours to fetch (1-hr <=36h, 3-hr beyond)",
331
- )
332
- btn = gr.Button("Fetch NBM Forecast")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
333
 
334
- with gr.Column(scale=5):
335
- status = gr.Textbox(
336
- label="Status",
337
- value="Ready",
338
- interactive=False,
339
  )
340
- table = gr.Dataframe(
341
- headers=[
342
- "time_utc",
343
- "temp_F",
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
- temp_wind_plot = gr.Plot(label="Temp/Dewpoint/Wind")
355
- cloud_precip_plot = gr.Plot(label="Clouds and Precip")
356
- snow_prob_plot = gr.Plot(label="Snow Probabilities (exceedance)")
357
- snow6_plot = gr.Plot(label="6 hr Snow + Accum")
358
- snow24_plot = gr.Plot(label="24 hr Snowfall")
359
- snow48_plot = gr.Plot(label="48 hr Snowfall")
360
- cloud_layers_plot = gr.Plot(label="Cloud Layers (%)")
361
- precip_type_plot = gr.Plot(label="Precip Type Probabilities")
362
- snow_level_plot = gr.Plot(label="Snow Level + Precip")
363
- wind_rose_plot = gr.Plot(label="Wind Rose (10 m)")
364
-
365
- # Trigger when clicking Fetch, or when lat/lon are edited (e.g., via map)
366
- btn.click(
367
- run_forecast,
368
- inputs=[lat_in, lon_in, hours],
369
- 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],
370
- )
371
- lat_in.change(
372
- run_forecast,
373
- inputs=[lat_in, lon_in, hours],
374
- 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],
375
- )
376
- lon_in.change(
377
- run_forecast,
378
- inputs=[lat_in, lon_in, hours],
379
- 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],
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
+