snesbitt's picture
Expand spin term definition on Theory page
c2f44c6
Raw
History Blame Contribute Delete
75.2 kB
"""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}<extra></extra>",
))
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}<br>%{{y:.1f}} km<extra></extra>",
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} hPa<extra>T</extra>",
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} hPa<extra>Td</extra>",
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} hPa<extra>Parcel</extra>",
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<extra></extra>",
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²<br>%{{y:.1f}} km<extra>{name}</extra>"))
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"""<!doctype html><html><head>
{{%metas%}}<title>{{%title%}}</title>
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
{{%css%}}
<style>{_CSS}</style>
</head><body>{{%app_entry%}}<footer>{{%config%}}{{%scripts%}}{{%renderer%}}</footer></body></html>"""
_register_callbacks(app)
return app