"""Interactive Dash front end for the UpdraftForcing model.""" from __future__ import annotations import numpy as np from dash import Dash, Input, Output, State, ctx, dcc, html, no_update import plotly.graph_objects as go from .sounding import wk_sounding, lift_parcel from .updraft import diagnose_w_profile, build_updraft_fields from .pressure import ( forcing_linear, forcing_splat, forcing_spin, forcing_buoyancy, solve_poisson_3d, pressure_accelerations, ) from .diagnostics import collect_diagnostics, bunkers_storm_motion from .colortables import FIELD_LABELS, FIELD_META, clim # --------------------------------------------------------------------------- # Grid constants # --------------------------------------------------------------------------- NX, NY, NZ = 100, 100, 161 DX = DY = 100.0 # m DZ = 100.0 # m Z_GRID = np.linspace(0.0, (NZ - 1) * DZ, NZ) # 0 … 16 000 m X_KM = np.linspace(0.0, (NX - 1) * DX / 1000.0, NX) Y_KM = np.linspace(0.0, (NY - 1) * DY / 1000.0, NY) X2, Y2 = np.meshgrid(X_KM * 1000.0, Y_KM * 1000.0, indexing="ij") # (Nx, Ny) meters # --------------------------------------------------------------------------- # Hodograph defaults (Weisman-Klemp supercell) # --------------------------------------------------------------------------- HODO_LEVELS_KM = [0, 1, 2, 3, 4, 5, 6, 8, 10] WK_U = [0.0, 3.0, 6.0, 9.0, 12.0, 15.0, 18.0, 20.0, 21.0] WK_V = [0.0, 5.0, 9.0, 11.0, 12.0, 12.0, 11.0, 10.0, 9.0] # Vorticity profile control points (prescribed mature-storm rotation) ZETA_Z_KM = [0, 2, 4, 6, 8, 10, 12, 14, 16] ZETA_DEFAULTS = [0.0] * 9 # Sounding defaults SND_DEFAULTS = dict(theta_ml=300.0, qv_ml=14.0, z_ml=1000.0, z_trop=12000.0, T_trop=213.0, gamma_ft=1.25) # Updraft defaults UPD_DEFAULTS = dict(r0=2500.0, shape="tophat", delta_T=2.0) # --------------------------------------------------------------------------- # Server-side computed fields cache (single-user model) # --------------------------------------------------------------------------- _C: dict = {} # keyed by field name → 3-D numpy array or scalar def _build_env_winds(u_pts, v_pts): """Interpolate hodograph control points to full Z_GRID.""" z_hodo_m = np.asarray(HODO_LEVELS_KM) * 1000.0 env_u = np.interp(Z_GRID, z_hodo_m, np.asarray(u_pts, dtype=float)) env_v = np.interp(Z_GRID, z_hodo_m, np.asarray(v_pts, dtype=float)) dudz = np.gradient(env_u, DZ) dvdz = np.gradient(env_v, DZ) return env_u, env_v, dudz, dvdz def _run_model(snd_params, upd_params, u_pts, v_pts, zeta_cpts): """Full model run: sounding → updraft → pressure → diagnostics.""" snd_kw = dict( theta_ml = snd_params.get("theta_ml", SND_DEFAULTS["theta_ml"]), qv_ml_gkg = snd_params.get("qv_ml", SND_DEFAULTS["qv_ml"]), z_ml_m = snd_params.get("z_ml", SND_DEFAULTS["z_ml"]), z_trop_m = snd_params.get("z_trop", SND_DEFAULTS["z_trop"]), T_trop_K = snd_params.get("T_trop", SND_DEFAULTS["T_trop"]), gamma_ft = snd_params.get("gamma_ft", SND_DEFAULTS["gamma_ft"]), ) snd = wk_sounding(Z_GRID, **snd_kw) wp = diagnose_w_profile( Z_GRID, snd["T_K"], snd["qv"], snd["p_hPa"], delta_T_K=upd_params["delta_T"], ) env_u, env_v, dudz_env, dvdz_env = _build_env_winds(u_pts, v_pts) fields = build_updraft_fields( X2, Y2, Z_GRID, r0=upd_params["r0"], shape=upd_params["shape"], w_z=wp["w_z"], zeta_cpts=np.asarray(zeta_cpts), zeta_z_km=np.asarray(ZETA_Z_KM, dtype=float), env_u=env_u, env_v=env_v, theta_env=snd["theta"], theta_parcel=wp["T_parcel"] * (snd["p_hPa"] / 1000.0) ** 0.2854, ) rho0 = snd["rho"] theta0 = snd["theta"] F_lin = forcing_linear(rho0, dudz_env, dvdz_env, fields["w3d"], DX) F_spin = forcing_spin(rho0, fields["u3d"], fields["v3d"], fields["w3d"], env_u, env_v, DX, DZ) F_splat = forcing_splat(rho0, fields["u3d"], fields["v3d"], fields["w3d"], env_u, env_v, DX, DZ) F_buoy = forcing_buoyancy(rho0, theta0, fields["theta_prime3d"], DZ) p_lin = solve_poisson_3d(F_lin, DX, DZ) p_spin = solve_poisson_3d(F_spin, DX, DZ) p_splat = solve_poisson_3d(F_splat, DX, DZ) p_buoy = solve_poisson_3d(F_buoy, DX, DZ) p_total = p_lin + p_spin + p_splat + p_buoy accels = pressure_accelerations(p_lin, p_spin, p_splat, p_buoy, rho0, DZ) parcel_diag = { "CAPE": wp["CAPE"], "CIN": wp["CIN"], "LCL_m": wp["LCL_m"], "LFC_m": wp["LFC_m"], "EL_m": wp["EL_m"], "z_top_m": wp["z_top_m"], } diag = collect_diagnostics( Z_GRID, snd, parcel_diag, u_pts, v_pts, HODO_LEVELS_KM, fields["w3d"], fields["zeta3d"], ) _C.update({ "w": fields["w3d"], "u": fields["u3d"], "v": fields["v3d"], "zeta": fields["zeta3d"], "p_total": p_total, "p_lin": p_lin, "p_spin": p_spin, "p_splat": p_splat, "p_buoy": p_buoy, **accels, "snd": snd, "T_parcel": wp["T_parcel"], "w_z": wp["w_z"], "B_z": wp["B_z"], "EL_m": wp["EL_m"], "z_top_m": wp["z_top_m"], "diag": diag, "env_u": env_u, "env_v": env_v, }) return diag # --------------------------------------------------------------------------- # UI helpers # --------------------------------------------------------------------------- def _help(tip: str) -> html.Span: """Inline ? icon that shows a CSS tooltip on hover.""" return html.Span("?", className="uf-help", **{"data-tip": tip}) def _slider(id_, label, mn, mx, step, value, unit="", tip=""): label_group = [html.Span(label, className="uf-slider-label")] if tip: label_group.append(_help(tip)) return html.Div( className="uf-slider-row", children=[ html.Div( [html.Div(label_group, style={"display": "flex", "alignItems": "center", "gap": "4px"}), html.Span(f"{value}{unit}", id=f"{id_}-val", className="uf-slider-value")], className="uf-slider-header", ), dcc.Slider(id=id_, min=mn, max=mx, step=step, value=value, marks=None, tooltip={"always_visible": False}), ], ) def _profile_editor_fig(title, values, z_km, xunit, xrange): """Return a go.Figure for a draggable profile editor.""" values = np.asarray(values, dtype=float) z_km = np.asarray(z_km, dtype=float) vlo, vhi = xrange fig = go.Figure() fig.add_trace(go.Scatter( x=values, y=z_km, mode="lines+markers", line=dict(color="#6ecbff", width=2), marker=dict(size=7, color="#6ecbff"), hoverinfo="skip", showlegend=False, )) radius_px = 9 shapes = [] for v, zk in zip(values, z_km): shapes.append(dict( type="circle", xref="x", yref="y", xsizemode="pixel", ysizemode="pixel", xanchor=float(v), yanchor=float(zk), x0=-radius_px, x1=radius_px, y0=-radius_px, y1=radius_px, fillcolor="#ffd685", line=dict(color="white", width=1.5), editable=True, layer="above", )) fig.update_layout( title=dict(text=title, font=dict(size=12)), xaxis_title=xunit, yaxis_title="height (km)", template="plotly_dark", margin=dict(l=50, r=10, t=40, b=40), height=300, dragmode=False, xaxis=dict(range=[vlo, vhi], fixedrange=True), yaxis=dict(range=[0, z_km[-1] + 0.5], fixedrange=True), shapes=shapes, ) return fig def _profile_editor(fig_id, title, values, z_km, xunit, xrange): """Draggable profile graph (Mountain Waves pattern).""" return dcc.Graph( id=fig_id, figure=_profile_editor_fig(title, values, z_km, xunit, xrange), config={"edits": {"shapePosition": True}, "displayModeBar": False, "scrollZoom": False}, ) def _hodo_figure(u_pts, v_pts, storm_u=None, storm_v=None, lm_u=None, lm_v=None, mean_u=None, mean_v=None): u_pts = np.asarray(u_pts, dtype=float) v_pts = np.asarray(v_pts, dtype=float) fig = go.Figure() seg_colors = ["#e06c6c", "#e09c4a", "#c8d44a", "#5ac85a", "#4ab8e0", "#7070e0", "#a060d0", "#808080", "#606060"] for i in range(len(u_pts) - 1): fig.add_trace(go.Scatter( x=u_pts[i:i+2], y=v_pts[i:i+2], mode="lines", line=dict(color=seg_colors[min(i, len(seg_colors)-1)], width=2.5), hoverinfo="skip", showlegend=False, )) radius_px = 9 shapes = [] for u, v in zip(u_pts, v_pts): shapes.append(dict( type="circle", xref="x", yref="y", xsizemode="pixel", ysizemode="pixel", xanchor=float(u), yanchor=float(v), x0=-radius_px, x1=radius_px, y0=-radius_px, y1=radius_px, fillcolor="#ffd685", line=dict(color="white", width=1.5), editable=True, layer="above", )) for u, v, lkm in zip(u_pts, v_pts, HODO_LEVELS_KM): fig.add_annotation(x=float(u), y=float(v), text=f"{lkm}", font=dict(size=9, color="#dfe3ea"), showarrow=False, xshift=12, yshift=6) if storm_u is not None: fig.add_trace(go.Scatter(x=[storm_u], y=[storm_v], mode="markers+text", marker=dict(symbol="star", size=14, color="#ff8c00"), text=["RM"], textposition="top right", textfont=dict(size=9, color="#ff8c00"), hoverinfo="skip", showlegend=False)) if lm_u is not None: fig.add_trace(go.Scatter(x=[lm_u], y=[lm_v], mode="markers+text", marker=dict(symbol="star", size=14, color="#aaaaff"), text=["LM"], textposition="top right", textfont=dict(size=9, color="#aaaaff"), hoverinfo="skip", showlegend=False)) if mean_u is not None: fig.add_trace(go.Scatter(x=[mean_u], y=[mean_v], mode="markers", marker=dict(symbol="x", size=11, color="#80ff80"), hoverinfo="skip", showlegend=False)) all_u = list(u_pts) + ([storm_u] if storm_u else []) + ([lm_u] if lm_u else []) all_v = list(v_pts) + ([storm_v] if storm_v else []) + ([lm_v] if lm_v else []) pad = 5 fig.update_layout( title=dict(text="Hodograph (drag to edit)", font=dict(size=12)), xaxis_title="U (m s⁻¹)", yaxis_title="V (m s⁻¹)", template="plotly_dark", margin=dict(l=50, r=10, t=40, b=40), height=300, dragmode=False, xaxis=dict(range=[min(all_u)-pad, max(all_u)+pad], zeroline=True, zerolinecolor="#555", fixedrange=True), yaxis=dict(range=[min(all_v)-pad, max(all_v)+pad], zeroline=True, zerolinecolor="#555", fixedrange=True, scaleanchor="x", scaleratio=1), shapes=shapes, ) return fig def _field_heatmap(arr2d, x_axis, y_axis, x_label, y_label, title, field): colorscale, symmetric, label, unit = FIELD_META.get(field, ("Viridis", False, field, "")) zmin, zmax = clim(arr2d, symmetric) fig = go.Figure(data=go.Heatmap( x=x_axis, y=y_axis, z=arr2d.T, colorscale=colorscale, zmin=zmin, zmax=zmax, colorbar=dict(title=unit, len=0.8), zsmooth="best", hovertemplate=f"%{{z:.3g}} {unit}", )) fig.update_layout( title=dict(text=title, font=dict(size=13)), xaxis_title=x_label, yaxis_title=y_label, template="plotly_dark", margin=dict(l=55, r=20, t=45, b=45), height=420, ) return fig def _profile_fig(y_vals, z_km, title, color, xunit): fig = go.Figure() fig.add_trace(go.Scatter( x=y_vals, y=z_km, mode="lines", line=dict(color=color, width=2), hovertemplate=f"%{{x:.2g}} {xunit}
%{{y:.1f}} km", showlegend=False, )) fig.add_vline(x=0, line=dict(color="#555", width=1)) fig.update_layout( title=dict(text=title, font=dict(size=11)), xaxis_title=xunit, yaxis_title="z (km)", template="plotly_dark", margin=dict(l=50, r=10, t=35, b=35), height=220, ) return fig def _diag_row(label, value, unit=""): return html.Tr([ html.Td(label, style={"color": "#c7ced6", "fontSize": "13px", "paddingRight": "12px"}), html.Td(f"{value:.1f} {unit}", style={"color": "#ffd685", "fontWeight": "700", "fontSize": "13px", "fontVariantNumeric": "tabular-nums"}), ]) # --------------------------------------------------------------------------- # Static page content # --------------------------------------------------------------------------- def _getting_started_content(): def _step(n, title, body): return html.Div([ html.Div(f"Step {n} — {title}", className="gs-step-title"), html.Div(body, className="gs-step-body"), ], className="gs-step") def _row(label, desc): return html.Tr([ html.Td(label, className="gs-ctrl-label"), html.Td(desc, className="gs-ctrl-desc"), ]) return html.Div(className="uf-page-content", children=[ html.H2("Getting Started", className="page-h2"), html.P( "UpdraftForcing is a diagnostic kinematic model for convective storms. " "It prescribes an idealized updraft in a sheared environment and instantly " "diagnoses the resulting pressure perturbation field decomposed into physical " "forcing mechanisms. No time-stepping — every slider move reruns the full " "3-D Poisson solve (~1.5 s).", className="gs-intro", ), _step(1, "Set up the sounding", html.Table(className="gs-table", children=[ html.Tbody([ _row("θ_ml (K)", "Mixed-layer potential temperature. The surface parcel is lifted from this θ. " "Higher values give a warmer boundary layer and more buoyancy."), _row("qv_ml (g/kg)", "Boundary-layer water vapor mixing ratio. More moisture raises the dew point, " "lowering the LCL and increasing CAPE."), _row("BL depth (m)", "Depth of the well-mixed layer. The constant-θ / qv layer extends to this height."), _row("Tropopause ht. (m)", "Height of the tropopause. Controls the depth of the unstable layer. " "A higher tropopause allows a deeper updraft and more CAPE."), _row("Tropopause T (K)", "Temperature at the tropopause. Colder tropopause → larger CAPE."), _row("Lapse rate exp.", "Power-law exponent γ shaping the free-troposphere θ profile. " "γ = 1 is linear; γ < 1 concentrates instability near the surface; γ > 1 near the tropopause."), _row("Presets", "WK Supercell loads the Weisman-Klemp (1982) default environment. " "Weak shear loads a straight unidirectional hodograph. Reset returns all controls to defaults."), ]), ])), _step(2, "Draw the hodograph (Hodograph tab)", [ html.P("The hodograph shows the environmental wind vector at 9 levels: " "surface, 1, 2, 3, 4, 5, 6, 8, and 10 km. Each gold circle is draggable. " "Drag left/right to change U; drag up/down to change V."), html.P("The app automatically computes:"), html.Table(className="gs-table", children=[html.Tbody([ _row("RM / LM (stars)", "Bunkers et al. (2000) right- and left-mover storm motions " "— mean 0–6 km wind ± 7.5 m/s perpendicular to the 0–6 km shear vector."), _row("Mean wind (×)", "Simple 0–6 km mass-weighted mean wind."), _row("SRH 0–2 / 2–5 km", "Storm-relative helicity relative to the right-mover, " "shown in the diagnostics table."), ])]), ]), _step(3, "Configure the updraft (Updraft tab)", html.Table(className="gs-table", children=[ html.Tbody([ _row("ΔT surface (K)", "Temperature excess of the surface parcel above the environment. " "Drives the diagnosed w(z) profile via w²(z) = max(0, 2·∫B dz). " "Larger ΔT → more kinetic energy to overcome CIN → stronger updraft."), _row("Radius (m)", "Radial extent of the updraft core. " "The shape function tapers w and ζ to zero at this distance from center."), _row("ζ(z) profile", "Prescribed vertical vorticity representing accumulated storm rotation. " "Drag the gold circles rightward for cyclonic (positive) rotation, " "leftward for anticyclonic. The vorticity drives the spin pressure term " "and updraft helicity. Default is zero (no rotation)."), _row("Core shape — Top-hat", "Uniform w and ζ inside r₀·0.9 with a cosine taper in the outer 10%. " "Good for representing a solid rotating mesocyclone."), _row("Core shape — Cosine", "w(r) = cos(π·r / 2r₀). Smooth bell with no flat core. " "More realistic radial gradient of w."), ]), ])), _step(4, "Explore the output fields", [ html.P("Use the field dropdown and view tabs (Plan view / X cross-sec / Y cross-sec) " "in the center panel. The slice slider moves through the atmosphere."), html.Table(className="gs-table", children=[html.Tbody([ _row("w", "Vertical velocity (m/s). Blue = updraft, red = downdraft."), _row("u, v", "Total wind components including environmental flow and core rotation."), _row("Vertical ζ_z", "Prescribed vorticity within the core (s⁻¹)."), _row("p' total", "Sum of all four pressure perturbation components (Pa)."), _row("p' linear", "Shear-interaction term. Produces high p' on the upshear side, " "low p' downshear — causes updraft propagation toward high pressure."), _row("p' spin", "Nonlinear rotation term. Produces low p' inside the rotating core " "(dynamic pipe effect) — upward acceleration within the mesocyclone."), _row("p' splat", "Nonlinear deformation term. Produces high p' where air is being " "strained (e.g., at the updraft base)."), _row("p' buoyancy", "Buoyancy-induced term. Low p' above warm anomalies " "adds to the upward acceleration."), _row("Accel. terms", "Vertical acceleration −(1/ρ₀)·∂p'/∂z from each component, " "plotted as profiles in the right panel."), ])]), ]), _step(5, "Read the diagnostics panel", [ html.P("The right panel updates after every model run:"), html.Table(className="gs-table", children=[html.Tbody([ _row("CAPE / CIN", "Convective available potential energy and convective inhibition (J/kg)."), _row("LCL / LFC / EL", "Lifting condensation level, level of free convection, " "and equilibrium level (km)."), _row("Overshoot top", "Height where w → 0 above the EL. Diagnosed from the parcel model."), _row("SRH 0–2 / 2–5 km", "Storm-relative helicity layers (m²/s²). " "Values > 150 are supportive of supercell tornadoes."), _row("UH 0–2 / 2–5 km", "Updraft helicity (m²/s²). Nonzero only when ζ is prescribed."), _row("0–6 km shear", "Bulk wind shear magnitude (m/s). > 15 m/s favors supercells."), _row("w_max", "Peak vertical velocity at the updraft core center (m/s)."), ])]), ]), ]) def _theory_content(): def _eq(latex): return dcc.Markdown(f"$$\n{latex}\n$$", mathjax=True, className="theory-eq-md") def _h3(text): return html.H3(text, className="theory-h3") def _p(text): return dcc.Markdown(text, mathjax=True, className="theory-p-md") def _ref(authors, year, title, journal): return html.Li([ html.Span(f"{authors} ({year}). ", style={"color": "#dfe3ea"}), html.Em(title, style={"color": "#c7ced6"}), html.Span(f". {journal}", style={"color": "#8d97a2"}), ], className="theory-ref") def _assume(text, note=""): children = [html.Td("✓", className="assume-check"), html.Td(text, className="assume-text")] if note: children.append(html.Td(note, className="assume-note")) else: children.append(html.Td("", className="assume-note")) return html.Tr(children) return html.Div(className="uf-page-content", children=[ html.H2("Theory", className="page-h2"), # ---- Model assumptions ---- _h3("Model Assumptions"), _p( "UpdraftForcing is a **kinematic diagnostic model** — it does not time-step. " "The table below lists the key assumptions required for reproducibility." ), html.Table(className="assume-table", children=[html.Tbody([ _assume("Anelastic approximation", "Acoustic modes filtered; ∇·(ρ₀**u**) = 0. " "Base-state density ρ₀(z) from the WK sounding."), _assume("Horizontally homogeneous environment", "U(z), V(z) vary only with height; no mesoscale gradients."), _assume("Kinematically prescribed updraft", "w(x,y,z) is imposed; there is no momentum equation or " "feedback from the diagnosed pressure on the flow."), _assume("1-D pseudoadiabatic parcel model", "Condensate is removed immediately (no liquid-water loading). " "Virtual temperature correction applied throughout."), _assume("Solid-body rotation within core radius r₀", "v_θ(r) = ζ(z)·r/2 for r ≤ r₀; w and ζ taper to zero at r₀."), _assume("Prescribed accumulated vorticity ζ(z)", "Represents the rotation a mature storm has built up via tilting " "and stretching; not diagnosed from the tendency equation."), _assume("Poisson BCs: Neumann top and bottom", "∂p'/∂z = 0 at z = 0 m and z = 16 000 m. " "Periodic in x and y (implicit via FFT)."), _assume("Grid: 100 × 100 × 161 points, Δx = Δy = Δz = 100 m", "Domain 10 km × 10 km × 16 km AGL. " "Updraft centered at (5 km, 5 km)."), _assume("Single updraft cell; no downdraft, anvil, or cold pool", ""), _assume("No surface fluxes, radiation, or Coriolis force", ""), ])]), # ---- Sounding ---- _h3("Weisman-Klemp Analytic Sounding"), _p( "The environmental profile follows Weisman and Klemp (1982). " r"Potential temperature $\theta$ is prescribed in three layers:" ), _eq( r"\theta(z) = \begin{cases}" r" \theta_{ml} & z \leq z_{ml} \\" r" \theta_{ml} + (\theta_{trop} - \theta_{ml})\!\left(\dfrac{z - z_{ml}}{z_{trop} - z_{ml}}\right)^{\!\gamma} & z_{ml} < z \leq z_{trop} \\" r" \theta_{trop}\,\exp\!\left(\dfrac{N^2\,(z - z_{trop})}{g}\right) & z > z_{trop}" r"\end{cases}" ), _p( r"Pressure is integrated hydrostatically from the surface. " r"Free-troposphere moisture is set to 45% RH; above the tropopause " r"$N^2 = 4 \times 10^{-4}\ \mathrm{s}^{-2}$. " r"Boundary-layer $q_v$ is constant at $q_{v,ml}$." ), # ---- Parcel model ---- _h3("1-D Parcel Model and Diagnosed w(z)"), _p( r"A surface parcel with temperature excess $\Delta T$ is lifted " r"dry-adiabatically below the LCL and moist-adiabatically above. " r"Virtual temperature correction is applied throughout. Buoyancy:" ), _eq( r"B(z) = g \cdot \frac{T_{v,\mathrm{parcel}}(z) - T_{v,\mathrm{env}}(z)}{T_{v,\mathrm{env}}(z)}" ), _p(r"Vertical velocity is diagnosed by integrating $B$ upward from the surface:"), _eq( r"w^2(z) = \max\!\left(0,\; 2\int_0^z B(z')\,dz'\right)" ), _p( r"Above the equilibrium level the parcel is negatively buoyant; $w$ continues to " r"decelerate until $w \to 0$ at the overshooting top $z_{top}$." ), # ---- Pressure decomposition ---- _h3("Pressure Perturbation Decomposition (Trapp 2013)"), _p(r"For an anelastic atmosphere the pressure perturbation satisfies:"), _eq( r"\nabla^2 p' = F_{\mathrm{lin}} + F_{\mathrm{spin}} + F_{\mathrm{splat}} + F_{\mathrm{buoy}}" ), _p("Each component is solved independently so their spatial structures can be compared directly."), html.Div(className="theory-grid", children=[ html.Div([ html.Div("Linear (shear interaction)", className="theory-term-title"), _eq( r"F_{\mathrm{lin}} = -2\rho_0 \left[" r"\frac{\partial U}{\partial z}\frac{\partial w'}{\partial x}" r"+ \frac{\partial V}{\partial z}\frac{\partial w'}{\partial y}\right]" ), _p( r"Interaction of the environmental shear with horizontal gradients of the " r"updraft. Produces high $p'$ on the upshear flank and low $p'$ downshear, " r"deflecting the updraft toward high pressure." ), ], className="theory-term"), html.Div([ html.Div("Nonlinear spin", className="theory-term-title"), _eq( r"F_{\mathrm{spin}} = +\rho_0 \sum_{i,j} R_{ij}^2" r" = +\frac{\rho_0}{2}\,|\boldsymbol{\omega}'|^2" ), _eq( r"R_{ij} = \tfrac{1}{2}\!\left(" r"\frac{\partial u_i'}{\partial x_j}" r"- \frac{\partial u_j'}{\partial x_i}\right)" ), _eq( r"|\boldsymbol{\omega}'|^2 = \zeta_x^2 + \zeta_y^2 + \zeta_z^2" ), _p( r"Rotation-rate tensor squared, equal to $\tfrac{1}{2}|\boldsymbol{\omega}'|^2$. " r"Positive definite, so $F_{\mathrm{spin}} > 0$ everywhere. " r"Positive forcing in $\nabla^2 p' = F$ yields **low** $p'$ " r"— the dynamic pipe effect. Drives upward acceleration inside " r"a rotating mesocyclone." ), ], className="theory-term"), html.Div([ html.Div("Nonlinear splat", className="theory-term-title"), _eq( r"\begin{aligned}" r"F_{\mathrm{splat}} &= -\rho_0 \sum_{i,j} S_{ij}^2 \\" r"S_{ij} &= \tfrac{1}{2}\!\left(\frac{\partial u_i'}{\partial x_j}" r"+ \frac{\partial u_j'}{\partial x_i}\right)" r"\end{aligned}" ), _p( r"Strain-rate tensor squared. Produces high $p'$ wherever the flow " r"is being deformed — typically at the updraft base and flanks." ), ], className="theory-term"), html.Div([ html.Div("Buoyancy", className="theory-term-title"), _eq( r"F_{\mathrm{buoy}} = -\rho_0 \frac{g}{\theta_0}\frac{\partial \theta'}{\partial z}" ), _p( r"Vertical gradient of the potential temperature perturbation. " r"Produces low $p'$ above warm anomalies, reinforcing buoyant acceleration." ), ], className="theory-term"), ]), # ---- Poisson solver ---- _h3("Poisson Solver — 2-D FFT + Tridiagonal"), _p( r"Each forcing component $F(x,y,z)$ is transformed via 2-D real FFT in $x,y$. " r"For each horizontal wavenumber pair $(k_x,\,k_y)$ the following vertical ODE is solved:" ), _eq( 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)" ), _p( r"Neumann boundary conditions $\partial p'/\partial z = 0$ are applied at " r"$z = 0$ and $z = z_{top}$. The tridiagonal systems for all $(k_x,k_y)$ pairs " r"are solved simultaneously with a vectorized Thomas algorithm " r"(~0.08 s per forcing component). An inverse 2-D real FFT recovers $p'(x,y,z)$." ), # ---- Diagnostics ---- _h3("Storm Diagnostics"), _p(r"Storm motion from Bunkers et al. (2000):"), _eq( r"\mathbf{c}_{\mathrm{RM}} = \bar{\mathbf{u}}_{0\text{–}6\,\mathrm{km}} + \mathbf{D}_\perp," r"\qquad |\mathbf{D}_\perp| = 7.5\ \mathrm{m\,s^{-1}}\ \perp\ \text{0–6 km shear}" ), _p(r"Storm-relative helicity:"), _eq( r"\mathrm{SRH} = \sum_{n} \bigl[(u_{n+1} - c_u)(v_n - c_v)" r"- (u_n - c_u)(v_{n+1} - c_v)\bigr]" ), _p(r"Updraft helicity (nonzero only when $\zeta$ is prescribed):"), _eq( r"\mathrm{UH} = \int_{z_{\mathrm{bot}}}^{z_{\mathrm{top}}}" r"w(0,0,z)\;\zeta_z(0,0,z)\;dz" ), # ---- References ---- html.H3("References", className="theory-h3", style={"marginTop": "30px"}), html.Ol(className="theory-refs", children=[ _ref("Trapp, R. J.", 2013, "Mesoscale-Convective Processes in the Atmosphere", "Cambridge University Press"), _ref("Weisman, M. L. and Klemp, J. B.", 1982, "The dependence of numerically simulated convective storms on vertical wind " "shear and buoyancy", "Mon. Wea. Rev., 110, 504–520"), _ref("Bunkers, M. J., Klimowski, B. A., Zeitler, J. W., Thompson, R. L., " "and Hjelmfelt, M. R.", 2000, "Predicting supercell motion using a new hodograph technique", "Wea. Forecasting, 15, 61–79"), _ref("Bolton, D.", 1980, "The computation of equivalent potential temperature", "Mon. Wea. Rev., 108, 1046–1053"), ]), ]) # --------------------------------------------------------------------------- # Skew-T helpers # --------------------------------------------------------------------------- _SKEW = 45.0 # °C shift per log10-pressure decade _BARB_X = 60.0 # x anchor for wind barb tips (skewed °C) _BARB_ASPECT = 120.0 # x-units per 1 log10-p unit (matches ~550px / ~4.5px/unit plot) _BARB_STAFF = 5.0 _BARB_FULL = 2.8 _BARB_HALF = 1.4 _BARB_SEP = 0.91 def _sx(T_C, p_hPa): """Skew-T x coordinate.""" return np.asarray(T_C, float) + _SKEW * np.log10(1000.0 / np.asarray(p_hPa, float)) def _wind_barbs_trace(env_u, env_v, p_snd): """ Return (xs, ys) lists (with None separators) for a single wind-barb go.Scatter trace. NH convention: staff points upwind; flags on the right when looking from tip toward tail (CW 90° from the upwind direction). """ barb_lvls = [1000, 925, 850, 700, 600, 500, 400, 300, 250, 200, 150, 100] # np.interp requires xp increasing; p_snd decreases, so reverse p_rev = p_snd[::-1] u_rev = env_u[::-1] v_rev = env_v[::-1] xs: list = [] ys: list = [] def screen(ex, en, length): """(east, north) direction + length → (Δx_skewed, Δlog10p).""" return ex * length, -en * length / _BARB_ASPECT for p_tgt in barb_lvls: if p_tgt > p_rev[-1] or p_tgt < p_rev[0]: continue u = float(np.interp(p_tgt, p_rev, u_rev)) v = float(np.interp(p_tgt, p_rev, v_rev)) spd = float(np.hypot(u, v)) spd_kt = spd * 1.9438 log_p0 = np.log10(float(p_tgt)) if spd < 0.5: # calm: tiny tick xs.extend([_BARB_X - 0.4, _BARB_X + 0.4, None]) ys.extend([p_tgt, p_tgt, None]) continue ux = -u / spd; uy = -v / spd # upwind unit vector (E, N components) pex = uy; pen = -ux # perpendicular = CW 90° of upwind (NH convention) # Staff (tip → tail) dx_s, dl_s = screen(ux, uy, _BARB_STAFF) xs.extend([_BARB_X, _BARB_X + dx_s, None]) ys.extend([float(p_tgt), float(10 ** (log_p0 + dl_s)), None]) n50 = int(spd_kt // 50) rem = spd_kt - n50 * 50 n10 = int(rem // 10); rem -= n10 * 10 n5 = 1 if rem >= 5 else 0 pos = _BARB_STAFF # current position along staff from tip for _ in range(n50): # pennants dx_b, dl_b = screen(ux, uy, pos) b1x, b1l = _BARB_X + dx_b, log_p0 + dl_b dx_e, dl_e = screen(pex, pen, _BARB_FULL) b2x, b2l = b1x + dx_e, b1l + dl_e dx_b3, dl_b3 = screen(ux, uy, pos - 2 * _BARB_SEP) b3x, b3l = _BARB_X + dx_b3, log_p0 + dl_b3 xs.extend([b1x, b2x, b3x, b1x, None]) ys.extend([10**b1l, 10**b2l, 10**b3l, 10**b1l, None]) pos -= 2 * _BARB_SEP for _ in range(n10): # full barbs dx_b, dl_b = screen(ux, uy, pos) b1x, b1l = _BARB_X + dx_b, log_p0 + dl_b dx_e, dl_e = screen(pex, pen, _BARB_FULL) xs.extend([b1x, b1x + dx_e, None]) ys.extend([10**b1l, 10**(b1l + dl_e), None]) pos -= _BARB_SEP if n5: # half barb dx_b, dl_b = screen(ux, uy, pos) b1x, b1l = _BARB_X + dx_b, log_p0 + dl_b dx_e, dl_e = screen(pex, pen, _BARB_HALF) xs.extend([b1x, b1x + dx_e, None]) ys.extend([10**b1l, 10**(b1l + dl_e), None]) return xs, ys def _skewt_figure() -> go.Figure: """Build a Skew-T log-P diagram from the current _C cache.""" snd = _C.get("snd") if snd is None: return go.Figure() Rd = 287.04; Rv = 461.5; Lv = 2.501e6; Cp = 1005.7 eps = Rd / Rv; KAPPA = Rd / Cp # Background pressure grid (surface → top, decreasing) p_bg = np.concatenate([np.arange(1050, 500, -5.0), np.arange(500, 95, -2.5)]) fig = go.Figure() # ---- isotherms ---- for T_iso in range(-100, 61, 10): fig.add_trace(go.Scatter( x=_sx(T_iso, p_bg), y=p_bg, mode="lines", hoverinfo="skip", showlegend=False, line=dict(color="rgba(90,120,150,0.55)" if T_iso == 0 else "rgba(65,90,115,0.3)", width=0.9 if T_iso == 0 else 0.55))) # ---- dry adiabats ---- for theta_K in [255, 265, 275, 285, 295, 305, 315, 325, 340, 360, 390]: T_C = theta_K * (p_bg / 1000.0) ** KAPPA - 273.15 fig.add_trace(go.Scatter( x=_sx(T_C, p_bg), y=p_bg, mode="lines", hoverinfo="skip", showlegend=False, line=dict(color="rgba(210,155,55,0.38)", width=0.6, dash="dash"))) # ---- moist adiabats ---- def _ma(T0_C, p_arr): T = T0_C + 273.15 out = [] for i, p in enumerate(p_arr): out.append(T - 273.15) if i < len(p_arr) - 1: dp = p_arr[i + 1] - p T_c = T - 273.15 es = 6.112 * np.exp(17.67 * T_c / (T_c + 243.5)) rs = eps * es / max(p - es, 0.1) # dT/dp = (T/p) * (Rd + Lv*rs/T) / (Cp + Lv²*rs/(Rv*T²)) T = max(T + (T / p) * (Rd + Lv * rs / T) / (Cp + Lv**2 * rs / (Rv * T**2)) * dp, 100.0) return np.array(out) for T0 in [-25, -15, -5, 5, 15, 25, 35]: T_ma = _ma(T0, p_bg) mask = T_ma > -85 if mask.sum() >= 3: fig.add_trace(go.Scatter( x=_sx(T_ma[mask], p_bg[mask]), y=p_bg[mask], mode="lines", hoverinfo="skip", showlegend=False, line=dict(color="rgba(70,185,115,0.40)", width=0.6, dash="dash"))) # ---- mixing-ratio lines (below 550 hPa) ---- p_low = p_bg[p_bg >= 550] for w_gkg in [2, 4, 7, 10, 16]: w = w_gkg / 1000.0 e = p_low * w / (eps + w) ln_e = np.log(np.maximum(e, 1e-6) / 6.112) T_C = 243.5 * ln_e / (17.67 - ln_e) mask = (T_C > -40) & (T_C < 40) if mask.sum() >= 2: fig.add_trace(go.Scatter( x=_sx(T_C[mask], p_low[mask]), y=p_low[mask], mode="lines", hoverinfo="skip", showlegend=False, line=dict(color="rgba(65,150,215,0.40)", width=0.5, dash="dot"))) # ---- sounding profiles ---- p_snd = snd["p_hPa"] T_env_C = snd["T_K"] - 273.15 Td_env_C= snd["Td_K"] - 273.15 mask_snd = (p_snd >= 98) & (p_snd <= 1060) p_s = p_snd[mask_snd] T_s = T_env_C[mask_snd] Td_s = Td_env_C[mask_snd] T_pcl_K = _C.get("T_parcel") T_pcl_C = (T_pcl_K[mask_snd] - 273.15) if T_pcl_K is not None else None diag = _C.get("diag", {}) def _z2p(z_m): return float(np.interp(float(z_m or 0), Z_GRID, p_snd)) p_LCL = _z2p(diag.get("LCL_m", 0)) p_LFC = _z2p(diag.get("LFC_m", 0)) p_EL = _z2p(_C.get("EL_m", 0)) p_top = _z2p(_C.get("z_top_m", 0)) # CAPE shading if T_pcl_C is not None: cm = (p_s <= p_LFC + 5) & (p_s >= p_EL - 5) & (T_pcl_C > T_s) if cm.sum() >= 2: xenv, xpcl = _sx(T_s[cm], p_s[cm]), _sx(T_pcl_C[cm], p_s[cm]) fig.add_trace(go.Scatter( x=list(xenv) + list(xpcl[::-1]), y=list(p_s[cm]) + list(p_s[cm][::-1]), fill="toself", fillcolor="rgba(50,200,70,0.22)", line=dict(width=0), showlegend=False, hoverinfo="skip")) # CIN shading cm2 = (p_s >= p_LFC - 5) & (T_pcl_C < T_s) if cm2.sum() >= 2: xenv, xpcl = _sx(T_s[cm2], p_s[cm2]), _sx(T_pcl_C[cm2], p_s[cm2]) fig.add_trace(go.Scatter( x=list(xenv) + list(xpcl[::-1]), y=list(p_s[cm2]) + list(p_s[cm2][::-1]), fill="toself", fillcolor="rgba(180,180,180,0.18)", line=dict(width=0), showlegend=False, hoverinfo="skip")) fig.add_trace(go.Scatter( x=_sx(T_s, p_s), y=p_s, mode="lines", name="Temp", line=dict(color="#e06c6c", width=2.5), hovertemplate="%{text}°C @ %{y:.0f} hPaT", text=[f"{t:.1f}" for t in T_s])) fig.add_trace(go.Scatter( x=_sx(Td_s, p_s), y=p_s, mode="lines", name="Dewpt", line=dict(color="#5ac85a", width=2.5), hovertemplate="%{text}°C @ %{y:.0f} hPaTd", text=[f"{t:.1f}" for t in Td_s])) if T_pcl_C is not None: fig.add_trace(go.Scatter( x=_sx(T_pcl_C, p_s), y=p_s, mode="lines", name="Parcel", line=dict(color="#ffd685", width=1.8, dash="dash"), hovertemplate="%{text}°C @ %{y:.0f} hPaParcel", text=[f"{t:.1f}" for t in T_pcl_C])) # Level lines — manually convert pressure → paper-y so log-reversed axis never misplaces labels _ylo_log = np.log10(1025.0) # matches range[0] in update_layout below _yhi_log = np.log10(98.0) # matches range[1] for p_lvl, label, color in [ (p_LCL, "LCL", "#9abfff"), (p_LFC, "LFC", "#ffaa44"), (p_EL, "EL", "#ffd685"), (p_top, "Overshoot top", "#ff8c00"), ]: if 98 < p_lvl < 1060: fig.add_hline(y=p_lvl, line=dict(color=color, width=0.9, dash="dot")) y_paper = (np.log10(float(p_lvl)) - _ylo_log) / (_yhi_log - _ylo_log) fig.add_annotation( x=0.99, xref="paper", y=float(np.clip(y_paper, 0.01, 0.99)), yref="paper", text=f" {label} ", showarrow=False, xanchor="right", yanchor="bottom", font=dict(color=color, size=10), bgcolor="rgba(10,15,26,0.7)", ) # Wind barbs env_u = _C.get("env_u"); env_v = _C.get("env_v") if env_u is not None and env_v is not None: bx, by = _wind_barbs_trace(env_u, env_v, p_snd) if bx: fig.add_trace(go.Scatter(x=bx, y=by, mode="lines", line=dict(color="#ccd3db", width=1.2), showlegend=False, hoverinfo="skip")) # Vertical guide line for barb column fig.add_vline(x=_BARB_X - _BARB_STAFF - 0.5, line=dict(color="rgba(80,100,120,0.3)", width=0.6)) p_ticks = [1000, 925, 850, 700, 600, 500, 400, 300, 250, 200, 150, 100] x_lo = min(float(_sx(T_s.min() - 5, 1050)), -55.0) x_hi = max(float(_sx(T_s.max() + 2, min(p_s))), _BARB_X + _BARB_FULL + 2) fig.update_layout( template="plotly_dark", height=620, margin=dict(l=60, r=15, t=18, b=40), paper_bgcolor="#11161f", plot_bgcolor="#0a0f1a", xaxis=dict( title="Temperature (°C)", range=[x_lo, x_hi], tickmode="array", tickvals=list(range(-80, 61, 10)), ticktext=[str(t) for t in range(-80, 61, 10)], showgrid=False, zeroline=False, ), yaxis=dict( title="Pressure (hPa)", type="log", range=[np.log10(1025), np.log10(98)], tickmode="array", tickvals=p_ticks, ticktext=[str(p) for p in p_ticks], showgrid=False, ), legend=dict(x=0.01, y=0.01, font=dict(size=11), bgcolor="rgba(10,15,26,0.8)", bordercolor="#2d3a4b", borderwidth=1), ) for p_ref in [1000, 850, 700, 500, 300, 200]: fig.add_hline(y=p_ref, line=dict(color="rgba(90,120,150,0.22)", width=0.6), annotation_text=f"{p_ref}", annotation_position="left", annotation_font=dict(size=9, color="rgba(130,155,185,0.7)")) return fig # --------------------------------------------------------------------------- # Layout # --------------------------------------------------------------------------- def _build_layout(): diag = _run_model(SND_DEFAULTS, UPD_DEFAULTS, WK_U, WK_V, ZETA_DEFAULTS) # ========================================================= # INPUTS TAB — three columns: Sounding | Hodograph | Updraft # ========================================================= snd_col = html.Div(className="inp-col", children=[ html.Div("Weisman-Klemp Sounding", className="uf-section-title"), _slider("snd-theta-ml", "θ_ml (K)", 295, 315, 0.5, SND_DEFAULTS["theta_ml"], tip="Mixed-layer potential temperature. Higher values give a warmer " "boundary layer and more buoyancy."), _slider("snd-qv-ml", "qv_ml (g/kg)", 8, 20, 0.5, SND_DEFAULTS["qv_ml"], tip="Boundary-layer water vapor mixing ratio. More moisture lowers " "the LCL and increases CAPE."), _slider("snd-z-ml", "BL depth (m)", 500, 2000, 100, SND_DEFAULTS["z_ml"], " m", tip="Depth of the well-mixed layer. The constant-θ and qv profile " "extends to this height."), _slider("snd-z-trop", "Tropopause ht. (m)", 9000, 14000, 250, SND_DEFAULTS["z_trop"], " m", tip="Height of the tropopause. A higher tropopause allows a deeper " "updraft and more CAPE."), _slider("snd-T-trop", "Tropopause T (K)", 195, 220, 1, SND_DEFAULTS["T_trop"], " K", tip="Temperature at the tropopause. Colder tropopause → larger " "temperature difference from the surface → more CAPE."), _slider("snd-gamma", "Lapse rate exp.", 0.8, 1.8, 0.05, SND_DEFAULTS["gamma_ft"], tip="Shape exponent γ for the free-troposphere θ profile: " "θ = θ_ml + (θ_trop−θ_ml)·((z−z_ml)/(z_trop−z_ml))^γ. " "γ=1 linear; γ<1 more unstable near surface; γ>1 near tropopause."), html.Div(className="uf-preset-row", children=[ html.Button("WK Supercell", id="preset-wk", className="uf-btn"), html.Button("Linear shear", id="preset-weak", className="uf-btn"), html.Button("Reset", id="preset-reset", className="uf-btn"), ]), ]) hodo_col = html.Div(className="inp-col", style={"gridColumn": "2", "gridRow": "1 / span 2"}, children=[ html.Div("Environmental Hodograph", className="uf-section-title"), dcc.Graph(id="hodograph", figure=_hodo_figure(WK_U, WK_V, storm_u=diag["storm_u"], storm_v=diag["storm_v"], lm_u=diag["lm_u"], lm_v=diag["lm_v"], mean_u=diag["mean_u"], mean_v=diag["mean_v"]), config={"edits": {"shapePosition": True}, "displayModeBar": False, "scrollZoom": False}), html.Div([ html.Span("Drag gold circles to edit wind at each level. " "RM = right-mover, LM = left-mover, × = mean wind.", style={"color": "#8f98a3", "fontSize": "11px"}), _help("Wind levels: 0, 1, 2, 3, 4, 5, 6, 8, 10 km. " "Storm motion from Bunkers et al. (2000): " "mean 0–6 km wind ± 7.5 m/s ⊥ to shear vector. " "SRH computed relative to the right-mover."), ], style={"display": "flex", "alignItems": "flex-start", "gap": "6px", "marginTop": "6px"}), html.Div([ html.Div("Prescribed Mesocyclone ζ(z)", className="uf-section-title", style={"marginTop": "14px"}), _help("Vertical vorticity profile representing rotation the storm has built " "up. Drag circles rightward for cyclonic (positive) rotation. " "Drives p'_spin and updraft helicity."), ], style={"display": "flex", "alignItems": "center", "gap": "6px"}), _profile_editor("zeta-profile", "ζ(z) (s⁻¹)", ZETA_DEFAULTS, ZETA_Z_KM, "s⁻¹", (-0.05, 0.05)), ]) upd_col = html.Div(className="inp-col", children=[ html.Div("Updraft Core", className="uf-section-title"), html.Div([ html.Span("ΔT surface (K)", className="uf-slider-label"), _help("Temperature excess of the surface parcel above the environment. " "Drives the diagnosed w(z): w²(z) = max(0, 2·∫B dz). " "Larger ΔT → more KE to overcome CIN → stronger updraft."), dcc.Input(id="upd-delta-T", type="number", value=UPD_DEFAULTS["delta_T"], step=0.1, min=0.1, max=10.0, style={"width": "80px", "marginLeft": "8px", "border": "1px solid #2d3a4b", "padding": "4px 8px", "borderRadius": "4px"}), ], style={"display": "flex", "alignItems": "center", "marginBottom": "10px"}), _slider("upd-r0", "Radius (m)", 500, 5000, 100, UPD_DEFAULTS["r0"], " m", tip="Updraft core radius. The radial shape function tapers w and ζ " "to zero at this distance from center."), html.Div([ html.Span("Core shape", className="uf-slider-label"), _help("Top-hat: uniform w inside 90% of r₀ with cosine taper in the outer 10%. " "Cosine bell: w(r) = cos(π·r/2r₀), smooth with no flat core."), ], style={"display": "flex", "alignItems": "center", "gap": "6px", "marginTop": "10px"}), dcc.RadioItems( id="upd-shape", options=[{"label": " Top-hat", "value": "tophat"}, {"label": " Cosine", "value": "cosine"}], value=UPD_DEFAULTS["shape"], labelStyle={"marginRight": "14px", "color": "#dfe3ea", "fontSize": "13px"}, style={"marginBottom": "10px"}, ), ]) inputs_tab = html.Div(className="inp-grid", children=[snd_col, hodo_col, upd_col]) # ========================================================= # OUTPUTS TAB — sub-tabs: Skew-T | Model Results # ========================================================= skewt_subtab = html.Div(className="skewt-layout", children=[ dcc.Graph(id="skewt-graph", figure=_skewt_figure(), config={"displayModeBar": False}), html.Div(className="skewt-side", children=[ html.Div("Sounding Parameters", className="uf-section-title"), html.Table(id="diag-table", style={"width": "100%", "borderCollapse": "collapse"}), html.Div("Diagnosed w(z)", className="uf-section-title", style={"marginTop": "14px"}), dcc.Graph(id="w-profile-graph", config={"displayModeBar": False}), ]), ]) results_subtab = html.Div(className="results-layout", children=[ html.Div(className="results-center", children=[ html.Div(className="uf-field-controls", children=[ dcc.Dropdown( id="field-select", options=[{"label": v, "value": k} for k, v in FIELD_LABELS.items()], value="w", clearable=False, style={"width": "240px", "fontSize": "13px", "color": "#111"}, ), dcc.Tabs(id="view-tabs", value="plan", className="uf-view-tabs", children=[ dcc.Tab(label="Plan view", value="plan", className="uf-vtab", selected_className="uf-vtab-sel"), dcc.Tab(label="X cross-sec", value="xcross", className="uf-vtab", selected_className="uf-vtab-sel"), dcc.Tab(label="Y cross-sec", value="ycross", className="uf-vtab", selected_className="uf-vtab-sel"), ]), ]), html.Div(className="uf-slice-row", children=[ html.Span("Slice: ", className="uf-slider-label"), dcc.Slider(id="slice-slider", min=0, max=NZ - 1, step=1, value=NZ // 4, marks=None, tooltip={"always_visible": False}, className="uf-slice-slider"), html.Span(id="slice-label", children="z = 4.0 km", style={"color": "#6ecbff", "fontSize": "12px", "minWidth": "80px"}), ]), dcc.Graph(id="main-heatmap", config={"displayModeBar": False}), ]), html.Div(className="results-side", children=[ html.Div("Buoyancy B(z)", className="uf-section-title"), dcc.Graph(id="buoy-profile", config={"displayModeBar": False}), html.Div("Vertical accelerations", className="uf-section-title", style={"marginTop": "10px"}), dcc.Graph(id="accel-profile", config={"displayModeBar": False}), ]), ]) outputs_tab = html.Div(children=[ dcc.Tabs(id="out-tabs", value="skewt", className="uf-tabs", children=[ dcc.Tab(label="Skew-T", value="skewt", className="uf-tab", selected_className="uf-tab-sel", children=skewt_subtab), dcc.Tab(label="Model Results", value="results", className="uf-tab", selected_className="uf-tab-sel", children=results_subtab), ]), ]) return html.Div(className="uf-root", children=[ html.Div(className="uf-header", children=[ html.Div(style={"display": "flex", "alignItems": "center", "gap": "14px"}, children=[ html.Span("🌩️", style={"fontSize": "32px", "lineHeight": "1"}), html.Div([ html.H1("Updraft Forcing: A parcel-grid model", style={"margin": "0 0 2px 0", "fontSize": "22px"}), html.Div("A kinematic-diagnostic model to examine the effects of environmental wind shear and mesocyclone intensity on perturbation pressure and vertical accelerations", style={"color": "#9aa3ad", "fontSize": "12px"}), ]), html.Div(style={"marginLeft": "auto"}, children=[ html.A(href="https://climas.illinois.edu/", target="_blank", children=html.Img( src="/assets/climas_logo.jpg", alt="UIUC CliMAS", style={"height": "44px", "borderRadius": "8px", "display": "block"})), ]), ]), ]), dcc.Tabs(id="page-tabs", value="inputs", className="uf-page-tabs", children=[ dcc.Tab(label="Inputs", value="inputs", className="uf-ptab", selected_className="uf-ptab-sel", children=inputs_tab), dcc.Tab(label="Outputs", value="outputs", className="uf-ptab", selected_className="uf-ptab-sel", children=outputs_tab), dcc.Tab(label="Getting Started", value="help", className="uf-ptab", selected_className="uf-ptab-sel", children=_getting_started_content()), dcc.Tab(label="Theory", value="theory", className="uf-ptab", selected_className="uf-ptab-sel", children=_theory_content()), ]), dcc.Store(id="hodo-store", data={"u": WK_U, "v": WK_V}), dcc.Store(id="zeta-store", data={"zeta": ZETA_DEFAULTS}), dcc.Store(id="model-rev", data=0), ]) # --------------------------------------------------------------------------- # Callbacks # --------------------------------------------------------------------------- def _register_callbacks(app): for sid, unit in [("snd-theta-ml", " K"), ("snd-qv-ml", " g/kg"), ("snd-z-ml", " m"), ("snd-z-trop", " m"), ("snd-T-trop", " K"), ("snd-gamma", ""), ("upd-r0", " m")]: @app.callback(Output(f"{sid}-val", "children"), Input(sid, "value"), prevent_initial_call=True) def _upd_label(v, _unit=unit): return f"{v}{_unit}" @app.callback( [Output("snd-theta-ml", "value"), Output("snd-qv-ml", "value"), Output("snd-z-ml", "value"), Output("snd-z-trop", "value"), Output("snd-T-trop", "value"), Output("snd-gamma", "value"), Output("upd-delta-T", "value"), Output("upd-r0", "value"), Output("upd-shape", "value"), Output("hodo-store", "data", allow_duplicate=True)], [Input("preset-wk", "n_clicks"), Input("preset-weak", "n_clicks"), Input("preset-reset", "n_clicks")], prevent_initial_call=True, ) def _preset(wk, weak, reset): if ctx.triggered_id == "preset-weak": u = [0, 5, 10, 15, 20, 22, 23, 24, 25] v = [0, 0, 0, 0, 0, 0, 0, 0, 0] return (300, 12, 1000, 11000, 215, 1.2, 2.0, 2500, "tophat", {"u": u, "v": v}) return (SND_DEFAULTS["theta_ml"], SND_DEFAULTS["qv_ml"], SND_DEFAULTS["z_ml"], SND_DEFAULTS["z_trop"], SND_DEFAULTS["T_trop"], SND_DEFAULTS["gamma_ft"], UPD_DEFAULTS["delta_T"], UPD_DEFAULTS["r0"], UPD_DEFAULTS["shape"], {"u": WK_U, "v": WK_V}) @app.callback( Output("hodo-store", "data"), Input("hodograph", "relayoutData"), State("hodo-store", "data"), prevent_initial_call=True, ) def _hodo_drag(relay, store): if not relay: return no_update u = list(store["u"]); v = list(store["v"]); changed = False for i in range(len(u)): # Plotly emits xanchor/yanchor for pixel-mode shapes; after conversion # it may emit x0/x1/y0/y1 in data coords — handle both. if f"shapes[{i}].xanchor" in relay: try: u[i] = max(-60.0, min(60.0, float(relay[f"shapes[{i}].xanchor"]))); changed = True except (TypeError, ValueError): pass elif f"shapes[{i}].x0" in relay and f"shapes[{i}].x1" in relay: try: u[i] = max(-60.0, min(60.0, (float(relay[f"shapes[{i}].x0"]) + float(relay[f"shapes[{i}].x1"])) / 2)); changed = True except (TypeError, ValueError): pass if f"shapes[{i}].yanchor" in relay: try: v[i] = max(-60.0, min(60.0, float(relay[f"shapes[{i}].yanchor"]))); changed = True except (TypeError, ValueError): pass elif f"shapes[{i}].y0" in relay and f"shapes[{i}].y1" in relay: try: v[i] = max(-60.0, min(60.0, (float(relay[f"shapes[{i}].y0"]) + float(relay[f"shapes[{i}].y1"])) / 2)); changed = True except (TypeError, ValueError): pass return {"u": u, "v": v} if changed else no_update @app.callback( Output("zeta-store", "data"), Input("zeta-profile", "relayoutData"), State("zeta-store", "data"), prevent_initial_call=True, ) def _zeta_drag(relay, store): if not relay: return no_update zeta = list(store["zeta"]); changed = False for i in range(len(zeta)): if f"shapes[{i}].xanchor" in relay: try: zeta[i] = max(-0.10, min(0.10, float(relay[f"shapes[{i}].xanchor"]))); changed = True except (TypeError, ValueError): pass elif f"shapes[{i}].x0" in relay and f"shapes[{i}].x1" in relay: try: zeta[i] = max(-0.10, min(0.10, (float(relay[f"shapes[{i}].x0"]) + float(relay[f"shapes[{i}].x1"])) / 2)); changed = True except (TypeError, ValueError): pass return {"zeta": zeta} if changed else no_update @app.callback( Output("hodograph", "figure"), [Input("hodo-store", "data"), Input("model-rev", "data")], ) def _redraw_hodo(store, _rev): d = _C.get("diag", {}) return _hodo_figure(store["u"], store["v"], storm_u=d.get("storm_u"), storm_v=d.get("storm_v"), lm_u=d.get("lm_u"), lm_v=d.get("lm_v"), mean_u=d.get("mean_u"), mean_v=d.get("mean_v")) @app.callback( Output("zeta-profile", "figure"), Input("zeta-store", "data"), ) def _redraw_zeta(store): return _profile_editor_fig("ζ(z) (s⁻¹)", store["zeta"], ZETA_Z_KM, "s⁻¹", (-0.05, 0.05)) @app.callback( Output("model-rev", "data"), [Input("snd-theta-ml", "value"), Input("snd-qv-ml", "value"), Input("snd-z-ml", "value"), Input("snd-z-trop", "value"), Input("snd-T-trop", "value"), Input("snd-gamma", "value"), Input("upd-delta-T", "value"), Input("upd-r0", "value"), Input("upd-shape", "value"), Input("hodo-store", "data"), Input("zeta-store", "data")], State("model-rev", "data"), prevent_initial_call=True, ) def _compute(theta_ml, qv_ml, z_ml, z_trop, T_trop, gamma, delta_T, r0, shape, hodo, zeta_data, rev): snd_p = dict( theta_ml=theta_ml or SND_DEFAULTS["theta_ml"], qv_ml=qv_ml or SND_DEFAULTS["qv_ml"], z_ml=z_ml or SND_DEFAULTS["z_ml"], z_trop=z_trop or SND_DEFAULTS["z_trop"], T_trop=T_trop or SND_DEFAULTS["T_trop"], gamma_ft=gamma or SND_DEFAULTS["gamma_ft"], ) upd_p = dict( delta_T=delta_T or UPD_DEFAULTS["delta_T"], r0=r0 or UPD_DEFAULTS["r0"], shape=shape or UPD_DEFAULTS["shape"], ) try: _run_model(snd_p, upd_p, hodo["u"], hodo["v"], zeta_data["zeta"]) except Exception as exc: import traceback; traceback.print_exc() print(f"[UpdraftForcing] compute error: {exc!r}", flush=True) return (rev or 0) + 1 @app.callback( Output("slice-label", "children"), [Input("slice-slider", "value"), Input("view-tabs", "value")], ) def _slice_label(idx, view): if view == "plan": z_km = Z_GRID[idx] / 1000.0 p_hPa = _C.get("snd", {}).get("p_hPa", np.ones(NZ) * 500.0) p = float(np.interp(Z_GRID[idx], Z_GRID, p_hPa)) return f"z = {z_km:.1f} km ({p:.0f} hPa)" elif view == "xcross": return f"y = {Y_KM[idx % NY]:.1f} km" else: return f"x = {X_KM[idx % NX]:.1f} km" @app.callback( [Output("slice-slider", "max"), Output("slice-slider", "value")], Input("view-tabs", "value"), ) def _slice_range(view): if view == "plan": return NZ - 1, NZ // 4 if view == "xcross": return NY - 1, NY // 2 return NX - 1, NX // 2 @app.callback( Output("main-heatmap", "figure"), [Input("field-select", "value"), Input("view-tabs", "value"), Input("slice-slider", "value"), Input("model-rev", "data")], ) def _display(field, view, idx, _rev): arr = _C.get(field) if arr is None: return go.Figure() if view == "plan": k = min(idx, NZ - 1) return _field_heatmap(arr[:, :, k], X_KM, Y_KM, "x (km)", "y (km)", f"{FIELD_LABELS.get(field, field)} — z = {Z_GRID[k]/1000:.1f} km", field) elif view == "xcross": j = min(idx, NY - 1) return _field_heatmap(arr[:, j, :], X_KM, Z_GRID/1000.0, "x (km)", "z (km)", f"{FIELD_LABELS.get(field, field)} — y = {Y_KM[j]:.1f} km", field) else: i = min(idx, NX - 1) return _field_heatmap(arr[i, :, :], Y_KM, Z_GRID/1000.0, "y (km)", "z (km)", f"{FIELD_LABELS.get(field, field)} — x = {X_KM[i]:.1f} km", field) @app.callback( Output("w-profile-graph", "figure"), Input("model-rev", "data"), ) def _w_profile_fig(_rev): w_z = _C.get("w_z", np.zeros(NZ)) EL = _C.get("EL_m", 12000.0) z_top = _C.get("z_top_m", 13000.0) z_km = Z_GRID / 1000.0 fig = go.Figure() fig.add_trace(go.Scatter(x=w_z, y=z_km, mode="lines", line=dict(color="#4ab8e0", width=2), hovertemplate="%{x:.1f} m/s @ %{y:.2f} km", showlegend=False)) fig.add_hline(y=EL/1000.0, line=dict(color="#ffd685", dash="dash", width=1.5), annotation_text="EL", annotation_font_color="#ffd685", annotation_position="top right") fig.add_hline(y=z_top/1000.0, line=dict(color="#ff8c00", dash="dash", width=1.5), annotation_text="Overshoot top", annotation_font_color="#ff8c00", annotation_position="top right") fig.update_layout(xaxis_title="w (m s⁻¹)", yaxis_title="z (km)", template="plotly_dark", margin=dict(l=50, r=10, t=10, b=35), height=200, xaxis=dict(rangemode="tozero")) return fig @app.callback( Output("diag-table", "children"), Input("model-rev", "data"), ) def _diag_table(_rev): d = _C.get("diag", {}) rows = [ _diag_row("CAPE", d.get("CAPE", 0), "J/kg"), _diag_row("CIN", d.get("CIN", 0), "J/kg"), _diag_row("LCL height", (d.get("LCL_m", 0) or 0) / 1000.0, "km"), _diag_row("LFC height", (d.get("LFC_m", 0) or 0) / 1000.0, "km"), _diag_row("EL height", (d.get("EL_m", 0) or 0) / 1000.0, "km"), _diag_row("Overshoot top", (d.get("z_top_m", 0) or 0) / 1000.0, "km"), _diag_row("SRH 0-2 km", d.get("SRH_02", 0), "m²/s²"), _diag_row("SRH 2-5 km", d.get("SRH_25", 0), "m²/s²"), _diag_row("UH 0-2 km", d.get("UH_02", 0), "m²/s²"), _diag_row("UH 2-5 km", d.get("UH_25", 0), "m²/s²"), _diag_row("0-6 km shear", d.get("BWS_06", 0), "m/s"), _diag_row("w_max (center)", d.get("w_max", 0), "m/s"), ] return html.Tbody(rows) @app.callback( Output("skewt-graph", "figure"), Input("model-rev", "data"), ) def _skewt_cb(_rev): return _skewt_figure() @app.callback( [Output("buoy-profile", "figure"), Output("accel-profile", "figure")], Input("model-rev", "data"), ) def _profiles(_rev): z_km = Z_GRID / 1000.0 cx, cy = NX // 2, NY // 2 B_z = _C.get("B_z", np.zeros(NZ)) buoy_fig = _profile_fig(B_z, z_km, "Buoyancy B(z)", "#4ab8e0", "m s⁻²") fig = go.Figure() for key, name, col in [("a_lin", "Linear", "#e09c4a"), ("a_spin", "Spin", "#c8d44a"), ("a_splat","Splat", "#e06c6c"), ("a_buoy", "Buoyancy", "#4ab8e0")]: arr = _C.get(key, np.zeros((NX, NY, NZ))) fig.add_trace(go.Scatter(x=arr[cx, cy, :], y=z_km, mode="lines", name=name, line=dict(color=col, width=1.8), hovertemplate=f"%{{x:.3g}} m/s²
%{{y:.1f}} km{name}")) fig.add_vline(x=0, line=dict(color="#555", width=1)) fig.update_layout(xaxis_title="acceleration (m s⁻²)", yaxis_title="z (km)", template="plotly_dark", margin=dict(l=50, r=10, t=10, b=35), height=220, legend=dict(font=dict(size=10), orientation="h", y=1.05)) return buoy_fig, fig # --------------------------------------------------------------------------- # App factory # --------------------------------------------------------------------------- _CSS = """ body { background: #0b0e14; color: #dfe3ea; font-family: -apple-system, system-ui, sans-serif; margin: 0; } .uf-root { max-width: 1600px; margin: 0 auto; padding: 16px; } .uf-header { margin-bottom: 6px; } .uf-header h1 { color: #6ecbff; } /* Page-level navigation tabs */ .uf-page-tabs { margin-bottom: 0; } .uf-page-tabs > .tab-container { border-bottom: 1px solid #2d3a4b !important; margin-bottom: 14px; } .uf-ptab { background: transparent !important; border: none !important; color: #9aa3ad !important; font-size: 13px !important; padding: 7px 18px !important; } .uf-ptab-sel { color: #6ecbff !important; border-bottom: 2px solid #6ecbff !important; background: transparent !important; } /* Three-column model layout */ .uf-main { display: grid; grid-template-columns: 320px 1fr 280px; gap: 16px; } .uf-left { background: #11161f; border-radius: 8px; padding: 12px; min-height: 600px; } .uf-center{ background: #11161f; border-radius: 8px; padding: 12px; } .uf-right { background: #11161f; border-radius: 8px; padding: 12px; } .uf-section-title { font-size: 11px; font-weight: 600; color: #8d97a2; letter-spacing: 0.05em; margin-bottom: 8px; margin-top: 4px; } .uf-slider-row { margin-bottom: 10px; } .uf-slider-header { display: flex; justify-content: space-between; align-items: center; font-size: 12px; margin-bottom: 2px; } .uf-slider-label { color: #c7ced6; } .uf-slider-value { color: #6ecbff; font-variant-numeric: tabular-nums; } .uf-tabs .tab { background: #161d29; border: none; color: #9aa3ad; font-size: 12px; padding: 6px 12px; } .uf-tabs .tab--selected { color: #6ecbff; border-bottom: 2px solid #3b86e6 !important; background: #0f1520; } .uf-tabs .tab-container { border-bottom: 1px solid #2d3a4b; } .uf-view-tabs .tab { background: transparent; border: none; color: #9aa3ad; font-size: 12px; padding: 4px 10px; } .uf-view-tabs .tab--selected { color: #6ecbff; border-bottom: 2px solid #3b86e6 !important; } .uf-view-tabs { flex: 1; } .uf-field-controls { display: flex; align-items: center; gap: 12px; margin-bottom: 10px; } .uf-slice-row { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; } .uf-slice-slider { flex: 1; } .uf-preset-row { display: flex; gap: 8px; margin-top: 12px; } .uf-btn { background: #1e2835; border: 1px solid #2d3a4b; color: #dfe3ea; padding: 5px 10px; border-radius: 5px; cursor: pointer; font-size: 12px; } .uf-btn:hover { background: #2a3a4e; } table tr:nth-child(even) td { background: #161d29; } /* ? tooltip */ .uf-help { display: inline-flex; align-items: center; justify-content: center; width: 15px; height: 15px; border-radius: 50%; background: #253040; color: #6ecbff; font-size: 9px; font-weight: 700; cursor: help; position: relative; flex-shrink: 0; user-select: none; } .uf-help::after { content: attr(data-tip); position: absolute; right: 0; top: 20px; background: #1a2233; color: #dfe3ea; padding: 8px 11px; border-radius: 6px; border: 1px solid #3b4d63; font-size: 11px; font-weight: 400; line-height: 1.55; white-space: normal; width: 220px; z-index: 2000; display: none; box-shadow: 0 4px 16px rgba(0,0,0,0.5); pointer-events: none; } .uf-help:hover::after { display: block; } /* Getting started & Theory pages */ .uf-page-content { max-width: 900px; margin: 0 auto; padding: 8px 20px 40px 20px; } .page-h2 { color: #6ecbff; font-size: 20px; margin-bottom: 20px; } .gs-intro { color: #c7ced6; font-size: 13px; line-height: 1.7; margin-bottom: 24px; } .gs-step { margin-bottom: 28px; } .gs-step-title { font-size: 13px; font-weight: 700; color: #ffd685; margin-bottom: 10px; letter-spacing: 0.03em; } .gs-step-body { font-size: 13px; color: #c7ced6; line-height: 1.65; } .gs-step-body p { margin: 0 0 8px 0; } .gs-table { width: 100%; border-collapse: collapse; } .gs-table tr:nth-child(even) td { background: #161d29; } .gs-ctrl-label { color: #6ecbff; font-size: 12px; font-weight: 600; padding: 5px 12px 5px 8px; white-space: nowrap; vertical-align: top; width: 140px; } .gs-ctrl-desc { color: #c7ced6; font-size: 12px; padding: 5px 8px; line-height: 1.55; } .theory-h3 { color: #ffd685; font-size: 14px; margin: 24px 0 8px 0; } .theory-p-md p { color: #c7ced6; font-size: 13px; line-height: 1.65; margin: 0 0 8px 0; } .theory-p-md strong { color: #dfe3ea; } .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; } .theory-eq-md .MathJax { color: #b0d8ff !important; font-size: 1.05em !important; } .theory-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin: 12px 0 20px 0; } .theory-term { background: #11161f; border-radius: 6px; padding: 12px; } .theory-term-title { font-size: 12px; font-weight: 700; color: #6ecbff; margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.06em; } .theory-refs { color: #c7ced6; font-size: 13px; line-height: 1.8; padding-left: 20px; } .theory-ref { margin-bottom: 6px; } .assume-table { width: 100%; border-collapse: collapse; margin: 10px 0 20px 0; font-size: 12px; } .assume-table tr:nth-child(even) td { background: #161d29; } .assume-check { color: #5ac87a; font-size: 13px; padding: 5px 10px 5px 6px; width: 18px; vertical-align: top; } .assume-text { color: #dfe3ea; font-weight: 600; padding: 5px 12px 5px 4px; width: 310px; vertical-align: top; } .assume-note { color: #8d97a2; padding: 5px 4px; line-height: 1.5; vertical-align: top; } /* New 4-tab layout grids */ .inp-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; padding: 4px 0; } .inp-col { background: #11161f; border-radius: 8px; padding: 12px; } .skewt-layout { display: grid; grid-template-columns: 1fr 260px; gap: 16px; padding: 4px 0; } .skewt-side { background: #11161f; border-radius: 8px; padding: 12px; overflow-y: auto; } .results-layout { display: grid; grid-template-columns: 1fr 280px; gap: 16px; padding: 4px 0; } .results-center { background: #11161f; border-radius: 8px; padding: 12px; } .results-side { background: #11161f; border-radius: 8px; padding: 12px; } /* Number inputs and slider tooltips — black text on white background */ input[type="number"], input[type="text"] { color: #111 !important; background: #fff !important; } .rc-slider-tooltip-content { color: #111 !important; background: #fff !important; } /* Field-select dropdown — force black text inside the React-Select widget */ #field-select .Select-value-label, #field-select .Select-placeholder, #field-select .Select-option, #field-select input { color: #111 !important; } #field-select .VirtualizedSelectOption { color: #111 !important; } """ def create_app() -> Dash: import os _assets = os.path.join(os.path.dirname(__file__), "assets") app = Dash(__name__, suppress_callback_exceptions=True, assets_folder=_assets) app.title = "Updraft Forcing: A parcel-grid model" app.layout = _build_layout() app.index_string = f""" {{%metas%}}{{%title%}} {{%css%}} {{%app_entry%}}""" _register_callbacks(app) return app