Spaces:
Sleeping
Sleeping
Initial commit: UpdraftForcing Dash app
Browse files- python/updraft_forcing/app.py +588 -251
python/updraft_forcing/app.py
CHANGED
|
@@ -2,7 +2,6 @@
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
-
import math
|
| 6 |
import numpy as np
|
| 7 |
from dash import Dash, Input, Output, State, ctx, dcc, html, no_update
|
| 8 |
import plotly.graph_objects as go
|
|
@@ -63,7 +62,6 @@ def _build_env_winds(u_pts, v_pts):
|
|
| 63 |
|
| 64 |
def _run_model(snd_params, upd_params, u_pts, v_pts, zeta_cpts):
|
| 65 |
"""Full model run: sounding → updraft → pressure → diagnostics."""
|
| 66 |
-
# Map UI parameter keys to wk_sounding keyword names
|
| 67 |
snd_kw = dict(
|
| 68 |
theta_ml = snd_params.get("theta_ml", SND_DEFAULTS["theta_ml"]),
|
| 69 |
qv_ml_gkg = snd_params.get("qv_ml", SND_DEFAULTS["qv_ml"]),
|
|
@@ -74,16 +72,13 @@ def _run_model(snd_params, upd_params, u_pts, v_pts, zeta_cpts):
|
|
| 74 |
)
|
| 75 |
snd = wk_sounding(Z_GRID, **snd_kw)
|
| 76 |
|
| 77 |
-
# 1-D parcel and diagnosed w profile
|
| 78 |
wp = diagnose_w_profile(
|
| 79 |
Z_GRID, snd["T_K"], snd["qv"], snd["p_hPa"],
|
| 80 |
delta_T_K=upd_params["delta_T"],
|
| 81 |
)
|
| 82 |
|
| 83 |
-
# Environmental wind on the full grid
|
| 84 |
env_u, env_v, dudz_env, dvdz_env = _build_env_winds(u_pts, v_pts)
|
| 85 |
|
| 86 |
-
# 3-D updraft fields
|
| 87 |
fields = build_updraft_fields(
|
| 88 |
X2, Y2, Z_GRID,
|
| 89 |
r0=upd_params["r0"],
|
|
@@ -96,16 +91,15 @@ def _run_model(snd_params, upd_params, u_pts, v_pts, zeta_cpts):
|
|
| 96 |
theta_parcel=wp["T_parcel"] * (snd["p_hPa"] / 1000.0) ** 0.2854,
|
| 97 |
)
|
| 98 |
|
| 99 |
-
|
| 100 |
-
rho0 = snd["rho"]
|
| 101 |
theta0 = snd["theta"]
|
| 102 |
|
| 103 |
-
F_lin
|
| 104 |
-
F_spin
|
| 105 |
-
|
| 106 |
F_splat = forcing_splat(rho0, fields["u3d"], fields["v3d"], fields["w3d"],
|
| 107 |
env_u, env_v, DX, DZ)
|
| 108 |
-
F_buoy
|
| 109 |
|
| 110 |
p_lin = solve_poisson_3d(F_lin, DX, DZ)
|
| 111 |
p_spin = solve_poisson_3d(F_spin, DX, DZ)
|
|
@@ -115,7 +109,6 @@ def _run_model(snd_params, upd_params, u_pts, v_pts, zeta_cpts):
|
|
| 115 |
|
| 116 |
accels = pressure_accelerations(p_lin, p_spin, p_splat, p_buoy, rho0, DZ)
|
| 117 |
|
| 118 |
-
# Diagnostics
|
| 119 |
parcel_diag = {
|
| 120 |
"CAPE": wp["CAPE"], "CIN": wp["CIN"],
|
| 121 |
"LCL_m": wp["LCL_m"], "LFC_m": wp["LFC_m"],
|
|
@@ -127,7 +120,6 @@ def _run_model(snd_params, upd_params, u_pts, v_pts, zeta_cpts):
|
|
| 127 |
fields["w3d"], fields["zeta3d"],
|
| 128 |
)
|
| 129 |
|
| 130 |
-
# Cache
|
| 131 |
_C.update({
|
| 132 |
"w": fields["w3d"],
|
| 133 |
"u": fields["u3d"],
|
|
@@ -155,12 +147,21 @@ def _run_model(snd_params, upd_params, u_pts, v_pts, zeta_cpts):
|
|
| 155 |
# UI helpers
|
| 156 |
# ---------------------------------------------------------------------------
|
| 157 |
|
| 158 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
return html.Div(
|
| 160 |
className="uf-slider-row",
|
| 161 |
children=[
|
| 162 |
html.Div(
|
| 163 |
-
[html.
|
|
|
|
| 164 |
html.Span(f"{value}{unit}", id=f"{id_}-val", className="uf-slider-value")],
|
| 165 |
className="uf-slider-header",
|
| 166 |
),
|
|
@@ -171,15 +172,14 @@ def _slider(id_, label, mn, mx, step, value, unit=""):
|
|
| 171 |
|
| 172 |
|
| 173 |
def _profile_editor(fig_id, title, values, z_km, xunit, xrange):
|
| 174 |
-
"""Draggable profile graph
|
| 175 |
values = np.asarray(values, dtype=float)
|
| 176 |
-
z_km
|
| 177 |
vlo, vhi = xrange
|
| 178 |
|
| 179 |
fig = go.Figure()
|
| 180 |
fig.add_trace(go.Scatter(
|
| 181 |
-
x=values, y=z_km,
|
| 182 |
-
mode="lines+markers",
|
| 183 |
line=dict(color="#6ecbff", width=2),
|
| 184 |
marker=dict(size=7, color="#6ecbff"),
|
| 185 |
hoverinfo="skip", showlegend=False,
|
|
@@ -200,8 +200,7 @@ def _profile_editor(fig_id, title, values, z_km, xunit, xrange):
|
|
| 200 |
xaxis_title=xunit, yaxis_title="height (km)",
|
| 201 |
template="plotly_dark",
|
| 202 |
margin=dict(l=50, r=10, t=40, b=40),
|
| 203 |
-
height=300,
|
| 204 |
-
dragmode=False,
|
| 205 |
xaxis=dict(range=[vlo, vhi], fixedrange=True),
|
| 206 |
yaxis=dict(range=[0, z_km[-1] + 0.5], fixedrange=True),
|
| 207 |
shapes=shapes,
|
|
@@ -214,28 +213,22 @@ def _profile_editor(fig_id, title, values, z_km, xunit, xrange):
|
|
| 214 |
|
| 215 |
def _hodo_figure(u_pts, v_pts, storm_u=None, storm_v=None,
|
| 216 |
lm_u=None, lm_v=None, mean_u=None, mean_v=None):
|
| 217 |
-
"""Build the interactive hodograph figure with draggable level markers."""
|
| 218 |
u_pts = np.asarray(u_pts, dtype=float)
|
| 219 |
v_pts = np.asarray(v_pts, dtype=float)
|
| 220 |
-
levels_km = HODO_LEVELS_KM
|
| 221 |
|
| 222 |
fig = go.Figure()
|
| 223 |
-
|
| 224 |
-
# Colored hodograph segments: 0-3 km = warm, 3-6 km = mid, 6+ = cool
|
| 225 |
seg_colors = ["#e06c6c", "#e09c4a", "#c8d44a", "#5ac85a", "#4ab8e0", "#7070e0",
|
| 226 |
"#a060d0", "#808080", "#606060"]
|
| 227 |
for i in range(len(u_pts) - 1):
|
| 228 |
fig.add_trace(go.Scatter(
|
| 229 |
-
x=u_pts[i:i+2], y=v_pts[i:i+2],
|
| 230 |
-
mode="lines",
|
| 231 |
line=dict(color=seg_colors[min(i, len(seg_colors)-1)], width=2.5),
|
| 232 |
hoverinfo="skip", showlegend=False,
|
| 233 |
))
|
| 234 |
|
| 235 |
-
# Draggable level markers as Plotly shapes (circles)
|
| 236 |
radius_px = 9
|
| 237 |
shapes = []
|
| 238 |
-
for
|
| 239 |
shapes.append(dict(
|
| 240 |
type="circle", xref="x", yref="y",
|
| 241 |
xsizemode="pixel", ysizemode="pixel",
|
|
@@ -245,53 +238,39 @@ def _hodo_figure(u_pts, v_pts, storm_u=None, storm_v=None,
|
|
| 245 |
editable=True, layer="above",
|
| 246 |
))
|
| 247 |
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
text=f"{lkm}",
|
| 253 |
-
font=dict(size=9, color="#dfe3ea"),
|
| 254 |
-
showarrow=False, xshift=12, yshift=6,
|
| 255 |
-
)
|
| 256 |
|
| 257 |
-
# Storm motion markers
|
| 258 |
if storm_u is not None:
|
| 259 |
-
fig.add_trace(go.Scatter(
|
| 260 |
-
x=[storm_u], y=[storm_v], mode="markers+text",
|
| 261 |
marker=dict(symbol="star", size=14, color="#ff8c00"),
|
| 262 |
text=["RM"], textposition="top right", textfont=dict(size=9, color="#ff8c00"),
|
| 263 |
-
hoverinfo="skip", showlegend=False
|
| 264 |
-
))
|
| 265 |
if lm_u is not None:
|
| 266 |
-
fig.add_trace(go.Scatter(
|
| 267 |
-
x=[lm_u], y=[lm_v], mode="markers+text",
|
| 268 |
marker=dict(symbol="star", size=14, color="#aaaaff"),
|
| 269 |
text=["LM"], textposition="top right", textfont=dict(size=9, color="#aaaaff"),
|
| 270 |
-
hoverinfo="skip", showlegend=False
|
| 271 |
-
))
|
| 272 |
if mean_u is not None:
|
| 273 |
-
fig.add_trace(go.Scatter(
|
| 274 |
-
x=[mean_u], y=[mean_v], mode="markers",
|
| 275 |
marker=dict(symbol="x", size=11, color="#80ff80"),
|
| 276 |
-
hoverinfo="skip", showlegend=False
|
| 277 |
-
))
|
| 278 |
|
| 279 |
-
# Axis cross-hairs
|
| 280 |
all_u = list(u_pts) + ([storm_u] if storm_u else []) + ([lm_u] if lm_u else [])
|
| 281 |
all_v = list(v_pts) + ([storm_v] if storm_v else []) + ([lm_v] if lm_v else [])
|
| 282 |
pad = 5
|
| 283 |
-
ulo, uhi = min(all_u) - pad, max(all_u) + pad
|
| 284 |
-
vlo, vhi = min(all_v) - pad, max(all_v) + pad
|
| 285 |
-
|
| 286 |
fig.update_layout(
|
| 287 |
title=dict(text="Hodograph (drag to edit)", font=dict(size=12)),
|
| 288 |
xaxis_title="U (m s⁻¹)", yaxis_title="V (m s⁻¹)",
|
| 289 |
template="plotly_dark",
|
| 290 |
margin=dict(l=50, r=10, t=40, b=40),
|
| 291 |
-
height=300,
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
yaxis=dict(range=[
|
|
|
|
| 295 |
scaleanchor="x", scaleratio=1),
|
| 296 |
shapes=shapes,
|
| 297 |
)
|
|
@@ -299,10 +278,7 @@ def _hodo_figure(u_pts, v_pts, storm_u=None, storm_v=None,
|
|
| 299 |
|
| 300 |
|
| 301 |
def _field_heatmap(arr2d, x_axis, y_axis, x_label, y_label, title, field):
|
| 302 |
-
|
| 303 |
-
colorscale, symmetric, label, unit = FIELD_META.get(
|
| 304 |
-
field, ("Viridis", False, field, "")
|
| 305 |
-
)
|
| 306 |
zmin, zmax = clim(arr2d, symmetric)
|
| 307 |
fig = go.Figure(data=go.Heatmap(
|
| 308 |
x=x_axis, y=y_axis, z=arr2d.T,
|
|
@@ -323,8 +299,7 @@ def _field_heatmap(arr2d, x_axis, y_axis, x_label, y_label, title, field):
|
|
| 323 |
def _profile_fig(y_vals, z_km, title, color, xunit):
|
| 324 |
fig = go.Figure()
|
| 325 |
fig.add_trace(go.Scatter(
|
| 326 |
-
x=y_vals, y=z_km,
|
| 327 |
-
mode="lines", line=dict(color=color, width=2),
|
| 328 |
hovertemplate=f"%{{x:.2g}} {xunit}<br>%{{y:.1f}} km<extra></extra>",
|
| 329 |
showlegend=False,
|
| 330 |
))
|
|
@@ -347,86 +322,470 @@ def _diag_row(label, value, unit=""):
|
|
| 347 |
])
|
| 348 |
|
| 349 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 350 |
# ---------------------------------------------------------------------------
|
| 351 |
# Layout
|
| 352 |
# ---------------------------------------------------------------------------
|
| 353 |
|
| 354 |
def _build_layout():
|
| 355 |
-
# Initial computation with defaults
|
| 356 |
diag = _run_model(SND_DEFAULTS, UPD_DEFAULTS, WK_U, WK_V, ZETA_DEFAULTS)
|
| 357 |
|
|
|
|
| 358 |
left = html.Div(className="uf-left", children=[
|
| 359 |
dcc.Tabs(id="ctrl-tabs", value="sounding", className="uf-tabs", children=[
|
| 360 |
|
| 361 |
-
# --- Sounding tab ---
|
| 362 |
dcc.Tab(label="Sounding", value="sounding", className="uf-tab",
|
| 363 |
selected_className="uf-tab-sel", children=html.Div([
|
| 364 |
html.Div("Weisman-Klemp Sounding", className="uf-section-title"),
|
| 365 |
-
_slider("snd-theta-ml", "θ_ml (K)", 295, 315, 0.5, SND_DEFAULTS["theta_ml"]
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
_slider("snd-
|
| 369 |
-
|
| 370 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 371 |
html.Div(className="uf-preset-row", children=[
|
| 372 |
-
html.Button("WK Supercell", id="preset-wk",
|
| 373 |
html.Button("Weak shear", id="preset-weak", className="uf-btn"),
|
| 374 |
html.Button("Reset", id="preset-reset", className="uf-btn"),
|
| 375 |
]),
|
| 376 |
])),
|
| 377 |
|
| 378 |
-
# --- Updraft tab ---
|
| 379 |
dcc.Tab(label="Updraft", value="updraft", className="uf-tab",
|
| 380 |
selected_className="uf-tab-sel", children=html.Div([
|
| 381 |
html.Div("Updraft Core", className="uf-section-title"),
|
| 382 |
html.Div([
|
| 383 |
html.Span("ΔT surface (K)", className="uf-slider-label"),
|
|
|
|
|
|
|
|
|
|
| 384 |
dcc.Input(id="upd-delta-T", type="number", value=UPD_DEFAULTS["delta_T"],
|
| 385 |
step=0.1, min=0.1, max=10.0,
|
| 386 |
style={"width": "80px", "marginLeft": "8px",
|
| 387 |
"background": "#0f1520", "border": "1px solid #2d3a4b",
|
| 388 |
-
"color": "#dfe3ea", "padding": "4px 8px",
|
|
|
|
| 389 |
], style={"display": "flex", "alignItems": "center", "marginBottom": "10px"}),
|
| 390 |
-
_slider("upd-r0", "Radius (m)", 500, 5000, 100, UPD_DEFAULTS["r0"], " m"
|
| 391 |
-
|
| 392 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 393 |
_profile_editor("zeta-profile", "ζ(z) (s⁻¹)",
|
| 394 |
ZETA_DEFAULTS, ZETA_Z_KM, "s⁻¹", (-0.05, 0.05)),
|
| 395 |
-
html.Div(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 396 |
dcc.RadioItems(
|
| 397 |
id="upd-shape",
|
| 398 |
options=[{"label": " Top-hat", "value": "tophat"},
|
| 399 |
-
{"label": " Cosine",
|
| 400 |
value=UPD_DEFAULTS["shape"],
|
| 401 |
labelStyle={"marginRight": "14px", "color": "#dfe3ea", "fontSize": "13px"},
|
| 402 |
style={"marginBottom": "10px"},
|
| 403 |
),
|
| 404 |
])),
|
| 405 |
|
| 406 |
-
# --- Hodograph tab ---
|
| 407 |
dcc.Tab(label="Hodograph", value="hodograph", className="uf-tab",
|
| 408 |
selected_className="uf-tab-sel", children=html.Div([
|
| 409 |
html.Div("Environmental hodograph", className="uf-section-title"),
|
| 410 |
dcc.Graph(id="hodograph",
|
| 411 |
figure=_hodo_figure(WK_U, WK_V,
|
| 412 |
storm_u=diag["storm_u"], storm_v=diag["storm_v"],
|
| 413 |
-
lm_u=diag["lm_u"],
|
| 414 |
-
mean_u=diag["mean_u"],
|
| 415 |
config={"edits": {"shapePosition": True},
|
| 416 |
"displayModeBar": False, "scrollZoom": False}),
|
| 417 |
-
html.Div(
|
| 418 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 419 |
])),
|
| 420 |
]),
|
| 421 |
])
|
| 422 |
|
|
|
|
| 423 |
center = html.Div(className="uf-center", children=[
|
| 424 |
html.Div(className="uf-field-controls", children=[
|
| 425 |
dcc.Dropdown(
|
| 426 |
id="field-select",
|
| 427 |
options=[{"label": v, "value": k} for k, v in FIELD_LABELS.items()],
|
| 428 |
-
value="w",
|
| 429 |
-
clearable=False,
|
| 430 |
style={"width": "240px", "fontSize": "13px"},
|
| 431 |
),
|
| 432 |
dcc.Tabs(id="view-tabs", value="plan", className="uf-view-tabs", children=[
|
|
@@ -444,12 +803,12 @@ def _build_layout():
|
|
| 444 |
style={"color": "#6ecbff", "fontSize": "12px", "minWidth": "80px"}),
|
| 445 |
]),
|
| 446 |
dcc.Graph(id="main-heatmap", config={"displayModeBar": False}),
|
| 447 |
-
# Diagnosed w(z) profile
|
| 448 |
html.Div(className="uf-section-title", style={"marginTop": "14px"},
|
| 449 |
children="Diagnosed w(z) from parcel model"),
|
| 450 |
dcc.Graph(id="w-profile-graph", config={"displayModeBar": False}),
|
| 451 |
])
|
| 452 |
|
|
|
|
| 453 |
right = html.Div(className="uf-right", children=[
|
| 454 |
html.Div("Sounding Parameters", className="uf-section-title"),
|
| 455 |
html.Table(id="diag-table", style={"width": "100%", "borderCollapse": "collapse"}),
|
|
@@ -459,17 +818,25 @@ def _build_layout():
|
|
| 459 |
dcc.Graph(id="accel-profile", config={"displayModeBar": False}),
|
| 460 |
])
|
| 461 |
|
|
|
|
|
|
|
| 462 |
return html.Div(className="uf-root", children=[
|
| 463 |
html.Div(className="uf-header", children=[
|
| 464 |
html.H1("UpdraftForcing", style={"margin": "0 0 4px 0", "fontSize": "22px"}),
|
| 465 |
-
html.Div("Convective updraft wind shear diagnostics
|
| 466 |
style={"color": "#9aa3ad", "fontSize": "12px"}),
|
| 467 |
]),
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 473 |
])
|
| 474 |
|
| 475 |
|
|
@@ -479,9 +846,6 @@ def _build_layout():
|
|
| 479 |
|
| 480 |
def _register_callbacks(app):
|
| 481 |
|
| 482 |
-
# ------------------------------------------------------------------
|
| 483 |
-
# 1. Sounding slider value labels
|
| 484 |
-
# ------------------------------------------------------------------
|
| 485 |
for sid, unit in [("snd-theta-ml", " K"), ("snd-qv-ml", " g/kg"),
|
| 486 |
("snd-z-ml", " m"), ("snd-z-trop", " m"),
|
| 487 |
("snd-T-trop", " K"), ("snd-gamma", ""),
|
|
@@ -491,9 +855,6 @@ def _register_callbacks(app):
|
|
| 491 |
def _upd_label(v, _unit=unit):
|
| 492 |
return f"{v}{_unit}"
|
| 493 |
|
| 494 |
-
# ------------------------------------------------------------------
|
| 495 |
-
# 2. Preset buttons → sounding + updraft sliders
|
| 496 |
-
# ------------------------------------------------------------------
|
| 497 |
@app.callback(
|
| 498 |
[Output("snd-theta-ml", "value"), Output("snd-qv-ml", "value"),
|
| 499 |
Output("snd-z-ml", "value"), Output("snd-z-trop", "value"),
|
|
@@ -507,23 +868,16 @@ def _register_callbacks(app):
|
|
| 507 |
prevent_initial_call=True,
|
| 508 |
)
|
| 509 |
def _preset(wk, weak, reset):
|
| 510 |
-
|
| 511 |
-
if trig == "preset-weak":
|
| 512 |
u = [0, 5, 10, 15, 20, 22, 23, 24, 25]
|
| 513 |
v = [0, 0, 0, 0, 0, 0, 0, 0, 0]
|
| 514 |
-
return (300, 12, 1000, 11000, 215, 1.2, 2.0, 2500, "tophat",
|
| 515 |
-
{"u": u, "v": v})
|
| 516 |
-
# WK supercell or reset
|
| 517 |
return (SND_DEFAULTS["theta_ml"], SND_DEFAULTS["qv_ml"],
|
| 518 |
SND_DEFAULTS["z_ml"], SND_DEFAULTS["z_trop"],
|
| 519 |
SND_DEFAULTS["T_trop"], SND_DEFAULTS["gamma_ft"],
|
| 520 |
UPD_DEFAULTS["delta_T"], UPD_DEFAULTS["r0"],
|
| 521 |
-
UPD_DEFAULTS["shape"],
|
| 522 |
-
{"u": WK_U, "v": WK_V})
|
| 523 |
|
| 524 |
-
# ------------------------------------------------------------------
|
| 525 |
-
# 3. Hodograph drag → update hodo-store
|
| 526 |
-
# ------------------------------------------------------------------
|
| 527 |
@app.callback(
|
| 528 |
Output("hodo-store", "data"),
|
| 529 |
Input("hodograph", "relayoutData"),
|
|
@@ -533,31 +887,16 @@ def _register_callbacks(app):
|
|
| 533 |
def _hodo_drag(relay, store):
|
| 534 |
if not relay:
|
| 535 |
return no_update
|
| 536 |
-
u = list(store["u"])
|
| 537 |
-
v = list(store["v"])
|
| 538 |
-
changed = False
|
| 539 |
for i in range(len(u)):
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
pass
|
| 548 |
-
if kya in relay:
|
| 549 |
-
try:
|
| 550 |
-
v[i] = max(-60.0, min(60.0, float(relay[kya])))
|
| 551 |
-
changed = True
|
| 552 |
-
except (TypeError, ValueError):
|
| 553 |
-
pass
|
| 554 |
-
if not changed:
|
| 555 |
-
return no_update
|
| 556 |
-
return {"u": u, "v": v}
|
| 557 |
|
| 558 |
-
# ------------------------------------------------------------------
|
| 559 |
-
# 4. ζ(z) profile drag → update zeta-store
|
| 560 |
-
# ------------------------------------------------------------------
|
| 561 |
@app.callback(
|
| 562 |
Output("zeta-store", "data"),
|
| 563 |
Input("zeta-profile", "relayoutData"),
|
|
@@ -567,39 +906,27 @@ def _register_callbacks(app):
|
|
| 567 |
def _zeta_drag(relay, store):
|
| 568 |
if not relay:
|
| 569 |
return no_update
|
| 570 |
-
zeta = list(store["zeta"])
|
| 571 |
-
changed = False
|
| 572 |
for i in range(len(zeta)):
|
| 573 |
kxa = f"shapes[{i}].xanchor"
|
| 574 |
if kxa in relay:
|
| 575 |
try:
|
| 576 |
-
zeta[i] = max(-0.10, min(0.10, float(relay[kxa])))
|
| 577 |
-
changed = True
|
| 578 |
except (TypeError, ValueError):
|
| 579 |
pass
|
| 580 |
-
|
| 581 |
-
return no_update
|
| 582 |
-
return {"zeta": zeta}
|
| 583 |
|
| 584 |
-
# ------------------------------------------------------------------
|
| 585 |
-
# 5. Redraw hodograph when hodo-store changes
|
| 586 |
-
# ------------------------------------------------------------------
|
| 587 |
@app.callback(
|
| 588 |
Output("hodograph", "figure"),
|
| 589 |
[Input("hodo-store", "data"), Input("model-rev", "data")],
|
| 590 |
)
|
| 591 |
def _redraw_hodo(store, _rev):
|
| 592 |
-
|
| 593 |
-
return _hodo_figure(
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
mean_u=diag.get("mean_u"), mean_v=diag.get("mean_v"),
|
| 598 |
-
)
|
| 599 |
|
| 600 |
-
# ------------------------------------------------------------------
|
| 601 |
-
# 6. Main model compute → model-rev increment
|
| 602 |
-
# ------------------------------------------------------------------
|
| 603 |
@app.callback(
|
| 604 |
Output("model-rev", "data"),
|
| 605 |
[Input("snd-theta-ml", "value"), Input("snd-qv-ml", "value"),
|
|
@@ -615,29 +942,24 @@ def _register_callbacks(app):
|
|
| 615 |
delta_T, r0, shape, hodo, zeta_data, rev):
|
| 616 |
snd_p = dict(
|
| 617 |
theta_ml=theta_ml or SND_DEFAULTS["theta_ml"],
|
| 618 |
-
qv_ml=qv_ml
|
| 619 |
-
z_ml=z_ml
|
| 620 |
-
z_trop=z_trop
|
| 621 |
-
T_trop=T_trop
|
| 622 |
gamma_ft=gamma or SND_DEFAULTS["gamma_ft"],
|
| 623 |
)
|
| 624 |
upd_p = dict(
|
| 625 |
delta_T=delta_T or UPD_DEFAULTS["delta_T"],
|
| 626 |
-
r0=r0
|
| 627 |
shape=shape or UPD_DEFAULTS["shape"],
|
| 628 |
)
|
| 629 |
try:
|
| 630 |
-
_run_model(snd_p, upd_p,
|
| 631 |
-
hodo["u"], hodo["v"],
|
| 632 |
-
zeta_data["zeta"])
|
| 633 |
except Exception as exc:
|
| 634 |
import traceback; traceback.print_exc()
|
| 635 |
print(f"[UpdraftForcing] compute error: {exc!r}", flush=True)
|
| 636 |
return (rev or 0) + 1
|
| 637 |
|
| 638 |
-
# ------------------------------------------------------------------
|
| 639 |
-
# 7. Slice-slider label update
|
| 640 |
-
# ------------------------------------------------------------------
|
| 641 |
@app.callback(
|
| 642 |
Output("slice-label", "children"),
|
| 643 |
[Input("slice-slider", "value"), Input("view-tabs", "value")],
|
|
@@ -653,92 +975,63 @@ def _register_callbacks(app):
|
|
| 653 |
else:
|
| 654 |
return f"x = {X_KM[idx % NX]:.1f} km"
|
| 655 |
|
| 656 |
-
# ------------------------------------------------------------------
|
| 657 |
-
# 8. Slice-slider range update when view changes
|
| 658 |
-
# ------------------------------------------------------------------
|
| 659 |
@app.callback(
|
| 660 |
[Output("slice-slider", "max"), Output("slice-slider", "value")],
|
| 661 |
Input("view-tabs", "value"),
|
| 662 |
)
|
| 663 |
def _slice_range(view):
|
| 664 |
-
if view == "plan":
|
| 665 |
-
|
| 666 |
-
|
| 667 |
-
return NY - 1, NY // 2
|
| 668 |
-
else:
|
| 669 |
-
return NX - 1, NX // 2
|
| 670 |
|
| 671 |
-
# ------------------------------------------------------------------
|
| 672 |
-
# 9. Main heatmap display
|
| 673 |
-
# ------------------------------------------------------------------
|
| 674 |
@app.callback(
|
| 675 |
Output("main-heatmap", "figure"),
|
| 676 |
-
[Input("field-select", "value"),
|
| 677 |
-
Input("
|
| 678 |
-
Input("slice-slider", "value"),
|
| 679 |
-
Input("model-rev", "data")],
|
| 680 |
)
|
| 681 |
def _display(field, view, idx, _rev):
|
| 682 |
arr = _C.get(field)
|
| 683 |
if arr is None:
|
| 684 |
return go.Figure()
|
| 685 |
-
|
| 686 |
if view == "plan":
|
| 687 |
k = min(idx, NZ - 1)
|
| 688 |
-
|
| 689 |
-
|
| 690 |
-
return _field_heatmap(slice2d, X_KM, Y_KM, "x (km)", "y (km)", title, field)
|
| 691 |
elif view == "xcross":
|
| 692 |
j = min(idx, NY - 1)
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
title = f"{FIELD_LABELS.get(field, field)} — y = {Y_KM[j]:.1f} km"
|
| 696 |
-
return _field_heatmap(slice2d, X_KM, z_km, "x (km)", "z (km)", title, field)
|
| 697 |
else:
|
| 698 |
i = min(idx, NX - 1)
|
| 699 |
-
|
| 700 |
-
|
| 701 |
-
|
| 702 |
-
return _field_heatmap(slice2d, Y_KM, z_km, "y (km)", "z (km)", title, field)
|
| 703 |
-
|
| 704 |
-
# ------------------------------------------------------------------
|
| 705 |
-
# 10. Diagnosed w(z) profile display
|
| 706 |
-
# ------------------------------------------------------------------
|
| 707 |
@app.callback(
|
| 708 |
Output("w-profile-graph", "figure"),
|
| 709 |
Input("model-rev", "data"),
|
| 710 |
)
|
| 711 |
def _w_profile_fig(_rev):
|
| 712 |
-
w_z
|
| 713 |
-
EL
|
| 714 |
z_top = _C.get("z_top_m", 13000.0)
|
| 715 |
-
z_km
|
| 716 |
-
|
| 717 |
fig = go.Figure()
|
| 718 |
-
fig.add_trace(go.Scatter(
|
| 719 |
-
|
| 720 |
-
|
| 721 |
-
|
| 722 |
-
|
| 723 |
-
))
|
| 724 |
-
fig.add_hline(y=EL / 1000.0, line=dict(color="#ffd685", dash="dash", width=1.5),
|
| 725 |
annotation_text="EL", annotation_font_color="#ffd685",
|
| 726 |
annotation_position="top right")
|
| 727 |
-
fig.add_hline(y=z_top
|
| 728 |
annotation_text="Overshoot top", annotation_font_color="#ff8c00",
|
| 729 |
annotation_position="top right")
|
| 730 |
-
fig.update_layout(
|
| 731 |
-
|
| 732 |
-
|
| 733 |
-
|
| 734 |
-
height=200,
|
| 735 |
-
xaxis=dict(rangemode="tozero"),
|
| 736 |
-
)
|
| 737 |
return fig
|
| 738 |
|
| 739 |
-
# ------------------------------------------------------------------
|
| 740 |
-
# 11. Diagnostics table
|
| 741 |
-
# ------------------------------------------------------------------
|
| 742 |
@app.callback(
|
| 743 |
Output("diag-table", "children"),
|
| 744 |
Input("model-rev", "data"),
|
|
@@ -761,9 +1054,6 @@ def _register_callbacks(app):
|
|
| 761 |
]
|
| 762 |
return html.Tbody(rows)
|
| 763 |
|
| 764 |
-
# ------------------------------------------------------------------
|
| 765 |
-
# 12. Buoyancy and acceleration profiles
|
| 766 |
-
# ------------------------------------------------------------------
|
| 767 |
@app.callback(
|
| 768 |
[Output("buoy-profile", "figure"), Output("accel-profile", "figure")],
|
| 769 |
Input("model-rev", "data"),
|
|
@@ -771,37 +1061,23 @@ def _register_callbacks(app):
|
|
| 771 |
def _profiles(_rev):
|
| 772 |
z_km = Z_GRID / 1000.0
|
| 773 |
cx, cy = NX // 2, NY // 2
|
| 774 |
-
|
| 775 |
B_z = _C.get("B_z", np.zeros(NZ))
|
| 776 |
buoy_fig = _profile_fig(B_z, z_km, "Buoyancy B(z)", "#4ab8e0", "m s⁻²")
|
| 777 |
|
| 778 |
-
# Acceleration profiles at core center
|
| 779 |
-
a_lin = _C.get("a_lin", np.zeros((NX, NY, NZ)))
|
| 780 |
-
a_spin = _C.get("a_spin", np.zeros((NX, NY, NZ)))
|
| 781 |
-
a_splat = _C.get("a_splat", np.zeros((NX, NY, NZ)))
|
| 782 |
-
a_buoy = _C.get("a_buoy", np.zeros((NX, NY, NZ)))
|
| 783 |
-
|
| 784 |
fig = go.Figure()
|
| 785 |
-
for
|
| 786 |
-
|
| 787 |
-
|
| 788 |
-
|
| 789 |
-
(
|
| 790 |
-
|
| 791 |
-
|
| 792 |
-
|
| 793 |
-
mode="lines", name=name,
|
| 794 |
-
line=dict(color=col, width=1.8),
|
| 795 |
-
hovertemplate=f"%{{x:.3g}} m/s²<br>%{{y:.1f}} km<extra>{name}</extra>",
|
| 796 |
-
))
|
| 797 |
fig.add_vline(x=0, line=dict(color="#555", width=1))
|
| 798 |
-
fig.update_layout(
|
| 799 |
-
|
| 800 |
-
|
| 801 |
-
|
| 802 |
-
height=220,
|
| 803 |
-
legend=dict(font=dict(size=10), orientation="h", y=1.05),
|
| 804 |
-
)
|
| 805 |
return buoy_fig, fig
|
| 806 |
|
| 807 |
|
|
@@ -812,15 +1088,24 @@ def _register_callbacks(app):
|
|
| 812 |
_CSS = """
|
| 813 |
body { background: #0b0e14; color: #dfe3ea; font-family: -apple-system, system-ui, sans-serif; margin: 0; }
|
| 814 |
.uf-root { max-width: 1600px; margin: 0 auto; padding: 16px; }
|
| 815 |
-
.uf-header { margin-bottom:
|
| 816 |
.uf-header h1 { color: #6ecbff; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 817 |
.uf-main { display: grid; grid-template-columns: 320px 1fr 280px; gap: 16px; }
|
| 818 |
-
.uf-left
|
| 819 |
-
.uf-center
|
| 820 |
.uf-right { background: #11161f; border-radius: 8px; padding: 12px; }
|
|
|
|
| 821 |
.uf-section-title { font-size: 11px; font-weight: 600; color: #8d97a2; letter-spacing: 0.08em; text-transform: uppercase; margin-bottom: 8px; margin-top: 4px; }
|
| 822 |
.uf-slider-row { margin-bottom: 10px; }
|
| 823 |
-
.uf-slider-header { display: flex; justify-content: space-between; font-size: 12px; margin-bottom: 2px; }
|
| 824 |
.uf-slider-label { color: #c7ced6; }
|
| 825 |
.uf-slider-value { color: #6ecbff; font-variant-numeric: tabular-nums; }
|
| 826 |
.uf-tabs .tab { background: #161d29; border: none; color: #9aa3ad; font-size: 12px; padding: 6px 12px; }
|
|
@@ -836,6 +1121,58 @@ body { background: #0b0e14; color: #dfe3ea; font-family: -apple-system, system-u
|
|
| 836 |
.uf-btn { background: #1e2835; border: 1px solid #2d3a4b; color: #dfe3ea; padding: 5px 10px; border-radius: 5px; cursor: pointer; font-size: 12px; }
|
| 837 |
.uf-btn:hover { background: #2a3a4e; }
|
| 838 |
table tr:nth-child(even) td { background: #161d29; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 839 |
"""
|
| 840 |
|
| 841 |
|
|
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
|
|
| 5 |
import numpy as np
|
| 6 |
from dash import Dash, Input, Output, State, ctx, dcc, html, no_update
|
| 7 |
import plotly.graph_objects as go
|
|
|
|
| 62 |
|
| 63 |
def _run_model(snd_params, upd_params, u_pts, v_pts, zeta_cpts):
|
| 64 |
"""Full model run: sounding → updraft → pressure → diagnostics."""
|
|
|
|
| 65 |
snd_kw = dict(
|
| 66 |
theta_ml = snd_params.get("theta_ml", SND_DEFAULTS["theta_ml"]),
|
| 67 |
qv_ml_gkg = snd_params.get("qv_ml", SND_DEFAULTS["qv_ml"]),
|
|
|
|
| 72 |
)
|
| 73 |
snd = wk_sounding(Z_GRID, **snd_kw)
|
| 74 |
|
|
|
|
| 75 |
wp = diagnose_w_profile(
|
| 76 |
Z_GRID, snd["T_K"], snd["qv"], snd["p_hPa"],
|
| 77 |
delta_T_K=upd_params["delta_T"],
|
| 78 |
)
|
| 79 |
|
|
|
|
| 80 |
env_u, env_v, dudz_env, dvdz_env = _build_env_winds(u_pts, v_pts)
|
| 81 |
|
|
|
|
| 82 |
fields = build_updraft_fields(
|
| 83 |
X2, Y2, Z_GRID,
|
| 84 |
r0=upd_params["r0"],
|
|
|
|
| 91 |
theta_parcel=wp["T_parcel"] * (snd["p_hPa"] / 1000.0) ** 0.2854,
|
| 92 |
)
|
| 93 |
|
| 94 |
+
rho0 = snd["rho"]
|
|
|
|
| 95 |
theta0 = snd["theta"]
|
| 96 |
|
| 97 |
+
F_lin = forcing_linear(rho0, dudz_env, dvdz_env, fields["w3d"], DX)
|
| 98 |
+
F_spin = forcing_spin(rho0, fields["u3d"], fields["v3d"], fields["w3d"],
|
| 99 |
+
env_u, env_v, DX, DZ)
|
| 100 |
F_splat = forcing_splat(rho0, fields["u3d"], fields["v3d"], fields["w3d"],
|
| 101 |
env_u, env_v, DX, DZ)
|
| 102 |
+
F_buoy = forcing_buoyancy(rho0, theta0, fields["theta_prime3d"], DZ)
|
| 103 |
|
| 104 |
p_lin = solve_poisson_3d(F_lin, DX, DZ)
|
| 105 |
p_spin = solve_poisson_3d(F_spin, DX, DZ)
|
|
|
|
| 109 |
|
| 110 |
accels = pressure_accelerations(p_lin, p_spin, p_splat, p_buoy, rho0, DZ)
|
| 111 |
|
|
|
|
| 112 |
parcel_diag = {
|
| 113 |
"CAPE": wp["CAPE"], "CIN": wp["CIN"],
|
| 114 |
"LCL_m": wp["LCL_m"], "LFC_m": wp["LFC_m"],
|
|
|
|
| 120 |
fields["w3d"], fields["zeta3d"],
|
| 121 |
)
|
| 122 |
|
|
|
|
| 123 |
_C.update({
|
| 124 |
"w": fields["w3d"],
|
| 125 |
"u": fields["u3d"],
|
|
|
|
| 147 |
# UI helpers
|
| 148 |
# ---------------------------------------------------------------------------
|
| 149 |
|
| 150 |
+
def _help(tip: str) -> html.Span:
|
| 151 |
+
"""Inline ? icon that shows a CSS tooltip on hover."""
|
| 152 |
+
return html.Span("?", className="uf-help", **{"data-tip": tip})
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def _slider(id_, label, mn, mx, step, value, unit="", tip=""):
|
| 156 |
+
label_group = [html.Span(label, className="uf-slider-label")]
|
| 157 |
+
if tip:
|
| 158 |
+
label_group.append(_help(tip))
|
| 159 |
return html.Div(
|
| 160 |
className="uf-slider-row",
|
| 161 |
children=[
|
| 162 |
html.Div(
|
| 163 |
+
[html.Div(label_group,
|
| 164 |
+
style={"display": "flex", "alignItems": "center", "gap": "4px"}),
|
| 165 |
html.Span(f"{value}{unit}", id=f"{id_}-val", className="uf-slider-value")],
|
| 166 |
className="uf-slider-header",
|
| 167 |
),
|
|
|
|
| 172 |
|
| 173 |
|
| 174 |
def _profile_editor(fig_id, title, values, z_km, xunit, xrange):
|
| 175 |
+
"""Draggable profile graph (Mountain Waves pattern)."""
|
| 176 |
values = np.asarray(values, dtype=float)
|
| 177 |
+
z_km = np.asarray(z_km, dtype=float)
|
| 178 |
vlo, vhi = xrange
|
| 179 |
|
| 180 |
fig = go.Figure()
|
| 181 |
fig.add_trace(go.Scatter(
|
| 182 |
+
x=values, y=z_km, mode="lines+markers",
|
|
|
|
| 183 |
line=dict(color="#6ecbff", width=2),
|
| 184 |
marker=dict(size=7, color="#6ecbff"),
|
| 185 |
hoverinfo="skip", showlegend=False,
|
|
|
|
| 200 |
xaxis_title=xunit, yaxis_title="height (km)",
|
| 201 |
template="plotly_dark",
|
| 202 |
margin=dict(l=50, r=10, t=40, b=40),
|
| 203 |
+
height=300, dragmode=False,
|
|
|
|
| 204 |
xaxis=dict(range=[vlo, vhi], fixedrange=True),
|
| 205 |
yaxis=dict(range=[0, z_km[-1] + 0.5], fixedrange=True),
|
| 206 |
shapes=shapes,
|
|
|
|
| 213 |
|
| 214 |
def _hodo_figure(u_pts, v_pts, storm_u=None, storm_v=None,
|
| 215 |
lm_u=None, lm_v=None, mean_u=None, mean_v=None):
|
|
|
|
| 216 |
u_pts = np.asarray(u_pts, dtype=float)
|
| 217 |
v_pts = np.asarray(v_pts, dtype=float)
|
|
|
|
| 218 |
|
| 219 |
fig = go.Figure()
|
|
|
|
|
|
|
| 220 |
seg_colors = ["#e06c6c", "#e09c4a", "#c8d44a", "#5ac85a", "#4ab8e0", "#7070e0",
|
| 221 |
"#a060d0", "#808080", "#606060"]
|
| 222 |
for i in range(len(u_pts) - 1):
|
| 223 |
fig.add_trace(go.Scatter(
|
| 224 |
+
x=u_pts[i:i+2], y=v_pts[i:i+2], mode="lines",
|
|
|
|
| 225 |
line=dict(color=seg_colors[min(i, len(seg_colors)-1)], width=2.5),
|
| 226 |
hoverinfo="skip", showlegend=False,
|
| 227 |
))
|
| 228 |
|
|
|
|
| 229 |
radius_px = 9
|
| 230 |
shapes = []
|
| 231 |
+
for u, v in zip(u_pts, v_pts):
|
| 232 |
shapes.append(dict(
|
| 233 |
type="circle", xref="x", yref="y",
|
| 234 |
xsizemode="pixel", ysizemode="pixel",
|
|
|
|
| 238 |
editable=True, layer="above",
|
| 239 |
))
|
| 240 |
|
| 241 |
+
for u, v, lkm in zip(u_pts, v_pts, HODO_LEVELS_KM):
|
| 242 |
+
fig.add_annotation(x=float(u), y=float(v), text=f"{lkm}",
|
| 243 |
+
font=dict(size=9, color="#dfe3ea"),
|
| 244 |
+
showarrow=False, xshift=12, yshift=6)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
|
|
|
|
| 246 |
if storm_u is not None:
|
| 247 |
+
fig.add_trace(go.Scatter(x=[storm_u], y=[storm_v], mode="markers+text",
|
|
|
|
| 248 |
marker=dict(symbol="star", size=14, color="#ff8c00"),
|
| 249 |
text=["RM"], textposition="top right", textfont=dict(size=9, color="#ff8c00"),
|
| 250 |
+
hoverinfo="skip", showlegend=False))
|
|
|
|
| 251 |
if lm_u is not None:
|
| 252 |
+
fig.add_trace(go.Scatter(x=[lm_u], y=[lm_v], mode="markers+text",
|
|
|
|
| 253 |
marker=dict(symbol="star", size=14, color="#aaaaff"),
|
| 254 |
text=["LM"], textposition="top right", textfont=dict(size=9, color="#aaaaff"),
|
| 255 |
+
hoverinfo="skip", showlegend=False))
|
|
|
|
| 256 |
if mean_u is not None:
|
| 257 |
+
fig.add_trace(go.Scatter(x=[mean_u], y=[mean_v], mode="markers",
|
|
|
|
| 258 |
marker=dict(symbol="x", size=11, color="#80ff80"),
|
| 259 |
+
hoverinfo="skip", showlegend=False))
|
|
|
|
| 260 |
|
|
|
|
| 261 |
all_u = list(u_pts) + ([storm_u] if storm_u else []) + ([lm_u] if lm_u else [])
|
| 262 |
all_v = list(v_pts) + ([storm_v] if storm_v else []) + ([lm_v] if lm_v else [])
|
| 263 |
pad = 5
|
|
|
|
|
|
|
|
|
|
| 264 |
fig.update_layout(
|
| 265 |
title=dict(text="Hodograph (drag to edit)", font=dict(size=12)),
|
| 266 |
xaxis_title="U (m s⁻¹)", yaxis_title="V (m s⁻¹)",
|
| 267 |
template="plotly_dark",
|
| 268 |
margin=dict(l=50, r=10, t=40, b=40),
|
| 269 |
+
height=300, dragmode=False,
|
| 270 |
+
xaxis=dict(range=[min(all_u)-pad, max(all_u)+pad],
|
| 271 |
+
zeroline=True, zerolinecolor="#555", fixedrange=True),
|
| 272 |
+
yaxis=dict(range=[min(all_v)-pad, max(all_v)+pad],
|
| 273 |
+
zeroline=True, zerolinecolor="#555", fixedrange=True,
|
| 274 |
scaleanchor="x", scaleratio=1),
|
| 275 |
shapes=shapes,
|
| 276 |
)
|
|
|
|
| 278 |
|
| 279 |
|
| 280 |
def _field_heatmap(arr2d, x_axis, y_axis, x_label, y_label, title, field):
|
| 281 |
+
colorscale, symmetric, label, unit = FIELD_META.get(field, ("Viridis", False, field, ""))
|
|
|
|
|
|
|
|
|
|
| 282 |
zmin, zmax = clim(arr2d, symmetric)
|
| 283 |
fig = go.Figure(data=go.Heatmap(
|
| 284 |
x=x_axis, y=y_axis, z=arr2d.T,
|
|
|
|
| 299 |
def _profile_fig(y_vals, z_km, title, color, xunit):
|
| 300 |
fig = go.Figure()
|
| 301 |
fig.add_trace(go.Scatter(
|
| 302 |
+
x=y_vals, y=z_km, mode="lines", line=dict(color=color, width=2),
|
|
|
|
| 303 |
hovertemplate=f"%{{x:.2g}} {xunit}<br>%{{y:.1f}} km<extra></extra>",
|
| 304 |
showlegend=False,
|
| 305 |
))
|
|
|
|
| 322 |
])
|
| 323 |
|
| 324 |
|
| 325 |
+
# ---------------------------------------------------------------------------
|
| 326 |
+
# Static page content
|
| 327 |
+
# ---------------------------------------------------------------------------
|
| 328 |
+
|
| 329 |
+
def _getting_started_content():
|
| 330 |
+
def _step(n, title, body):
|
| 331 |
+
return html.Div([
|
| 332 |
+
html.Div(f"Step {n} — {title}", className="gs-step-title"),
|
| 333 |
+
html.Div(body, className="gs-step-body"),
|
| 334 |
+
], className="gs-step")
|
| 335 |
+
|
| 336 |
+
def _row(label, desc):
|
| 337 |
+
return html.Tr([
|
| 338 |
+
html.Td(label, className="gs-ctrl-label"),
|
| 339 |
+
html.Td(desc, className="gs-ctrl-desc"),
|
| 340 |
+
])
|
| 341 |
+
|
| 342 |
+
return html.Div(className="uf-page-content", children=[
|
| 343 |
+
html.H2("Getting Started", className="page-h2"),
|
| 344 |
+
html.P(
|
| 345 |
+
"UpdraftForcing is a diagnostic kinematic model for convective storms. "
|
| 346 |
+
"It prescribes an idealized updraft in a sheared environment and instantly "
|
| 347 |
+
"diagnoses the resulting pressure perturbation field decomposed into physical "
|
| 348 |
+
"forcing mechanisms. No time-stepping — every slider move reruns the full "
|
| 349 |
+
"3-D Poisson solve (~1.5 s).",
|
| 350 |
+
className="gs-intro",
|
| 351 |
+
),
|
| 352 |
+
|
| 353 |
+
_step(1, "Set up the sounding", html.Table(className="gs-table", children=[
|
| 354 |
+
html.Tbody([
|
| 355 |
+
_row("θ_ml (K)",
|
| 356 |
+
"Mixed-layer potential temperature. The surface parcel is lifted from this θ. "
|
| 357 |
+
"Higher values give a warmer boundary layer and more buoyancy."),
|
| 358 |
+
_row("qv_ml (g/kg)",
|
| 359 |
+
"Boundary-layer water vapor mixing ratio. More moisture raises the dew point, "
|
| 360 |
+
"lowering the LCL and increasing CAPE."),
|
| 361 |
+
_row("BL depth (m)",
|
| 362 |
+
"Depth of the well-mixed layer. The constant-θ / qv layer extends to this height."),
|
| 363 |
+
_row("Tropopause ht. (m)",
|
| 364 |
+
"Height of the tropopause. Controls the depth of the unstable layer. "
|
| 365 |
+
"A higher tropopause allows a deeper updraft and more CAPE."),
|
| 366 |
+
_row("Tropopause T (K)",
|
| 367 |
+
"Temperature at the tropopause. Colder tropopause → larger CAPE."),
|
| 368 |
+
_row("Lapse rate exp.",
|
| 369 |
+
"Power-law exponent γ shaping the free-troposphere θ profile. "
|
| 370 |
+
"γ = 1 is linear; γ < 1 concentrates instability near the surface; γ > 1 near the tropopause."),
|
| 371 |
+
_row("Presets",
|
| 372 |
+
"WK Supercell loads the Weisman-Klemp (1982) default environment. "
|
| 373 |
+
"Weak shear loads a straight unidirectional hodograph. Reset returns all controls to defaults."),
|
| 374 |
+
]),
|
| 375 |
+
])),
|
| 376 |
+
|
| 377 |
+
_step(2, "Draw the hodograph (Hodograph tab)", [
|
| 378 |
+
html.P("The hodograph shows the environmental wind vector at 9 levels: "
|
| 379 |
+
"surface, 1, 2, 3, 4, 5, 6, 8, and 10 km. Each gold circle is draggable. "
|
| 380 |
+
"Drag left/right to change U; drag up/down to change V."),
|
| 381 |
+
html.P("The app automatically computes:"),
|
| 382 |
+
html.Table(className="gs-table", children=[html.Tbody([
|
| 383 |
+
_row("RM / LM (stars)", "Bunkers et al. (2000) right- and left-mover storm motions "
|
| 384 |
+
"— mean 0–6 km wind ± 7.5 m/s perpendicular to the 0–6 km shear vector."),
|
| 385 |
+
_row("Mean wind (×)", "Simple 0–6 km mass-weighted mean wind."),
|
| 386 |
+
_row("SRH 0–2 / 2–5 km", "Storm-relative helicity relative to the right-mover, "
|
| 387 |
+
"shown in the diagnostics table."),
|
| 388 |
+
])]),
|
| 389 |
+
]),
|
| 390 |
+
|
| 391 |
+
_step(3, "Configure the updraft (Updraft tab)", html.Table(className="gs-table", children=[
|
| 392 |
+
html.Tbody([
|
| 393 |
+
_row("ΔT surface (K)",
|
| 394 |
+
"Temperature excess of the surface parcel above the environment. "
|
| 395 |
+
"Drives the diagnosed w(z) profile via w²(z) = max(0, 2·∫B dz). "
|
| 396 |
+
"Larger ΔT → more kinetic energy to overcome CIN → stronger updraft."),
|
| 397 |
+
_row("Radius (m)",
|
| 398 |
+
"Radial extent of the updraft core. "
|
| 399 |
+
"The shape function tapers w and ζ to zero at this distance from center."),
|
| 400 |
+
_row("ζ(z) profile",
|
| 401 |
+
"Prescribed vertical vorticity representing accumulated storm rotation. "
|
| 402 |
+
"Drag the gold circles rightward for cyclonic (positive) rotation, "
|
| 403 |
+
"leftward for anticyclonic. The vorticity drives the spin pressure term "
|
| 404 |
+
"and updraft helicity. Default is zero (no rotation)."),
|
| 405 |
+
_row("Core shape — Top-hat",
|
| 406 |
+
"Uniform w and ζ inside r₀·0.9 with a cosine taper in the outer 10%. "
|
| 407 |
+
"Good for representing a solid rotating mesocyclone."),
|
| 408 |
+
_row("Core shape — Cosine",
|
| 409 |
+
"w(r) = cos(π·r / 2r₀). Smooth bell with no flat core. "
|
| 410 |
+
"More realistic radial gradient of w."),
|
| 411 |
+
]),
|
| 412 |
+
])),
|
| 413 |
+
|
| 414 |
+
_step(4, "Explore the output fields", [
|
| 415 |
+
html.P("Use the field dropdown and view tabs (Plan view / X cross-sec / Y cross-sec) "
|
| 416 |
+
"in the center panel. The slice slider moves through the atmosphere."),
|
| 417 |
+
html.Table(className="gs-table", children=[html.Tbody([
|
| 418 |
+
_row("w", "Vertical velocity (m/s). Blue = updraft, red = downdraft."),
|
| 419 |
+
_row("u, v", "Total wind components including environmental flow and core rotation."),
|
| 420 |
+
_row("Vertical ζ_z", "Prescribed vorticity within the core (s⁻¹)."),
|
| 421 |
+
_row("p' total", "Sum of all four pressure perturbation components (Pa)."),
|
| 422 |
+
_row("p' linear", "Shear-interaction term. Produces high p' on the upshear side, "
|
| 423 |
+
"low p' downshear — causes updraft propagation toward high pressure."),
|
| 424 |
+
_row("p' spin", "Nonlinear rotation term. Produces low p' inside the rotating core "
|
| 425 |
+
"(dynamic pipe effect) — upward acceleration within the mesocyclone."),
|
| 426 |
+
_row("p' splat", "Nonlinear deformation term. Produces high p' where air is being "
|
| 427 |
+
"strained (e.g., at the updraft base)."),
|
| 428 |
+
_row("p' buoyancy", "Buoyancy-induced term. Low p' above warm anomalies "
|
| 429 |
+
"adds to the upward acceleration."),
|
| 430 |
+
_row("Accel. terms", "Vertical acceleration −(1/ρ₀)·∂p'/∂z from each component, "
|
| 431 |
+
"plotted as profiles in the right panel."),
|
| 432 |
+
])]),
|
| 433 |
+
]),
|
| 434 |
+
|
| 435 |
+
_step(5, "Read the diagnostics panel", [
|
| 436 |
+
html.P("The right panel updates after every model run:"),
|
| 437 |
+
html.Table(className="gs-table", children=[html.Tbody([
|
| 438 |
+
_row("CAPE / CIN", "Convective available potential energy and convective inhibition (J/kg)."),
|
| 439 |
+
_row("LCL / LFC / EL", "Lifting condensation level, level of free convection, "
|
| 440 |
+
"and equilibrium level (km)."),
|
| 441 |
+
_row("Overshoot top", "Height where w → 0 above the EL. Diagnosed from the parcel model."),
|
| 442 |
+
_row("SRH 0–2 / 2–5 km", "Storm-relative helicity layers (m²/s²). "
|
| 443 |
+
"Values > 150 are supportive of supercell tornadoes."),
|
| 444 |
+
_row("UH 0–2 / 2–5 km", "Updraft helicity (m²/s²). Nonzero only when ζ is prescribed."),
|
| 445 |
+
_row("0–6 km shear", "Bulk wind shear magnitude (m/s). > 15 m/s favors supercells."),
|
| 446 |
+
_row("w_max", "Peak vertical velocity at the updraft core center (m/s)."),
|
| 447 |
+
])]),
|
| 448 |
+
]),
|
| 449 |
+
])
|
| 450 |
+
|
| 451 |
+
|
| 452 |
+
def _theory_content():
|
| 453 |
+
def _eq(latex):
|
| 454 |
+
return dcc.Markdown(f"$$\n{latex}\n$$", mathjax=True, className="theory-eq-md")
|
| 455 |
+
|
| 456 |
+
def _h3(text):
|
| 457 |
+
return html.H3(text, className="theory-h3")
|
| 458 |
+
|
| 459 |
+
def _p(text):
|
| 460 |
+
return dcc.Markdown(text, mathjax=True, className="theory-p-md")
|
| 461 |
+
|
| 462 |
+
def _ref(authors, year, title, journal):
|
| 463 |
+
return html.Li([
|
| 464 |
+
html.Span(f"{authors} ({year}). ", style={"color": "#dfe3ea"}),
|
| 465 |
+
html.Em(title, style={"color": "#c7ced6"}),
|
| 466 |
+
html.Span(f". {journal}", style={"color": "#8d97a2"}),
|
| 467 |
+
], className="theory-ref")
|
| 468 |
+
|
| 469 |
+
def _assume(text, note=""):
|
| 470 |
+
children = [html.Td("✓", className="assume-check"),
|
| 471 |
+
html.Td(text, className="assume-text")]
|
| 472 |
+
if note:
|
| 473 |
+
children.append(html.Td(note, className="assume-note"))
|
| 474 |
+
else:
|
| 475 |
+
children.append(html.Td("", className="assume-note"))
|
| 476 |
+
return html.Tr(children)
|
| 477 |
+
|
| 478 |
+
return html.Div(className="uf-page-content", children=[
|
| 479 |
+
html.H2("Theory", className="page-h2"),
|
| 480 |
+
|
| 481 |
+
# ---- Model assumptions ----
|
| 482 |
+
_h3("Model Assumptions"),
|
| 483 |
+
_p(
|
| 484 |
+
"UpdraftForcing is a **kinematic diagnostic model** — it does not time-step. "
|
| 485 |
+
"The table below lists the key assumptions required for reproducibility."
|
| 486 |
+
),
|
| 487 |
+
html.Table(className="assume-table", children=[html.Tbody([
|
| 488 |
+
_assume("Anelastic approximation",
|
| 489 |
+
"Acoustic modes filtered; ∇·(ρ₀**u**) = 0. "
|
| 490 |
+
"Base-state density ρ₀(z) from the WK sounding."),
|
| 491 |
+
_assume("Horizontally homogeneous environment",
|
| 492 |
+
"U(z), V(z) vary only with height; no mesoscale gradients."),
|
| 493 |
+
_assume("Kinematically prescribed updraft",
|
| 494 |
+
"w(x,y,z) is imposed; there is no momentum equation or "
|
| 495 |
+
"feedback from the diagnosed pressure on the flow."),
|
| 496 |
+
_assume("1-D pseudoadiabatic parcel model",
|
| 497 |
+
"Condensate is removed immediately (no liquid-water loading). "
|
| 498 |
+
"Virtual temperature correction applied throughout."),
|
| 499 |
+
_assume("Solid-body rotation within core radius r₀",
|
| 500 |
+
"v_θ(r) = ζ(z)·r/2 for r ≤ r₀; w and ζ taper to zero at r₀."),
|
| 501 |
+
_assume("Prescribed accumulated vorticity ζ(z)",
|
| 502 |
+
"Represents the rotation a mature storm has built up via tilting "
|
| 503 |
+
"and stretching; not diagnosed from the tendency equation."),
|
| 504 |
+
_assume("Poisson BCs: Neumann top and bottom",
|
| 505 |
+
"∂p'/∂z = 0 at z = 0 m and z = 16 000 m. "
|
| 506 |
+
"Periodic in x and y (implicit via FFT)."),
|
| 507 |
+
_assume("Grid: 100 × 100 × 161 points, Δx = Δy = Δz = 100 m",
|
| 508 |
+
"Domain 10 km × 10 km × 16 km AGL. "
|
| 509 |
+
"Updraft centered at (5 km, 5 km)."),
|
| 510 |
+
_assume("Single updraft cell; no downdraft, anvil, or cold pool", ""),
|
| 511 |
+
_assume("No surface fluxes, radiation, or Coriolis force", ""),
|
| 512 |
+
])]),
|
| 513 |
+
|
| 514 |
+
# ---- Sounding ----
|
| 515 |
+
_h3("Weisman-Klemp Analytic Sounding"),
|
| 516 |
+
_p(
|
| 517 |
+
"The environmental profile follows Weisman and Klemp (1982). "
|
| 518 |
+
r"Potential temperature $\theta$ is prescribed in three layers:"
|
| 519 |
+
),
|
| 520 |
+
_eq(
|
| 521 |
+
r"\theta(z) = \begin{cases}"
|
| 522 |
+
r" \theta_{ml} & z \leq z_{ml} \\"
|
| 523 |
+
r" \theta_{ml} + (\theta_{trop} - \theta_{ml})\!\left(\dfrac{z - z_{ml}}{z_{trop} - z_{ml}}\right)^{\!\gamma} & z_{ml} < z \leq z_{trop} \\"
|
| 524 |
+
r" \theta_{trop}\,\exp\!\left(\dfrac{N^2\,(z - z_{trop})}{g}\right) & z > z_{trop}"
|
| 525 |
+
r"\end{cases}"
|
| 526 |
+
),
|
| 527 |
+
_p(
|
| 528 |
+
r"Pressure is integrated hydrostatically from the surface. "
|
| 529 |
+
r"Free-troposphere moisture is set to 45% RH; above the tropopause "
|
| 530 |
+
r"$N^2 = 4 \times 10^{-4}\ \mathrm{s}^{-2}$. "
|
| 531 |
+
r"Boundary-layer $q_v$ is constant at $q_{v,ml}$."
|
| 532 |
+
),
|
| 533 |
+
|
| 534 |
+
# ---- Parcel model ----
|
| 535 |
+
_h3("1-D Parcel Model and Diagnosed w(z)"),
|
| 536 |
+
_p(
|
| 537 |
+
r"A surface parcel with temperature excess $\Delta T$ is lifted "
|
| 538 |
+
r"dry-adiabatically below the LCL and moist-adiabatically above. "
|
| 539 |
+
r"Virtual temperature correction is applied throughout. Buoyancy:"
|
| 540 |
+
),
|
| 541 |
+
_eq(
|
| 542 |
+
r"B(z) = g \cdot \frac{T_{v,\mathrm{parcel}}(z) - T_{v,\mathrm{env}}(z)}{T_{v,\mathrm{env}}(z)}"
|
| 543 |
+
),
|
| 544 |
+
_p(r"Vertical velocity is diagnosed by integrating $B$ upward from the surface:"),
|
| 545 |
+
_eq(
|
| 546 |
+
r"w^2(z) = \max\!\left(0,\; 2\int_0^z B(z')\,dz'\right)"
|
| 547 |
+
),
|
| 548 |
+
_p(
|
| 549 |
+
r"Above the equilibrium level the parcel is negatively buoyant; $w$ continues to "
|
| 550 |
+
r"decelerate until $w \to 0$ at the overshooting top $z_{top}$."
|
| 551 |
+
),
|
| 552 |
+
|
| 553 |
+
# ---- Pressure decomposition ----
|
| 554 |
+
_h3("Pressure Perturbation Decomposition (Trapp 2013)"),
|
| 555 |
+
_p(r"For an anelastic atmosphere the pressure perturbation satisfies:"),
|
| 556 |
+
_eq(
|
| 557 |
+
r"\nabla^2 p' = F_{\mathrm{lin}} + F_{\mathrm{spin}} + F_{\mathrm{splat}} + F_{\mathrm{buoy}}"
|
| 558 |
+
),
|
| 559 |
+
_p("Each component is solved independently so their spatial structures can be compared directly."),
|
| 560 |
+
|
| 561 |
+
html.Div(className="theory-grid", children=[
|
| 562 |
+
html.Div([
|
| 563 |
+
html.Div("Linear (shear interaction)", className="theory-term-title"),
|
| 564 |
+
_eq(
|
| 565 |
+
r"F_{\mathrm{lin}} = -2\rho_0 \left["
|
| 566 |
+
r"\frac{\partial U}{\partial z}\frac{\partial w'}{\partial x}"
|
| 567 |
+
r"+ \frac{\partial V}{\partial z}\frac{\partial w'}{\partial y}\right]"
|
| 568 |
+
),
|
| 569 |
+
_p(
|
| 570 |
+
r"Interaction of the environmental shear with horizontal gradients of the "
|
| 571 |
+
r"updraft. Produces high $p'$ on the upshear flank and low $p'$ downshear, "
|
| 572 |
+
r"deflecting the updraft toward high pressure."
|
| 573 |
+
),
|
| 574 |
+
], className="theory-term"),
|
| 575 |
+
html.Div([
|
| 576 |
+
html.Div("Nonlinear spin", className="theory-term-title"),
|
| 577 |
+
_eq(
|
| 578 |
+
r"\begin{aligned}"
|
| 579 |
+
r"F_{\mathrm{spin}} &= +\rho_0 \sum_{i,j} R_{ij}^2 \\"
|
| 580 |
+
r"R_{ij} &= \tfrac{1}{2}\!\left(\frac{\partial u_i'}{\partial x_j}"
|
| 581 |
+
r"- \frac{\partial u_j'}{\partial x_i}\right)"
|
| 582 |
+
r"\end{aligned}"
|
| 583 |
+
),
|
| 584 |
+
_p(
|
| 585 |
+
r"Rotation-rate tensor squared. Always produces low $p'$ — "
|
| 586 |
+
r"the dynamic pipe effect. Drives upward acceleration inside "
|
| 587 |
+
r"a rotating mesocyclone."
|
| 588 |
+
),
|
| 589 |
+
], className="theory-term"),
|
| 590 |
+
html.Div([
|
| 591 |
+
html.Div("Nonlinear splat", className="theory-term-title"),
|
| 592 |
+
_eq(
|
| 593 |
+
r"\begin{aligned}"
|
| 594 |
+
r"F_{\mathrm{splat}} &= -\rho_0 \sum_{i,j} S_{ij}^2 \\"
|
| 595 |
+
r"S_{ij} &= \tfrac{1}{2}\!\left(\frac{\partial u_i'}{\partial x_j}"
|
| 596 |
+
r"+ \frac{\partial u_j'}{\partial x_i}\right)"
|
| 597 |
+
r"\end{aligned}"
|
| 598 |
+
),
|
| 599 |
+
_p(
|
| 600 |
+
r"Strain-rate tensor squared. Produces high $p'$ wherever the flow "
|
| 601 |
+
r"is being deformed — typically at the updraft base and flanks."
|
| 602 |
+
),
|
| 603 |
+
], className="theory-term"),
|
| 604 |
+
html.Div([
|
| 605 |
+
html.Div("Buoyancy", className="theory-term-title"),
|
| 606 |
+
_eq(
|
| 607 |
+
r"F_{\mathrm{buoy}} = -\rho_0 \frac{g}{\theta_0}\frac{\partial \theta'}{\partial z}"
|
| 608 |
+
),
|
| 609 |
+
_p(
|
| 610 |
+
r"Vertical gradient of the potential temperature perturbation. "
|
| 611 |
+
r"Produces low $p'$ above warm anomalies, reinforcing buoyant acceleration."
|
| 612 |
+
),
|
| 613 |
+
], className="theory-term"),
|
| 614 |
+
]),
|
| 615 |
+
|
| 616 |
+
# ---- Poisson solver ----
|
| 617 |
+
_h3("Poisson Solver — 2-D FFT + Tridiagonal"),
|
| 618 |
+
_p(
|
| 619 |
+
r"Each forcing component $F(x,y,z)$ is transformed via 2-D real FFT in $x,y$. "
|
| 620 |
+
r"For each horizontal wavenumber pair $(k_x,\,k_y)$ the following vertical ODE is solved:"
|
| 621 |
+
),
|
| 622 |
+
_eq(
|
| 623 |
+
r"-(k_x^2 + k_y^2)\,\hat{P}(k_x,k_y,z) + \frac{d^2\hat{P}}{dz^2} = \hat{F}(k_x,k_y,z)"
|
| 624 |
+
),
|
| 625 |
+
_p(
|
| 626 |
+
r"Neumann boundary conditions $\partial p'/\partial z = 0$ are applied at "
|
| 627 |
+
r"$z = 0$ and $z = z_{top}$. The tridiagonal systems for all $(k_x,k_y)$ pairs "
|
| 628 |
+
r"are solved simultaneously with a vectorized Thomas algorithm "
|
| 629 |
+
r"(~0.08 s per forcing component). An inverse 2-D real FFT recovers $p'(x,y,z)$."
|
| 630 |
+
),
|
| 631 |
+
|
| 632 |
+
# ---- Diagnostics ----
|
| 633 |
+
_h3("Storm Diagnostics"),
|
| 634 |
+
_p(r"Storm motion from Bunkers et al. (2000):"),
|
| 635 |
+
_eq(
|
| 636 |
+
r"\mathbf{c}_{\mathrm{RM}} = \bar{\mathbf{u}}_{0\text{–}6\,\mathrm{km}} + \mathbf{D}_\perp,"
|
| 637 |
+
r"\qquad |\mathbf{D}_\perp| = 7.5\ \mathrm{m\,s^{-1}}\ \perp\ \text{0–6 km shear}"
|
| 638 |
+
),
|
| 639 |
+
_p(r"Storm-relative helicity:"),
|
| 640 |
+
_eq(
|
| 641 |
+
r"\mathrm{SRH} = \sum_{n} \bigl[(u_{n+1} - c_u)(v_n - c_v)"
|
| 642 |
+
r"- (u_n - c_u)(v_{n+1} - c_v)\bigr]"
|
| 643 |
+
),
|
| 644 |
+
_p(r"Updraft helicity (nonzero only when $\zeta$ is prescribed):"),
|
| 645 |
+
_eq(
|
| 646 |
+
r"\mathrm{UH} = \int_{z_{\mathrm{bot}}}^{z_{\mathrm{top}}}"
|
| 647 |
+
r"w(0,0,z)\;\zeta_z(0,0,z)\;dz"
|
| 648 |
+
),
|
| 649 |
+
|
| 650 |
+
# ---- References ----
|
| 651 |
+
html.H3("References", className="theory-h3", style={"marginTop": "30px"}),
|
| 652 |
+
html.Ol(className="theory-refs", children=[
|
| 653 |
+
_ref("Trapp, R. J.", 2013,
|
| 654 |
+
"Mesoscale-Convective Processes in the Atmosphere",
|
| 655 |
+
"Cambridge University Press"),
|
| 656 |
+
_ref("Weisman, M. L. and Klemp, J. B.", 1982,
|
| 657 |
+
"The dependence of numerically simulated convective storms on vertical wind "
|
| 658 |
+
"shear and buoyancy",
|
| 659 |
+
"Mon. Wea. Rev., 110, 504–520"),
|
| 660 |
+
_ref("Bunkers, M. J., Klimowski, B. A., Zeitler, J. W., Thompson, R. L., "
|
| 661 |
+
"and Hjelmfelt, M. R.", 2000,
|
| 662 |
+
"Predicting supercell motion using a new hodograph technique",
|
| 663 |
+
"Wea. Forecasting, 15, 61–79"),
|
| 664 |
+
_ref("Bolton, D.", 1980,
|
| 665 |
+
"The computation of equivalent potential temperature",
|
| 666 |
+
"Mon. Wea. Rev., 108, 1046–1053"),
|
| 667 |
+
]),
|
| 668 |
+
])
|
| 669 |
+
|
| 670 |
+
|
| 671 |
# ---------------------------------------------------------------------------
|
| 672 |
# Layout
|
| 673 |
# ---------------------------------------------------------------------------
|
| 674 |
|
| 675 |
def _build_layout():
|
|
|
|
| 676 |
diag = _run_model(SND_DEFAULTS, UPD_DEFAULTS, WK_U, WK_V, ZETA_DEFAULTS)
|
| 677 |
|
| 678 |
+
# ---- Left panel ----
|
| 679 |
left = html.Div(className="uf-left", children=[
|
| 680 |
dcc.Tabs(id="ctrl-tabs", value="sounding", className="uf-tabs", children=[
|
| 681 |
|
|
|
|
| 682 |
dcc.Tab(label="Sounding", value="sounding", className="uf-tab",
|
| 683 |
selected_className="uf-tab-sel", children=html.Div([
|
| 684 |
html.Div("Weisman-Klemp Sounding", className="uf-section-title"),
|
| 685 |
+
_slider("snd-theta-ml", "θ_ml (K)", 295, 315, 0.5, SND_DEFAULTS["theta_ml"],
|
| 686 |
+
tip="Mixed-layer potential temperature. Higher values give a warmer "
|
| 687 |
+
"boundary layer and more buoyancy."),
|
| 688 |
+
_slider("snd-qv-ml", "qv_ml (g/kg)", 8, 20, 0.5, SND_DEFAULTS["qv_ml"],
|
| 689 |
+
tip="Boundary-layer water vapor mixing ratio. More moisture lowers "
|
| 690 |
+
"the LCL and increases CAPE."),
|
| 691 |
+
_slider("snd-z-ml", "BL depth (m)", 500, 2000, 100, SND_DEFAULTS["z_ml"], " m",
|
| 692 |
+
tip="Depth of the well-mixed layer. The constant-θ and qv profile "
|
| 693 |
+
"extends to this height."),
|
| 694 |
+
_slider("snd-z-trop", "Tropopause ht. (m)", 9000, 14000, 250,
|
| 695 |
+
SND_DEFAULTS["z_trop"], " m",
|
| 696 |
+
tip="Height of the tropopause. A higher tropopause allows a deeper "
|
| 697 |
+
"updraft and more CAPE."),
|
| 698 |
+
_slider("snd-T-trop", "Tropopause T (K)", 195, 220, 1,
|
| 699 |
+
SND_DEFAULTS["T_trop"], " K",
|
| 700 |
+
tip="Temperature at the tropopause. Colder tropopause → larger "
|
| 701 |
+
"temperature difference from the surface → more CAPE."),
|
| 702 |
+
_slider("snd-gamma", "Lapse rate exp.", 0.8, 1.8, 0.05,
|
| 703 |
+
SND_DEFAULTS["gamma_ft"],
|
| 704 |
+
tip="Shape exponent γ for the free-troposphere θ profile: "
|
| 705 |
+
"θ = θ_ml + (θ_trop−θ_ml)·((z−z_ml)/(z_trop−z_ml))^γ. "
|
| 706 |
+
"γ=1 linear; γ<1 more unstable near surface; γ>1 near tropopause."),
|
| 707 |
html.Div(className="uf-preset-row", children=[
|
| 708 |
+
html.Button("WK Supercell", id="preset-wk", className="uf-btn"),
|
| 709 |
html.Button("Weak shear", id="preset-weak", className="uf-btn"),
|
| 710 |
html.Button("Reset", id="preset-reset", className="uf-btn"),
|
| 711 |
]),
|
| 712 |
])),
|
| 713 |
|
|
|
|
| 714 |
dcc.Tab(label="Updraft", value="updraft", className="uf-tab",
|
| 715 |
selected_className="uf-tab-sel", children=html.Div([
|
| 716 |
html.Div("Updraft Core", className="uf-section-title"),
|
| 717 |
html.Div([
|
| 718 |
html.Span("ΔT surface (K)", className="uf-slider-label"),
|
| 719 |
+
_help("Temperature excess of the surface parcel above the environment. "
|
| 720 |
+
"Drives the diagnosed w(z): w²(z) = max(0, 2·∫B dz). "
|
| 721 |
+
"Larger ΔT → more KE to overcome CIN → stronger updraft."),
|
| 722 |
dcc.Input(id="upd-delta-T", type="number", value=UPD_DEFAULTS["delta_T"],
|
| 723 |
step=0.1, min=0.1, max=10.0,
|
| 724 |
style={"width": "80px", "marginLeft": "8px",
|
| 725 |
"background": "#0f1520", "border": "1px solid #2d3a4b",
|
| 726 |
+
"color": "#dfe3ea", "padding": "4px 8px",
|
| 727 |
+
"borderRadius": "4px"}),
|
| 728 |
], style={"display": "flex", "alignItems": "center", "marginBottom": "10px"}),
|
| 729 |
+
_slider("upd-r0", "Radius (m)", 500, 5000, 100, UPD_DEFAULTS["r0"], " m",
|
| 730 |
+
tip="Updraft core radius. The radial shape function tapers w and ζ "
|
| 731 |
+
"to zero at this distance from center."),
|
| 732 |
+
html.Div([
|
| 733 |
+
html.Div("Prescribed ζ(z) — accumulated storm rotation",
|
| 734 |
+
className="uf-section-title", style={"marginTop": "12px"}),
|
| 735 |
+
_help("Vertical vorticity profile representing rotation that the storm "
|
| 736 |
+
"has built up via tilting and stretching over its lifetime. "
|
| 737 |
+
"Drag circles rightward for cyclonic (positive) rotation. "
|
| 738 |
+
"This drives p'_spin and updraft helicity."),
|
| 739 |
+
], style={"display": "flex", "alignItems": "center", "gap": "6px"}),
|
| 740 |
_profile_editor("zeta-profile", "ζ(z) (s⁻¹)",
|
| 741 |
ZETA_DEFAULTS, ZETA_Z_KM, "s⁻¹", (-0.05, 0.05)),
|
| 742 |
+
html.Div([
|
| 743 |
+
html.Span("Core shape", className="uf-slider-label"),
|
| 744 |
+
_help("Top-hat: uniform w inside 90% of r₀ with cosine taper in the outer 10%. "
|
| 745 |
+
"Cosine bell: w(r) = cos(π·r/2r₀), smooth with no flat core."),
|
| 746 |
+
], style={"display": "flex", "alignItems": "center", "gap": "6px",
|
| 747 |
+
"marginTop": "10px"}),
|
| 748 |
dcc.RadioItems(
|
| 749 |
id="upd-shape",
|
| 750 |
options=[{"label": " Top-hat", "value": "tophat"},
|
| 751 |
+
{"label": " Cosine", "value": "cosine"}],
|
| 752 |
value=UPD_DEFAULTS["shape"],
|
| 753 |
labelStyle={"marginRight": "14px", "color": "#dfe3ea", "fontSize": "13px"},
|
| 754 |
style={"marginBottom": "10px"},
|
| 755 |
),
|
| 756 |
])),
|
| 757 |
|
|
|
|
| 758 |
dcc.Tab(label="Hodograph", value="hodograph", className="uf-tab",
|
| 759 |
selected_className="uf-tab-sel", children=html.Div([
|
| 760 |
html.Div("Environmental hodograph", className="uf-section-title"),
|
| 761 |
dcc.Graph(id="hodograph",
|
| 762 |
figure=_hodo_figure(WK_U, WK_V,
|
| 763 |
storm_u=diag["storm_u"], storm_v=diag["storm_v"],
|
| 764 |
+
lm_u=diag["lm_u"], lm_v=diag["lm_v"],
|
| 765 |
+
mean_u=diag["mean_u"], mean_v=diag["mean_v"]),
|
| 766 |
config={"edits": {"shapePosition": True},
|
| 767 |
"displayModeBar": False, "scrollZoom": False}),
|
| 768 |
+
html.Div([
|
| 769 |
+
html.Span("Drag the gold circles to edit the wind at each level (km label). "
|
| 770 |
+
"RM = Bunkers right-mover, LM = left-mover, × = mean wind.",
|
| 771 |
+
style={"color": "#8f98a3", "fontSize": "11px"}),
|
| 772 |
+
_help("Wind levels: 0, 1, 2, 3, 4, 5, 6, 8, 10 km. "
|
| 773 |
+
"Storm motion (RM/LM) is computed using Bunkers et al. (2000): "
|
| 774 |
+
"mean 0–6 km wind ± 7.5 m/s perpendicular to the 0–6 km shear vector. "
|
| 775 |
+
"SRH is computed relative to the right-mover."),
|
| 776 |
+
], style={"display": "flex", "alignItems": "flex-start", "gap": "6px",
|
| 777 |
+
"marginTop": "6px"}),
|
| 778 |
])),
|
| 779 |
]),
|
| 780 |
])
|
| 781 |
|
| 782 |
+
# ---- Center panel ----
|
| 783 |
center = html.Div(className="uf-center", children=[
|
| 784 |
html.Div(className="uf-field-controls", children=[
|
| 785 |
dcc.Dropdown(
|
| 786 |
id="field-select",
|
| 787 |
options=[{"label": v, "value": k} for k, v in FIELD_LABELS.items()],
|
| 788 |
+
value="w", clearable=False,
|
|
|
|
| 789 |
style={"width": "240px", "fontSize": "13px"},
|
| 790 |
),
|
| 791 |
dcc.Tabs(id="view-tabs", value="plan", className="uf-view-tabs", children=[
|
|
|
|
| 803 |
style={"color": "#6ecbff", "fontSize": "12px", "minWidth": "80px"}),
|
| 804 |
]),
|
| 805 |
dcc.Graph(id="main-heatmap", config={"displayModeBar": False}),
|
|
|
|
| 806 |
html.Div(className="uf-section-title", style={"marginTop": "14px"},
|
| 807 |
children="Diagnosed w(z) from parcel model"),
|
| 808 |
dcc.Graph(id="w-profile-graph", config={"displayModeBar": False}),
|
| 809 |
])
|
| 810 |
|
| 811 |
+
# ---- Right panel ----
|
| 812 |
right = html.Div(className="uf-right", children=[
|
| 813 |
html.Div("Sounding Parameters", className="uf-section-title"),
|
| 814 |
html.Table(id="diag-table", style={"width": "100%", "borderCollapse": "collapse"}),
|
|
|
|
| 818 |
dcc.Graph(id="accel-profile", config={"displayModeBar": False}),
|
| 819 |
])
|
| 820 |
|
| 821 |
+
model_layout = html.Div(className="uf-main", children=[left, center, right])
|
| 822 |
+
|
| 823 |
return html.Div(className="uf-root", children=[
|
| 824 |
html.Div(className="uf-header", children=[
|
| 825 |
html.H1("UpdraftForcing", style={"margin": "0 0 4px 0", "fontSize": "22px"}),
|
| 826 |
+
html.Div("Convective updraft wind shear diagnostics",
|
| 827 |
style={"color": "#9aa3ad", "fontSize": "12px"}),
|
| 828 |
]),
|
| 829 |
+
dcc.Tabs(id="page-tabs", value="model", className="uf-page-tabs", children=[
|
| 830 |
+
dcc.Tab(label="Model", value="model", className="uf-ptab", selected_className="uf-ptab-sel",
|
| 831 |
+
children=model_layout),
|
| 832 |
+
dcc.Tab(label="Getting Started", value="help", className="uf-ptab", selected_className="uf-ptab-sel",
|
| 833 |
+
children=_getting_started_content()),
|
| 834 |
+
dcc.Tab(label="Theory", value="theory", className="uf-ptab", selected_className="uf-ptab-sel",
|
| 835 |
+
children=_theory_content()),
|
| 836 |
+
]),
|
| 837 |
+
dcc.Store(id="hodo-store", data={"u": WK_U, "v": WK_V}),
|
| 838 |
+
dcc.Store(id="zeta-store", data={"zeta": ZETA_DEFAULTS}),
|
| 839 |
+
dcc.Store(id="model-rev", data=0),
|
| 840 |
])
|
| 841 |
|
| 842 |
|
|
|
|
| 846 |
|
| 847 |
def _register_callbacks(app):
|
| 848 |
|
|
|
|
|
|
|
|
|
|
| 849 |
for sid, unit in [("snd-theta-ml", " K"), ("snd-qv-ml", " g/kg"),
|
| 850 |
("snd-z-ml", " m"), ("snd-z-trop", " m"),
|
| 851 |
("snd-T-trop", " K"), ("snd-gamma", ""),
|
|
|
|
| 855 |
def _upd_label(v, _unit=unit):
|
| 856 |
return f"{v}{_unit}"
|
| 857 |
|
|
|
|
|
|
|
|
|
|
| 858 |
@app.callback(
|
| 859 |
[Output("snd-theta-ml", "value"), Output("snd-qv-ml", "value"),
|
| 860 |
Output("snd-z-ml", "value"), Output("snd-z-trop", "value"),
|
|
|
|
| 868 |
prevent_initial_call=True,
|
| 869 |
)
|
| 870 |
def _preset(wk, weak, reset):
|
| 871 |
+
if ctx.triggered_id == "preset-weak":
|
|
|
|
| 872 |
u = [0, 5, 10, 15, 20, 22, 23, 24, 25]
|
| 873 |
v = [0, 0, 0, 0, 0, 0, 0, 0, 0]
|
| 874 |
+
return (300, 12, 1000, 11000, 215, 1.2, 2.0, 2500, "tophat", {"u": u, "v": v})
|
|
|
|
|
|
|
| 875 |
return (SND_DEFAULTS["theta_ml"], SND_DEFAULTS["qv_ml"],
|
| 876 |
SND_DEFAULTS["z_ml"], SND_DEFAULTS["z_trop"],
|
| 877 |
SND_DEFAULTS["T_trop"], SND_DEFAULTS["gamma_ft"],
|
| 878 |
UPD_DEFAULTS["delta_T"], UPD_DEFAULTS["r0"],
|
| 879 |
+
UPD_DEFAULTS["shape"], {"u": WK_U, "v": WK_V})
|
|
|
|
| 880 |
|
|
|
|
|
|
|
|
|
|
| 881 |
@app.callback(
|
| 882 |
Output("hodo-store", "data"),
|
| 883 |
Input("hodograph", "relayoutData"),
|
|
|
|
| 887 |
def _hodo_drag(relay, store):
|
| 888 |
if not relay:
|
| 889 |
return no_update
|
| 890 |
+
u = list(store["u"]); v = list(store["v"]); changed = False
|
|
|
|
|
|
|
| 891 |
for i in range(len(u)):
|
| 892 |
+
for key, lst in [(f"shapes[{i}].xanchor", u), (f"shapes[{i}].yanchor", v)]:
|
| 893 |
+
if key in relay:
|
| 894 |
+
try:
|
| 895 |
+
lst[i] = max(-60.0, min(60.0, float(relay[key]))); changed = True
|
| 896 |
+
except (TypeError, ValueError):
|
| 897 |
+
pass
|
| 898 |
+
return {"u": u, "v": v} if changed else no_update
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 899 |
|
|
|
|
|
|
|
|
|
|
| 900 |
@app.callback(
|
| 901 |
Output("zeta-store", "data"),
|
| 902 |
Input("zeta-profile", "relayoutData"),
|
|
|
|
| 906 |
def _zeta_drag(relay, store):
|
| 907 |
if not relay:
|
| 908 |
return no_update
|
| 909 |
+
zeta = list(store["zeta"]); changed = False
|
|
|
|
| 910 |
for i in range(len(zeta)):
|
| 911 |
kxa = f"shapes[{i}].xanchor"
|
| 912 |
if kxa in relay:
|
| 913 |
try:
|
| 914 |
+
zeta[i] = max(-0.10, min(0.10, float(relay[kxa]))); changed = True
|
|
|
|
| 915 |
except (TypeError, ValueError):
|
| 916 |
pass
|
| 917 |
+
return {"zeta": zeta} if changed else no_update
|
|
|
|
|
|
|
| 918 |
|
|
|
|
|
|
|
|
|
|
| 919 |
@app.callback(
|
| 920 |
Output("hodograph", "figure"),
|
| 921 |
[Input("hodo-store", "data"), Input("model-rev", "data")],
|
| 922 |
)
|
| 923 |
def _redraw_hodo(store, _rev):
|
| 924 |
+
d = _C.get("diag", {})
|
| 925 |
+
return _hodo_figure(store["u"], store["v"],
|
| 926 |
+
storm_u=d.get("storm_u"), storm_v=d.get("storm_v"),
|
| 927 |
+
lm_u=d.get("lm_u"), lm_v=d.get("lm_v"),
|
| 928 |
+
mean_u=d.get("mean_u"), mean_v=d.get("mean_v"))
|
|
|
|
|
|
|
| 929 |
|
|
|
|
|
|
|
|
|
|
| 930 |
@app.callback(
|
| 931 |
Output("model-rev", "data"),
|
| 932 |
[Input("snd-theta-ml", "value"), Input("snd-qv-ml", "value"),
|
|
|
|
| 942 |
delta_T, r0, shape, hodo, zeta_data, rev):
|
| 943 |
snd_p = dict(
|
| 944 |
theta_ml=theta_ml or SND_DEFAULTS["theta_ml"],
|
| 945 |
+
qv_ml=qv_ml or SND_DEFAULTS["qv_ml"],
|
| 946 |
+
z_ml=z_ml or SND_DEFAULTS["z_ml"],
|
| 947 |
+
z_trop=z_trop or SND_DEFAULTS["z_trop"],
|
| 948 |
+
T_trop=T_trop or SND_DEFAULTS["T_trop"],
|
| 949 |
gamma_ft=gamma or SND_DEFAULTS["gamma_ft"],
|
| 950 |
)
|
| 951 |
upd_p = dict(
|
| 952 |
delta_T=delta_T or UPD_DEFAULTS["delta_T"],
|
| 953 |
+
r0=r0 or UPD_DEFAULTS["r0"],
|
| 954 |
shape=shape or UPD_DEFAULTS["shape"],
|
| 955 |
)
|
| 956 |
try:
|
| 957 |
+
_run_model(snd_p, upd_p, hodo["u"], hodo["v"], zeta_data["zeta"])
|
|
|
|
|
|
|
| 958 |
except Exception as exc:
|
| 959 |
import traceback; traceback.print_exc()
|
| 960 |
print(f"[UpdraftForcing] compute error: {exc!r}", flush=True)
|
| 961 |
return (rev or 0) + 1
|
| 962 |
|
|
|
|
|
|
|
|
|
|
| 963 |
@app.callback(
|
| 964 |
Output("slice-label", "children"),
|
| 965 |
[Input("slice-slider", "value"), Input("view-tabs", "value")],
|
|
|
|
| 975 |
else:
|
| 976 |
return f"x = {X_KM[idx % NX]:.1f} km"
|
| 977 |
|
|
|
|
|
|
|
|
|
|
| 978 |
@app.callback(
|
| 979 |
[Output("slice-slider", "max"), Output("slice-slider", "value")],
|
| 980 |
Input("view-tabs", "value"),
|
| 981 |
)
|
| 982 |
def _slice_range(view):
|
| 983 |
+
if view == "plan": return NZ - 1, NZ // 4
|
| 984 |
+
if view == "xcross": return NY - 1, NY // 2
|
| 985 |
+
return NX - 1, NX // 2
|
|
|
|
|
|
|
|
|
|
| 986 |
|
|
|
|
|
|
|
|
|
|
| 987 |
@app.callback(
|
| 988 |
Output("main-heatmap", "figure"),
|
| 989 |
+
[Input("field-select", "value"), Input("view-tabs", "value"),
|
| 990 |
+
Input("slice-slider", "value"), Input("model-rev", "data")],
|
|
|
|
|
|
|
| 991 |
)
|
| 992 |
def _display(field, view, idx, _rev):
|
| 993 |
arr = _C.get(field)
|
| 994 |
if arr is None:
|
| 995 |
return go.Figure()
|
|
|
|
| 996 |
if view == "plan":
|
| 997 |
k = min(idx, NZ - 1)
|
| 998 |
+
return _field_heatmap(arr[:, :, k], X_KM, Y_KM, "x (km)", "y (km)",
|
| 999 |
+
f"{FIELD_LABELS.get(field, field)} — z = {Z_GRID[k]/1000:.1f} km", field)
|
|
|
|
| 1000 |
elif view == "xcross":
|
| 1001 |
j = min(idx, NY - 1)
|
| 1002 |
+
return _field_heatmap(arr[:, j, :], X_KM, Z_GRID/1000.0, "x (km)", "z (km)",
|
| 1003 |
+
f"{FIELD_LABELS.get(field, field)} — y = {Y_KM[j]:.1f} km", field)
|
|
|
|
|
|
|
| 1004 |
else:
|
| 1005 |
i = min(idx, NX - 1)
|
| 1006 |
+
return _field_heatmap(arr[i, :, :], Y_KM, Z_GRID/1000.0, "y (km)", "z (km)",
|
| 1007 |
+
f"{FIELD_LABELS.get(field, field)} — x = {X_KM[i]:.1f} km", field)
|
| 1008 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1009 |
@app.callback(
|
| 1010 |
Output("w-profile-graph", "figure"),
|
| 1011 |
Input("model-rev", "data"),
|
| 1012 |
)
|
| 1013 |
def _w_profile_fig(_rev):
|
| 1014 |
+
w_z = _C.get("w_z", np.zeros(NZ))
|
| 1015 |
+
EL = _C.get("EL_m", 12000.0)
|
| 1016 |
z_top = _C.get("z_top_m", 13000.0)
|
| 1017 |
+
z_km = Z_GRID / 1000.0
|
|
|
|
| 1018 |
fig = go.Figure()
|
| 1019 |
+
fig.add_trace(go.Scatter(x=w_z, y=z_km, mode="lines",
|
| 1020 |
+
line=dict(color="#4ab8e0", width=2),
|
| 1021 |
+
hovertemplate="%{x:.1f} m/s @ %{y:.2f} km<extra></extra>",
|
| 1022 |
+
showlegend=False))
|
| 1023 |
+
fig.add_hline(y=EL/1000.0, line=dict(color="#ffd685", dash="dash", width=1.5),
|
|
|
|
|
|
|
| 1024 |
annotation_text="EL", annotation_font_color="#ffd685",
|
| 1025 |
annotation_position="top right")
|
| 1026 |
+
fig.add_hline(y=z_top/1000.0, line=dict(color="#ff8c00", dash="dash", width=1.5),
|
| 1027 |
annotation_text="Overshoot top", annotation_font_color="#ff8c00",
|
| 1028 |
annotation_position="top right")
|
| 1029 |
+
fig.update_layout(xaxis_title="w (m s⁻¹)", yaxis_title="z (km)",
|
| 1030 |
+
template="plotly_dark",
|
| 1031 |
+
margin=dict(l=50, r=10, t=10, b=35), height=200,
|
| 1032 |
+
xaxis=dict(rangemode="tozero"))
|
|
|
|
|
|
|
|
|
|
| 1033 |
return fig
|
| 1034 |
|
|
|
|
|
|
|
|
|
|
| 1035 |
@app.callback(
|
| 1036 |
Output("diag-table", "children"),
|
| 1037 |
Input("model-rev", "data"),
|
|
|
|
| 1054 |
]
|
| 1055 |
return html.Tbody(rows)
|
| 1056 |
|
|
|
|
|
|
|
|
|
|
| 1057 |
@app.callback(
|
| 1058 |
[Output("buoy-profile", "figure"), Output("accel-profile", "figure")],
|
| 1059 |
Input("model-rev", "data"),
|
|
|
|
| 1061 |
def _profiles(_rev):
|
| 1062 |
z_km = Z_GRID / 1000.0
|
| 1063 |
cx, cy = NX // 2, NY // 2
|
|
|
|
| 1064 |
B_z = _C.get("B_z", np.zeros(NZ))
|
| 1065 |
buoy_fig = _profile_fig(B_z, z_km, "Buoyancy B(z)", "#4ab8e0", "m s⁻²")
|
| 1066 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1067 |
fig = go.Figure()
|
| 1068 |
+
for key, name, col in [("a_lin", "Linear", "#e09c4a"),
|
| 1069 |
+
("a_spin", "Spin", "#c8d44a"),
|
| 1070 |
+
("a_splat","Splat", "#e06c6c"),
|
| 1071 |
+
("a_buoy", "Buoyancy", "#4ab8e0")]:
|
| 1072 |
+
arr = _C.get(key, np.zeros((NX, NY, NZ)))
|
| 1073 |
+
fig.add_trace(go.Scatter(x=arr[cx, cy, :], y=z_km, mode="lines", name=name,
|
| 1074 |
+
line=dict(color=col, width=1.8),
|
| 1075 |
+
hovertemplate=f"%{{x:.3g}} m/s²<br>%{{y:.1f}} km<extra>{name}</extra>"))
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1076 |
fig.add_vline(x=0, line=dict(color="#555", width=1))
|
| 1077 |
+
fig.update_layout(xaxis_title="acceleration (m s⁻²)", yaxis_title="z (km)",
|
| 1078 |
+
template="plotly_dark",
|
| 1079 |
+
margin=dict(l=50, r=10, t=10, b=35), height=220,
|
| 1080 |
+
legend=dict(font=dict(size=10), orientation="h", y=1.05))
|
|
|
|
|
|
|
|
|
|
| 1081 |
return buoy_fig, fig
|
| 1082 |
|
| 1083 |
|
|
|
|
| 1088 |
_CSS = """
|
| 1089 |
body { background: #0b0e14; color: #dfe3ea; font-family: -apple-system, system-ui, sans-serif; margin: 0; }
|
| 1090 |
.uf-root { max-width: 1600px; margin: 0 auto; padding: 16px; }
|
| 1091 |
+
.uf-header { margin-bottom: 6px; }
|
| 1092 |
.uf-header h1 { color: #6ecbff; }
|
| 1093 |
+
|
| 1094 |
+
/* Page-level navigation tabs */
|
| 1095 |
+
.uf-page-tabs { margin-bottom: 0; }
|
| 1096 |
+
.uf-page-tabs > .tab-container { border-bottom: 1px solid #2d3a4b !important; margin-bottom: 14px; }
|
| 1097 |
+
.uf-ptab { background: transparent !important; border: none !important; color: #9aa3ad !important; font-size: 13px !important; padding: 7px 18px !important; }
|
| 1098 |
+
.uf-ptab-sel { color: #6ecbff !important; border-bottom: 2px solid #6ecbff !important; background: transparent !important; }
|
| 1099 |
+
|
| 1100 |
+
/* Three-column model layout */
|
| 1101 |
.uf-main { display: grid; grid-template-columns: 320px 1fr 280px; gap: 16px; }
|
| 1102 |
+
.uf-left { background: #11161f; border-radius: 8px; padding: 12px; min-height: 600px; }
|
| 1103 |
+
.uf-center{ background: #11161f; border-radius: 8px; padding: 12px; }
|
| 1104 |
.uf-right { background: #11161f; border-radius: 8px; padding: 12px; }
|
| 1105 |
+
|
| 1106 |
.uf-section-title { font-size: 11px; font-weight: 600; color: #8d97a2; letter-spacing: 0.08em; text-transform: uppercase; margin-bottom: 8px; margin-top: 4px; }
|
| 1107 |
.uf-slider-row { margin-bottom: 10px; }
|
| 1108 |
+
.uf-slider-header { display: flex; justify-content: space-between; align-items: center; font-size: 12px; margin-bottom: 2px; }
|
| 1109 |
.uf-slider-label { color: #c7ced6; }
|
| 1110 |
.uf-slider-value { color: #6ecbff; font-variant-numeric: tabular-nums; }
|
| 1111 |
.uf-tabs .tab { background: #161d29; border: none; color: #9aa3ad; font-size: 12px; padding: 6px 12px; }
|
|
|
|
| 1121 |
.uf-btn { background: #1e2835; border: 1px solid #2d3a4b; color: #dfe3ea; padding: 5px 10px; border-radius: 5px; cursor: pointer; font-size: 12px; }
|
| 1122 |
.uf-btn:hover { background: #2a3a4e; }
|
| 1123 |
table tr:nth-child(even) td { background: #161d29; }
|
| 1124 |
+
|
| 1125 |
+
/* ? tooltip */
|
| 1126 |
+
.uf-help {
|
| 1127 |
+
display: inline-flex; align-items: center; justify-content: center;
|
| 1128 |
+
width: 15px; height: 15px; border-radius: 50%;
|
| 1129 |
+
background: #253040; color: #6ecbff;
|
| 1130 |
+
font-size: 9px; font-weight: 700; cursor: help;
|
| 1131 |
+
position: relative; flex-shrink: 0; user-select: none;
|
| 1132 |
+
}
|
| 1133 |
+
.uf-help::after {
|
| 1134 |
+
content: attr(data-tip);
|
| 1135 |
+
position: absolute;
|
| 1136 |
+
right: 0; top: 20px;
|
| 1137 |
+
background: #1a2233; color: #dfe3ea;
|
| 1138 |
+
padding: 8px 11px; border-radius: 6px;
|
| 1139 |
+
border: 1px solid #3b4d63;
|
| 1140 |
+
font-size: 11px; font-weight: 400; line-height: 1.55;
|
| 1141 |
+
white-space: normal; width: 220px;
|
| 1142 |
+
z-index: 2000; display: none;
|
| 1143 |
+
box-shadow: 0 4px 16px rgba(0,0,0,0.5);
|
| 1144 |
+
pointer-events: none;
|
| 1145 |
+
}
|
| 1146 |
+
.uf-help:hover::after { display: block; }
|
| 1147 |
+
|
| 1148 |
+
/* Getting started & Theory pages */
|
| 1149 |
+
.uf-page-content { max-width: 900px; margin: 0 auto; padding: 8px 20px 40px 20px; }
|
| 1150 |
+
.page-h2 { color: #6ecbff; font-size: 20px; margin-bottom: 20px; }
|
| 1151 |
+
.gs-intro { color: #c7ced6; font-size: 13px; line-height: 1.7; margin-bottom: 24px; }
|
| 1152 |
+
.gs-step { margin-bottom: 28px; }
|
| 1153 |
+
.gs-step-title { font-size: 13px; font-weight: 700; color: #ffd685; margin-bottom: 10px; letter-spacing: 0.03em; }
|
| 1154 |
+
.gs-step-body { font-size: 13px; color: #c7ced6; line-height: 1.65; }
|
| 1155 |
+
.gs-step-body p { margin: 0 0 8px 0; }
|
| 1156 |
+
.gs-table { width: 100%; border-collapse: collapse; }
|
| 1157 |
+
.gs-table tr:nth-child(even) td { background: #161d29; }
|
| 1158 |
+
.gs-ctrl-label { color: #6ecbff; font-size: 12px; font-weight: 600; padding: 5px 12px 5px 8px; white-space: nowrap; vertical-align: top; width: 140px; }
|
| 1159 |
+
.gs-ctrl-desc { color: #c7ced6; font-size: 12px; padding: 5px 8px; line-height: 1.55; }
|
| 1160 |
+
|
| 1161 |
+
.theory-h3 { color: #ffd685; font-size: 14px; margin: 24px 0 8px 0; }
|
| 1162 |
+
.theory-p-md p { color: #c7ced6; font-size: 13px; line-height: 1.65; margin: 0 0 8px 0; }
|
| 1163 |
+
.theory-p-md strong { color: #dfe3ea; }
|
| 1164 |
+
.theory-eq-md { background: #0a0f1a; border-left: 3px solid #3b86e6; border-radius: 4px; padding: 4px 14px; margin: 8px 0 12px 0; text-align: center; overflow-x: auto; }
|
| 1165 |
+
.theory-eq-md .MathJax { color: #b0d8ff !important; font-size: 1.05em !important; }
|
| 1166 |
+
.theory-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin: 12px 0 20px 0; }
|
| 1167 |
+
.theory-term { background: #11161f; border-radius: 6px; padding: 12px; }
|
| 1168 |
+
.theory-term-title { font-size: 12px; font-weight: 700; color: #6ecbff; margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.06em; }
|
| 1169 |
+
.theory-refs { color: #c7ced6; font-size: 13px; line-height: 1.8; padding-left: 20px; }
|
| 1170 |
+
.theory-ref { margin-bottom: 6px; }
|
| 1171 |
+
.assume-table { width: 100%; border-collapse: collapse; margin: 10px 0 20px 0; font-size: 12px; }
|
| 1172 |
+
.assume-table tr:nth-child(even) td { background: #161d29; }
|
| 1173 |
+
.assume-check { color: #5ac87a; font-size: 13px; padding: 5px 10px 5px 6px; width: 18px; vertical-align: top; }
|
| 1174 |
+
.assume-text { color: #dfe3ea; font-weight: 600; padding: 5px 12px 5px 4px; width: 310px; vertical-align: top; }
|
| 1175 |
+
.assume-note { color: #8d97a2; padding: 5px 4px; line-height: 1.5; vertical-align: top; }
|
| 1176 |
"""
|
| 1177 |
|
| 1178 |
|