snesbitt commited on
Commit
bdb6526
·
1 Parent(s): 87602e0

Initial commit: UpdraftForcing Dash app

Browse files
Files changed (1) hide show
  1. 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
- # Pressure forcing and solve
100
- rho0 = snd["rho"]
101
  theta0 = snd["theta"]
102
 
103
- F_lin = forcing_linear(rho0, dudz_env, dvdz_env, fields["w3d"], DX)
104
- F_spin = forcing_spin(rho0, fields["u3d"], fields["v3d"], fields["w3d"],
105
- env_u, env_v, DX, DZ)
106
  F_splat = forcing_splat(rho0, fields["u3d"], fields["v3d"], fields["w3d"],
107
  env_u, env_v, DX, DZ)
108
- F_buoy = forcing_buoyancy(rho0, theta0, fields["theta_prime3d"], DZ)
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 _slider(id_, label, mn, mx, step, value, unit=""):
 
 
 
 
 
 
 
 
159
  return html.Div(
160
  className="uf-slider-row",
161
  children=[
162
  html.Div(
163
- [html.Span(label, className="uf-slider-label"),
 
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 identical to Mountain Waves pattern."""
175
  values = np.asarray(values, dtype=float)
176
- z_km = np.asarray(z_km, dtype=float)
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 i, (u, v) in enumerate(zip(u_pts, v_pts)):
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
- # Level annotations
249
- for i, (u, v, lkm) in enumerate(zip(u_pts, v_pts, levels_km)):
250
- fig.add_annotation(
251
- x=float(u), y=float(v),
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
- dragmode=False,
293
- xaxis=dict(range=[ulo, uhi], zeroline=True, zerolinecolor="#555", fixedrange=True),
294
- yaxis=dict(range=[vlo, vhi], zeroline=True, zerolinecolor="#555", fixedrange=True,
 
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
- """Return a Plotly Figure of a 2-D slice."""
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
- _slider("snd-qv-ml", "qv_ml (g/kg)", 8, 20, 0.5, SND_DEFAULTS["qv_ml"]),
367
- _slider("snd-z-ml", "BL depth (m)", 500, 2000, 100, SND_DEFAULTS["z_ml"], " m"),
368
- _slider("snd-z-trop", "Tropopause ht. (m)", 9000, 14000, 250, SND_DEFAULTS["z_trop"], " m"),
369
- _slider("snd-T-trop", "Tropopause T (K)", 195, 220, 1, SND_DEFAULTS["T_trop"], " K"),
370
- _slider("snd-gamma", "Lapse rate exp.", 0.8, 1.8, 0.05, SND_DEFAULTS["gamma_ft"]),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
371
  html.Div(className="uf-preset-row", children=[
372
- html.Button("WK Supercell", id="preset-wk", className="uf-btn"),
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", "borderRadius": "4px"}),
 
389
  ], style={"display": "flex", "alignItems": "center", "marginBottom": "10px"}),
390
- _slider("upd-r0", "Radius (m)", 500, 5000, 100, UPD_DEFAULTS["r0"], " m"),
391
- html.Div("Prescribed ζ(z) accumulated storm rotation",
392
- className="uf-section-title", style={"marginTop": "12px"}),
 
 
 
 
 
 
 
 
393
  _profile_editor("zeta-profile", "ζ(z) (s⁻¹)",
394
  ZETA_DEFAULTS, ZETA_Z_KM, "s⁻¹", (-0.05, 0.05)),
395
- html.Div("Core shape", className="uf-slider-label", style={"marginTop": "10px"}),
 
 
 
 
 
396
  dcc.RadioItems(
397
  id="upd-shape",
398
  options=[{"label": " Top-hat", "value": "tophat"},
399
- {"label": " Cosine", "value": "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"], lm_v=diag["lm_v"],
414
- mean_u=diag["mean_u"], mean_v=diag["mean_v"]),
415
  config={"edits": {"shapePosition": True},
416
  "displayModeBar": False, "scrollZoom": False}),
417
- html.Div("Drag the gold circles to edit the wind at each level.",
418
- style={"color": "#8f98a3", "fontSize": "11px", "marginTop": "6px"}),
 
 
 
 
 
 
 
 
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 — Trapp (2013)",
466
  style={"color": "#9aa3ad", "fontSize": "12px"}),
467
  ]),
468
- # Hidden stores
469
- dcc.Store(id="hodo-store", data={"u": WK_U, "v": WK_V}),
470
- dcc.Store(id="zeta-store", data={"zeta": ZETA_DEFAULTS}),
471
- dcc.Store(id="model-rev", data=0),
472
- html.Div(className="uf-main", children=[left, center, right]),
 
 
 
 
 
 
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
- trig = ctx.triggered_id
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
- kxa = f"shapes[{i}].xanchor"
541
- kya = f"shapes[{i}].yanchor"
542
- if kxa in relay:
543
- try:
544
- u[i] = max(-60.0, min(60.0, float(relay[kxa])))
545
- changed = True
546
- except (TypeError, ValueError):
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
- if not changed:
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
- diag = _C.get("diag", {})
593
- return _hodo_figure(
594
- store["u"], store["v"],
595
- storm_u=diag.get("storm_u"), storm_v=diag.get("storm_v"),
596
- lm_u=diag.get("lm_u"), lm_v=diag.get("lm_v"),
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 or SND_DEFAULTS["qv_ml"],
619
- z_ml=z_ml or SND_DEFAULTS["z_ml"],
620
- z_trop=z_trop or SND_DEFAULTS["z_trop"],
621
- T_trop=T_trop or SND_DEFAULTS["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 or UPD_DEFAULTS["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
- return NZ - 1, NZ // 4
666
- elif view == "xcross":
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("view-tabs", "value"),
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
- slice2d = arr[:, :, k]
689
- title = f"{FIELD_LABELS.get(field, field)} — z = {Z_GRID[k]/1000:.1f} km"
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
- slice2d = arr[:, j, :] # (Nx, Nz)
694
- z_km = Z_GRID / 1000.0
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
- slice2d = arr[i, :, :] # (Ny, Nz)
700
- z_km = Z_GRID / 1000.0
701
- title = f"{FIELD_LABELS.get(field, field)} — x = {X_KM[i]:.1f} km"
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 = _C.get("w_z", np.zeros(NZ))
713
- EL = _C.get("EL_m", 12000.0)
714
  z_top = _C.get("z_top_m", 13000.0)
715
- z_km = Z_GRID / 1000.0
716
-
717
  fig = go.Figure()
718
- fig.add_trace(go.Scatter(
719
- x=w_z, y=z_km,
720
- mode="lines", line=dict(color="#4ab8e0", width=2),
721
- name="w_diagnosed", showlegend=False,
722
- hovertemplate="%{x:.1f} m/s @ %{y:.2f} km<extra></extra>",
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 / 1000.0, line=dict(color="#ff8c00", dash="dash", width=1.5),
728
  annotation_text="Overshoot top", annotation_font_color="#ff8c00",
729
  annotation_position="top right")
730
- fig.update_layout(
731
- xaxis_title="w (m s⁻¹)", yaxis_title="z (km)",
732
- template="plotly_dark",
733
- margin=dict(l=50, r=10, t=10, b=35),
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 arr, name, col in [
786
- (a_lin, "Linear", "#e09c4a"),
787
- (a_spin, "Spin", "#c8d44a"),
788
- (a_splat, "Splat", "#e06c6c"),
789
- (a_buoy, "Buoyancy","#4ab8e0"),
790
- ]:
791
- fig.add_trace(go.Scatter(
792
- x=arr[cx, cy, :], y=z_km,
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
- xaxis_title="acceleration (m s⁻²)", yaxis_title="z (km)",
800
- template="plotly_dark",
801
- margin=dict(l=50, r=10, t=10, b=35),
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: 14px; }
816
  .uf-header h1 { color: #6ecbff; }
 
 
 
 
 
 
 
 
817
  .uf-main { display: grid; grid-template-columns: 320px 1fr 280px; gap: 16px; }
818
- .uf-left { background: #11161f; border-radius: 8px; padding: 12px; min-height: 600px; }
819
- .uf-center { background: #11161f; border-radius: 8px; padding: 12px; }
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