Spaces:
Sleeping
Sleeping
feat: add cloud layers plot (tcdcl195/196/197,tcdchcll), precip type probabilities (Rain/Snow/FZRA/Sleet), and snow level + precip panel; show these when vars are present
Browse files- app.py +81 -5
- nbm_client.py +118 -0
- plot_utils.py +89 -0
app.py
CHANGED
|
@@ -10,6 +10,9 @@ from nbm_client import (
|
|
| 10 |
fetch_point_probabilities,
|
| 11 |
get_latest_hourly_dataset_url,
|
| 12 |
get_latest_3hr_dataset_url,
|
|
|
|
|
|
|
|
|
|
| 13 |
)
|
| 14 |
from plot_utils import (
|
| 15 |
make_temp_dew_wind_fig,
|
|
@@ -17,6 +20,9 @@ from plot_utils import (
|
|
| 17 |
make_snow_prob_fig,
|
| 18 |
make_snow_6h_accum_fig,
|
| 19 |
make_window_snow_fig,
|
|
|
|
|
|
|
|
|
|
| 20 |
)
|
| 21 |
|
| 22 |
|
|
@@ -36,11 +42,26 @@ def run_forecast(lat, lon, hours=24):
|
|
| 36 |
temp_wind_fig = None
|
| 37 |
cloud_precip_fig = None
|
| 38 |
snow_prob_fig = None
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
def y(msg):
|
| 41 |
print(msg, flush=True)
|
| 42 |
elapsed = time.perf_counter() - t0
|
| 43 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
if lat is None or lon is None:
|
| 46 |
yield y("Click map or enter lat/lon.")
|
|
@@ -101,6 +122,46 @@ def run_forecast(lat, lon, hours=24):
|
|
| 101 |
print(f"Probability fetch/plot error: {e}")
|
| 102 |
snow_prob_fig = None
|
| 103 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
# Deterministic snowfall derivations if available
|
| 105 |
snow6_fig = None
|
| 106 |
snow24_fig = None
|
|
@@ -141,7 +202,19 @@ def run_forecast(lat, lon, hours=24):
|
|
| 141 |
f"{meta['lat']:.3f}, {meta['lon']:.3f} (grid: lat[{meta['ilat']}], lon[{meta['ilon']}])\n"
|
| 142 |
f"Dataset: {dataset_url} | total time {time.perf_counter()-t0:.1f}s"
|
| 143 |
)
|
| 144 |
-
yield
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
return
|
| 146 |
|
| 147 |
|
|
@@ -226,22 +299,25 @@ with gr.Blocks(title="NBM Point Forecast (NOAA NOMADS)") as demo:
|
|
| 226 |
snow6_plot = gr.Plot(label="6 hr Snow + Accum")
|
| 227 |
snow24_plot = gr.Plot(label="24 hr Snowfall")
|
| 228 |
snow48_plot = gr.Plot(label="48 hr Snowfall")
|
|
|
|
|
|
|
|
|
|
| 229 |
|
| 230 |
# Trigger when clicking Fetch, or when lat/lon are edited (e.g., via map)
|
| 231 |
btn.click(
|
| 232 |
run_forecast,
|
| 233 |
inputs=[lat_in, lon_in, hours],
|
| 234 |
-
outputs=[status, table, temp_wind_plot, cloud_precip_plot, snow_prob_plot, snow6_plot, snow24_plot, snow48_plot],
|
| 235 |
)
|
| 236 |
lat_in.change(
|
| 237 |
run_forecast,
|
| 238 |
inputs=[lat_in, lon_in, hours],
|
| 239 |
-
outputs=[status, table, temp_wind_plot, cloud_precip_plot, snow_prob_plot, snow6_plot, snow24_plot, snow48_plot],
|
| 240 |
)
|
| 241 |
lon_in.change(
|
| 242 |
run_forecast,
|
| 243 |
inputs=[lat_in, lon_in, hours],
|
| 244 |
-
outputs=[status, table, temp_wind_plot, cloud_precip_plot, snow_prob_plot, snow6_plot, snow24_plot, snow48_plot],
|
| 245 |
)
|
| 246 |
|
| 247 |
|
|
|
|
| 10 |
fetch_point_probabilities,
|
| 11 |
get_latest_hourly_dataset_url,
|
| 12 |
get_latest_3hr_dataset_url,
|
| 13 |
+
fetch_cloud_layers,
|
| 14 |
+
fetch_precip_type_probs,
|
| 15 |
+
fetch_snow_level_kft,
|
| 16 |
)
|
| 17 |
from plot_utils import (
|
| 18 |
make_temp_dew_wind_fig,
|
|
|
|
| 20 |
make_snow_prob_fig,
|
| 21 |
make_snow_6h_accum_fig,
|
| 22 |
make_window_snow_fig,
|
| 23 |
+
make_cloud_layers_fig,
|
| 24 |
+
make_precip_type_fig,
|
| 25 |
+
make_snow_level_fig,
|
| 26 |
)
|
| 27 |
|
| 28 |
|
|
|
|
| 42 |
temp_wind_fig = None
|
| 43 |
cloud_precip_fig = None
|
| 44 |
snow_prob_fig = None
|
| 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)
|
| 51 |
elapsed = time.perf_counter() - t0
|
| 52 |
+
return (
|
| 53 |
+
gr.update(value=f"{msg} (elapsed {elapsed:.1f}s)"),
|
| 54 |
+
table_df,
|
| 55 |
+
temp_wind_fig,
|
| 56 |
+
cloud_precip_fig,
|
| 57 |
+
snow_prob_fig,
|
| 58 |
+
None,
|
| 59 |
+
None,
|
| 60 |
+
None,
|
| 61 |
+
cloud_layers_fig,
|
| 62 |
+
precip_type_fig,
|
| 63 |
+
snow_level_fig,
|
| 64 |
+
)
|
| 65 |
|
| 66 |
if lat is None or lon is None:
|
| 67 |
yield y("Click map or enter lat/lon.")
|
|
|
|
| 122 |
print(f"Probability fetch/plot error: {e}")
|
| 123 |
snow_prob_fig = None
|
| 124 |
|
| 125 |
+
# Cloud layers and precip type probabilities
|
| 126 |
+
try:
|
| 127 |
+
t_layers, layers = fetch_cloud_layers(dataset_url, lat, lon, hours=hours)
|
| 128 |
+
if len(layers) > 0:
|
| 129 |
+
import pandas as _pd
|
| 130 |
+
x = _pd.to_datetime(df["time_utc"], utc=True, errors="coerce")
|
| 131 |
+
total = _pd.Series(df["cloud_cover_pct"].astype(float).values, index=x) if "cloud_cover_pct" in df.columns else None
|
| 132 |
+
# preserve input order
|
| 133 |
+
layers_ordered = {k: layers[k] for k in layers}
|
| 134 |
+
cloud_layers_fig = make_cloud_layers_fig(t_layers, layers_ordered, total)
|
| 135 |
+
except Exception as e:
|
| 136 |
+
print(f"Cloud layers plot error: {e}")
|
| 137 |
+
|
| 138 |
+
try:
|
| 139 |
+
t_ptype, ptype = fetch_precip_type_probs(dataset_url, lat, lon, hours=hours)
|
| 140 |
+
if len(ptype) > 0:
|
| 141 |
+
precip_type_fig = make_precip_type_fig(t_ptype, ptype)
|
| 142 |
+
except Exception as e:
|
| 143 |
+
print(f"Precip type plot error: {e}")
|
| 144 |
+
|
| 145 |
+
# Snow level with precip overlay
|
| 146 |
+
try:
|
| 147 |
+
t_sl, snow_kft = fetch_snow_level_kft(dataset_url, lat, lon, hours=hours)
|
| 148 |
+
if snow_kft is not None and len(snow_kft) > 0:
|
| 149 |
+
import pandas as _pd
|
| 150 |
+
x = _pd.to_datetime(df["time_utc"], utc=True, errors="coerce")
|
| 151 |
+
# Compute 6h precip window from available precip
|
| 152 |
+
if "precip_in" in df.columns:
|
| 153 |
+
# estimate step
|
| 154 |
+
step_hours = 1.0
|
| 155 |
+
if len(x) > 1:
|
| 156 |
+
step_hours = max(1.0, (x[1] - x[0]).total_seconds() / 3600.0)
|
| 157 |
+
w6 = max(1, int(round(6.0 / step_hours)))
|
| 158 |
+
p6 = _pd.Series(df["precip_in"].astype(float).values, index=x).rolling(window=w6, min_periods=1).sum()
|
| 159 |
+
else:
|
| 160 |
+
p6 = None
|
| 161 |
+
snow_level_fig = make_snow_level_fig(t_sl, snow_kft, p6)
|
| 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
|
|
|
|
| 202 |
f"{meta['lat']:.3f}, {meta['lon']:.3f} (grid: lat[{meta['ilat']}], lon[{meta['ilon']}])\n"
|
| 203 |
f"Dataset: {dataset_url} | total time {time.perf_counter()-t0:.1f}s"
|
| 204 |
)
|
| 205 |
+
yield (
|
| 206 |
+
gr.update(value=header),
|
| 207 |
+
table_df,
|
| 208 |
+
temp_wind_fig,
|
| 209 |
+
cloud_precip_fig,
|
| 210 |
+
snow_prob_fig,
|
| 211 |
+
snow6_fig,
|
| 212 |
+
snow24_fig,
|
| 213 |
+
snow48_fig,
|
| 214 |
+
cloud_layers_fig,
|
| 215 |
+
precip_type_fig,
|
| 216 |
+
snow_level_fig,
|
| 217 |
+
)
|
| 218 |
return
|
| 219 |
|
| 220 |
|
|
|
|
| 299 |
snow6_plot = gr.Plot(label="6 hr Snow + Accum")
|
| 300 |
snow24_plot = gr.Plot(label="24 hr Snowfall")
|
| 301 |
snow48_plot = gr.Plot(label="48 hr Snowfall")
|
| 302 |
+
cloud_layers_plot = gr.Plot(label="Cloud Layers (%)")
|
| 303 |
+
precip_type_plot = gr.Plot(label="Precip Type Probabilities")
|
| 304 |
+
snow_level_plot = gr.Plot(label="Snow Level + Precip")
|
| 305 |
|
| 306 |
# Trigger when clicking Fetch, or when lat/lon are edited (e.g., via map)
|
| 307 |
btn.click(
|
| 308 |
run_forecast,
|
| 309 |
inputs=[lat_in, lon_in, hours],
|
| 310 |
+
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],
|
| 311 |
)
|
| 312 |
lat_in.change(
|
| 313 |
run_forecast,
|
| 314 |
inputs=[lat_in, lon_in, hours],
|
| 315 |
+
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],
|
| 316 |
)
|
| 317 |
lon_in.change(
|
| 318 |
run_forecast,
|
| 319 |
inputs=[lat_in, lon_in, hours],
|
| 320 |
+
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],
|
| 321 |
)
|
| 322 |
|
| 323 |
|
nbm_client.py
CHANGED
|
@@ -328,3 +328,121 @@ def fetch_point_probabilities(
|
|
| 328 |
logger.warning(f"Skipping {v} due to read/convert error: {ex}")
|
| 329 |
|
| 330 |
return t_index, out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 328 |
logger.warning(f"Skipping {v} due to read/convert error: {ex}")
|
| 329 |
|
| 330 |
return t_index, out
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
def _open_ds(dataset_url: str) -> xr.Dataset:
|
| 334 |
+
return xr.open_dataset(_to_dap2_url(dataset_url), engine="pydap", decode_cf=True)
|
| 335 |
+
|
| 336 |
+
|
| 337 |
+
def fetch_cloud_layers(
|
| 338 |
+
dataset_url: str, lat: float, lon: float, hours: int = 24
|
| 339 |
+
) -> Tuple[pd.DatetimeIndex, Dict[str, pd.Series]]:
|
| 340 |
+
"""Return available cloud layer cover percentages as time series.
|
| 341 |
+
|
| 342 |
+
Looks for variables among: tcdcl195, tcdcl196, tcdcl197, tcdchcll.
|
| 343 |
+
Labels are derived from long_name attributes when present.
|
| 344 |
+
"""
|
| 345 |
+
logger = logging.getLogger(__name__)
|
| 346 |
+
ds = _open_ds(dataset_url)
|
| 347 |
+
lat_vals = ds["lat"].values
|
| 348 |
+
lon_vals = ds["lon"].values
|
| 349 |
+
ilat = _nearest_index(lat_vals, lat)
|
| 350 |
+
ilon = _nearest_index(lon_vals, lon)
|
| 351 |
+
candidates = ["tcdcl195", "tcdcl196", "tcdcl197", "tcdchcll"]
|
| 352 |
+
present = [v for v in candidates if v in ds.variables]
|
| 353 |
+
if not present:
|
| 354 |
+
return pd.DatetimeIndex([]), {}
|
| 355 |
+
t_full = _to_datetime_index(ds["time"])
|
| 356 |
+
step = _infer_step_hours(t_full)
|
| 357 |
+
n_req = int(np.ceil(max(1, float(hours)) / step))
|
| 358 |
+
n = min(len(t_full), n_req)
|
| 359 |
+
t = t_full[:n]
|
| 360 |
+
out: Dict[str, pd.Series] = {}
|
| 361 |
+
for v in present:
|
| 362 |
+
try:
|
| 363 |
+
arr = _mask_fill(ds[v].isel(lat=ilat, lon=ilon, time=slice(0, n)).values)
|
| 364 |
+
vals = np.clip(arr, 0, 100)
|
| 365 |
+
# Label
|
| 366 |
+
ln = str(ds[v].attrs.get("long_name", v))
|
| 367 |
+
if "high cloud" in ln.lower() or v == "tcdchcll":
|
| 368 |
+
name = "High Cloud (%)"
|
| 369 |
+
elif v.endswith("195"):
|
| 370 |
+
name = "Layer 195 (%)"
|
| 371 |
+
elif v.endswith("196"):
|
| 372 |
+
name = "Layer 196 (%)"
|
| 373 |
+
elif v.endswith("197"):
|
| 374 |
+
name = "Layer 197 (%)"
|
| 375 |
+
else:
|
| 376 |
+
name = v
|
| 377 |
+
out[name] = pd.Series(np.round(vals.astype(float), 1), index=t)
|
| 378 |
+
except Exception as ex:
|
| 379 |
+
logger.warning(f"Skipping cloud layer {v}: {ex}")
|
| 380 |
+
return t, out
|
| 381 |
+
|
| 382 |
+
|
| 383 |
+
def fetch_precip_type_probs(
|
| 384 |
+
dataset_url: str, lat: float, lon: float, hours: int = 24
|
| 385 |
+
) -> Tuple[pd.DatetimeIndex, Dict[str, pd.Series]]:
|
| 386 |
+
"""Return precipitation type probabilities (%): Rain, Snow, Freezing Rain, Sleet.
|
| 387 |
+
|
| 388 |
+
Uses variables ptype1to2sfc (Rain), ptype3to4sfc (Snow), ptype5to7sfc (Freezing Rain), ptype8to9sfc (Sleet).
|
| 389 |
+
"""
|
| 390 |
+
ds = _open_ds(dataset_url)
|
| 391 |
+
lat_vals = ds["lat"].values
|
| 392 |
+
lon_vals = ds["lon"].values
|
| 393 |
+
ilat = _nearest_index(lat_vals, lat)
|
| 394 |
+
ilon = _nearest_index(lon_vals, lon)
|
| 395 |
+
mapping = {
|
| 396 |
+
"ptype1to2sfc": "Rain",
|
| 397 |
+
"ptype3to4sfc": "Snow",
|
| 398 |
+
"ptype5to7sfc": "Freezing Rain",
|
| 399 |
+
"ptype8to9sfc": "Sleet",
|
| 400 |
+
}
|
| 401 |
+
present = [k for k in mapping if k in ds.variables]
|
| 402 |
+
if not present:
|
| 403 |
+
return pd.DatetimeIndex([]), {}
|
| 404 |
+
t_full = _to_datetime_index(ds["time"]) # time is global
|
| 405 |
+
step = _infer_step_hours(t_full)
|
| 406 |
+
n_req = int(np.ceil(max(1, float(hours)) / step))
|
| 407 |
+
n = min(len(t_full), n_req)
|
| 408 |
+
t = t_full[:n]
|
| 409 |
+
out: Dict[str, pd.Series] = {}
|
| 410 |
+
for k in present:
|
| 411 |
+
vals = _mask_fill(ds[k].isel(lat=ilat, lon=ilon, time=slice(0, n)).values)
|
| 412 |
+
# Normalize 0..1 to percent when needed
|
| 413 |
+
mx = float(np.nanmax(vals)) if np.isfinite(vals).any() else 0.0
|
| 414 |
+
if mx <= 1.0:
|
| 415 |
+
vals = vals * 100.0
|
| 416 |
+
out[mapping[k]] = pd.Series(np.round(vals.astype(float), 1), index=t)
|
| 417 |
+
return t, out
|
| 418 |
+
|
| 419 |
+
|
| 420 |
+
def fetch_snow_level_kft(
|
| 421 |
+
dataset_url: str, lat: float, lon: float, hours: int = 24
|
| 422 |
+
) -> Tuple[pd.DatetimeIndex, Optional[pd.Series]]:
|
| 423 |
+
"""Return snow level above mean sea level in kft if available (1-hr datasets typically).
|
| 424 |
+
Searches variable attributes for 'snow level'.
|
| 425 |
+
"""
|
| 426 |
+
ds = _open_ds(dataset_url)
|
| 427 |
+
lat_vals = ds["lat"].values
|
| 428 |
+
lon_vals = ds["lon"].values
|
| 429 |
+
ilat = _nearest_index(lat_vals, lat)
|
| 430 |
+
ilon = _nearest_index(lon_vals, lon)
|
| 431 |
+
# find first var whose long_name mentions 'snow level'
|
| 432 |
+
candidate: Optional[str] = None
|
| 433 |
+
for v in ds.variables:
|
| 434 |
+
ln = str(ds[v].attrs.get("long_name", "")).lower()
|
| 435 |
+
if "snow level" in ln:
|
| 436 |
+
candidate = v
|
| 437 |
+
break
|
| 438 |
+
if candidate is None:
|
| 439 |
+
return pd.DatetimeIndex([]), None
|
| 440 |
+
t_full = _to_datetime_index(ds["time"]) # assume aligned
|
| 441 |
+
step = _infer_step_hours(t_full)
|
| 442 |
+
n_req = int(np.ceil(max(1, float(hours)) / step))
|
| 443 |
+
n = min(len(t_full), n_req)
|
| 444 |
+
t = t_full[:n]
|
| 445 |
+
vals_m = _mask_fill(ds[candidate].isel(lat=ilat, lon=ilon, time=slice(0, n)).values)
|
| 446 |
+
# convert meters to kft
|
| 447 |
+
kft = (vals_m / 0.3048) / 1000.0
|
| 448 |
+
return t, pd.Series(np.round(kft.astype(float), 2), index=t)
|
plot_utils.py
CHANGED
|
@@ -199,3 +199,92 @@ def make_window_snow_fig(x: pd.DatetimeIndex, snow_win: pd.Series, window_label:
|
|
| 199 |
legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
|
| 200 |
)
|
| 201 |
return fig
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
|
| 200 |
)
|
| 201 |
return fig
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def make_cloud_layers_fig(
|
| 205 |
+
x: pd.DatetimeIndex,
|
| 206 |
+
layers: Dict[str, pd.Series],
|
| 207 |
+
total: pd.Series | None = None,
|
| 208 |
+
) -> go.Figure:
|
| 209 |
+
fig = go.Figure()
|
| 210 |
+
palette = ["#c7c7c7", "#8c8c8c", "#525252", "#a0a0ff"]
|
| 211 |
+
# Plot each layer as filled area
|
| 212 |
+
for i, (name, series) in enumerate(layers.items()):
|
| 213 |
+
fig.add_trace(
|
| 214 |
+
go.Scatter(
|
| 215 |
+
x=x,
|
| 216 |
+
y=series.values,
|
| 217 |
+
name=name,
|
| 218 |
+
mode="lines",
|
| 219 |
+
line=dict(color=palette[i % len(palette)]),
|
| 220 |
+
fill="tozeroy",
|
| 221 |
+
opacity=0.4,
|
| 222 |
+
)
|
| 223 |
+
)
|
| 224 |
+
if total is not None:
|
| 225 |
+
fig.add_trace(
|
| 226 |
+
go.Scatter(
|
| 227 |
+
x=x,
|
| 228 |
+
y=total.values,
|
| 229 |
+
name="Total Cloud (%)",
|
| 230 |
+
mode="lines",
|
| 231 |
+
line=dict(color="#444444", width=2),
|
| 232 |
+
)
|
| 233 |
+
)
|
| 234 |
+
fig.update_layout(
|
| 235 |
+
margin=dict(l=40, r=20, t=30, b=40),
|
| 236 |
+
xaxis=dict(title="Time (UTC)"),
|
| 237 |
+
yaxis=dict(title="Cloud Cover (%)", range=[0, 100]),
|
| 238 |
+
legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
|
| 239 |
+
)
|
| 240 |
+
return fig
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
def make_precip_type_fig(x: pd.DatetimeIndex, probs: Dict[str, pd.Series]) -> go.Figure:
|
| 244 |
+
fig = go.Figure()
|
| 245 |
+
colors = {
|
| 246 |
+
"Rain": "#2ca02c",
|
| 247 |
+
"Freezing Rain": "#e377c2",
|
| 248 |
+
"Snow": "#1f77b4",
|
| 249 |
+
"Sleet": "#9467bd",
|
| 250 |
+
}
|
| 251 |
+
for name, s in probs.items():
|
| 252 |
+
fig.add_trace(
|
| 253 |
+
go.Scatter(x=x, y=s.values, name=name, mode="lines", line=dict(color=colors.get(name, None), width=2))
|
| 254 |
+
)
|
| 255 |
+
fig.update_layout(
|
| 256 |
+
margin=dict(l=40, r=20, t=30, b=40),
|
| 257 |
+
xaxis=dict(title="Time (UTC)"),
|
| 258 |
+
yaxis=dict(title="Precip Type Prob (%)", range=[0, 100]),
|
| 259 |
+
legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
|
| 260 |
+
)
|
| 261 |
+
return fig
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
def make_snow_level_fig(
|
| 265 |
+
x: pd.DatetimeIndex,
|
| 266 |
+
snow_level_kft: pd.Series,
|
| 267 |
+
precip_window_in: pd.Series | None = None,
|
| 268 |
+
) -> go.Figure:
|
| 269 |
+
fig = go.Figure()
|
| 270 |
+
fig.add_trace(
|
| 271 |
+
go.Scatter(x=x, y=snow_level_kft.values, name="Snow level (kft)", mode="lines", line=dict(color="#1f77b4", width=3))
|
| 272 |
+
)
|
| 273 |
+
if precip_window_in is not None:
|
| 274 |
+
fig.add_trace(
|
| 275 |
+
go.Bar(
|
| 276 |
+
x=x,
|
| 277 |
+
y=precip_window_in.values,
|
| 278 |
+
name="Precip (in)",
|
| 279 |
+
marker_color="rgba(0,128,0,0.35)",
|
| 280 |
+
yaxis="y2",
|
| 281 |
+
)
|
| 282 |
+
)
|
| 283 |
+
fig.update_layout(
|
| 284 |
+
margin=dict(l=40, r=40, t=30, b=40),
|
| 285 |
+
xaxis=dict(title="Time (UTC)"),
|
| 286 |
+
yaxis=dict(title="Snow level (kft)", rangemode="tozero"),
|
| 287 |
+
yaxis2=dict(title="Precip (in)", overlaying="y", side="right", rangemode="tozero"),
|
| 288 |
+
legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
|
| 289 |
+
)
|
| 290 |
+
return fig
|