snesbitt Claude Sonnet 4.6 commited on
Commit
17eb167
·
1 Parent(s): bdb6526

Major UI overhaul: Skew-T, 4-tab layout, branding, bug fixes

Browse files

- Add full Skew-T log-P diagram with parcel trace, CAPE/CIN shading,
wind barbs (NH convention), and level markers (LCL/LFC/EL/Top)
- Restructure UI into Inputs / Outputs / Getting Started / Theory tabs;
Inputs uses 2-col grid (sounding+updraft left, hodograph+zeta right)
- Move ζ(z) profile editor below hodograph; rename to "Prescribed Mesocyclone ζ(z)"
- Fix draggable circle oval bug: handle both xanchor and x0/x1 relay keys;
add missing zeta-profile redraw callback
- Unify all diverging colormaps to RdBu_r
- Add UIUC CliMAS logo, cloud SVG favicon, updated page title/subtitle
- Fix Greek character rendering: remove text-transform:uppercase from section titles
- Label pressure reference lines on Skew-T; fix y-axis range (1025–98 hPa)
- Change "Weak shear" preset to "Linear shear"
- Add LaTeX equations and Model Assumptions table to Theory page

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

python/updraft_forcing/app.py CHANGED
@@ -132,6 +132,7 @@ def _run_model(snd_params, upd_params, u_pts, v_pts, zeta_cpts):
132
  "p_buoy": p_buoy,
133
  **accels,
134
  "snd": snd,
 
135
  "w_z": wp["w_z"],
136
  "B_z": wp["B_z"],
137
  "EL_m": wp["EL_m"],
@@ -171,8 +172,8 @@ def _slider(id_, label, mn, mx, step, value, unit="", tip=""):
171
  )
172
 
173
 
174
- def _profile_editor(fig_id, title, values, z_km, xunit, xrange):
175
- """Draggable profile graph (Mountain Waves pattern)."""
176
  values = np.asarray(values, dtype=float)
177
  z_km = np.asarray(z_km, dtype=float)
178
  vlo, vhi = xrange
@@ -205,8 +206,14 @@ def _profile_editor(fig_id, title, values, z_km, xunit, xrange):
205
  yaxis=dict(range=[0, z_km[-1] + 0.5], fixedrange=True),
206
  shapes=shapes,
207
  )
 
 
 
 
 
208
  return dcc.Graph(
209
- id=fig_id, figure=fig,
 
210
  config={"edits": {"shapePosition": True}, "displayModeBar": False, "scrollZoom": False},
211
  )
212
 
@@ -668,6 +675,283 @@ def _theory_content():
668
  ])
669
 
670
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
671
  # ---------------------------------------------------------------------------
672
  # Layout
673
  # ---------------------------------------------------------------------------
@@ -675,160 +959,190 @@ def _theory_content():
675
  def _build_layout():
676
  diag = _run_model(SND_DEFAULTS, UPD_DEFAULTS, WK_U, WK_V, ZETA_DEFAULTS)
677
 
678
- # ---- Left panel ----
679
- left = html.Div(className="uf-left", children=[
680
- dcc.Tabs(id="ctrl-tabs", value="sounding", className="uf-tabs", children=[
681
-
682
- dcc.Tab(label="Sounding", value="sounding", className="uf-tab",
683
- selected_className="uf-tab-sel", children=html.Div([
684
- html.Div("Weisman-Klemp Sounding", className="uf-section-title"),
685
- _slider("snd-theta-ml", "θ_ml (K)", 295, 315, 0.5, SND_DEFAULTS["theta_ml"],
686
- tip="Mixed-layer potential temperature. Higher values give a warmer "
687
- "boundary layer and more buoyancy."),
688
- _slider("snd-qv-ml", "qv_ml (g/kg)", 8, 20, 0.5, SND_DEFAULTS["qv_ml"],
689
- tip="Boundary-layer water vapor mixing ratio. More moisture lowers "
690
- "the LCL and increases CAPE."),
691
- _slider("snd-z-ml", "BL depth (m)", 500, 2000, 100, SND_DEFAULTS["z_ml"], " m",
692
- tip="Depth of the well-mixed layer. The constant-θ and qv profile "
693
- "extends to this height."),
694
- _slider("snd-z-trop", "Tropopause ht. (m)", 9000, 14000, 250,
695
- SND_DEFAULTS["z_trop"], " m",
696
- tip="Height of the tropopause. A higher tropopause allows a deeper "
697
- "updraft and more CAPE."),
698
- _slider("snd-T-trop", "Tropopause T (K)", 195, 220, 1,
699
- SND_DEFAULTS["T_trop"], " K",
700
- tip="Temperature at the tropopause. Colder tropopause → larger "
701
- "temperature difference from the surface → more CAPE."),
702
- _slider("snd-gamma", "Lapse rate exp.", 0.8, 1.8, 0.05,
703
- SND_DEFAULTS["gamma_ft"],
704
- tip="Shape exponent γ for the free-troposphere θ profile: "
705
- "θ = θ_ml + (θ_trop−θ_ml)·((z−z_ml)/(z_trop−z_ml))^γ. "
706
- "γ=1 linear; γ<1 more unstable near surface; γ>1 near tropopause."),
707
- html.Div(className="uf-preset-row", children=[
708
- html.Button("WK Supercell", id="preset-wk", className="uf-btn"),
709
- html.Button("Weak shear", id="preset-weak", className="uf-btn"),
710
- html.Button("Reset", id="preset-reset", className="uf-btn"),
711
- ]),
712
- ])),
713
 
714
- dcc.Tab(label="Updraft", value="updraft", className="uf-tab",
715
- selected_className="uf-tab-sel", children=html.Div([
716
- html.Div("Updraft Core", className="uf-section-title"),
717
- html.Div([
718
- html.Span("ΔT surface (K)", className="uf-slider-label"),
719
- _help("Temperature excess of the surface parcel above the environment. "
720
- "Drives the diagnosed w(z): w²(z) = max(0, 2·∫B dz). "
721
- "Larger ΔT → more KE to overcome CIN → stronger updraft."),
722
- dcc.Input(id="upd-delta-T", type="number", value=UPD_DEFAULTS["delta_T"],
723
- step=0.1, min=0.1, max=10.0,
724
- style={"width": "80px", "marginLeft": "8px",
725
- "background": "#0f1520", "border": "1px solid #2d3a4b",
726
- "color": "#dfe3ea", "padding": "4px 8px",
727
- "borderRadius": "4px"}),
728
- ], style={"display": "flex", "alignItems": "center", "marginBottom": "10px"}),
729
- _slider("upd-r0", "Radius (m)", 500, 5000, 100, UPD_DEFAULTS["r0"], " m",
730
- tip="Updraft core radius. The radial shape function tapers w and ζ "
731
- "to zero at this distance from center."),
732
- html.Div([
733
- html.Div("Prescribed ζ(z) — accumulated storm rotation",
734
- className="uf-section-title", style={"marginTop": "12px"}),
735
- _help("Vertical vorticity profile representing rotation that the storm "
736
- "has built up via tilting and stretching over its lifetime. "
737
- "Drag circles rightward for cyclonic (positive) rotation. "
738
- "This drives p'_spin and updraft helicity."),
739
- ], style={"display": "flex", "alignItems": "center", "gap": "6px"}),
740
- _profile_editor("zeta-profile", "ζ(z) (s⁻¹)",
741
- ZETA_DEFAULTS, ZETA_Z_KM, "s⁻¹", (-0.05, 0.05)),
742
- html.Div([
743
- html.Span("Core shape", className="uf-slider-label"),
744
- _help("Top-hat: uniform w inside 90% of r₀ with cosine taper in the outer 10%. "
745
- "Cosine bell: w(r) = cos(π·r/2r₀), smooth with no flat core."),
746
- ], style={"display": "flex", "alignItems": "center", "gap": "6px",
747
- "marginTop": "10px"}),
748
- dcc.RadioItems(
749
- id="upd-shape",
750
- options=[{"label": " Top-hat", "value": "tophat"},
751
- {"label": " Cosine", "value": "cosine"}],
752
- value=UPD_DEFAULTS["shape"],
753
- labelStyle={"marginRight": "14px", "color": "#dfe3ea", "fontSize": "13px"},
754
- style={"marginBottom": "10px"},
755
- ),
756
- ])),
757
-
758
- dcc.Tab(label="Hodograph", value="hodograph", className="uf-tab",
759
- selected_className="uf-tab-sel", children=html.Div([
760
- html.Div("Environmental hodograph", className="uf-section-title"),
761
- dcc.Graph(id="hodograph",
762
- figure=_hodo_figure(WK_U, WK_V,
763
- storm_u=diag["storm_u"], storm_v=diag["storm_v"],
764
- lm_u=diag["lm_u"], lm_v=diag["lm_v"],
765
- mean_u=diag["mean_u"], mean_v=diag["mean_v"]),
766
- config={"edits": {"shapePosition": True},
767
- "displayModeBar": False, "scrollZoom": False}),
768
- html.Div([
769
- html.Span("Drag the gold circles to edit the wind at each level (km label). "
770
- "RM = Bunkers right-mover, LM = left-mover, × = mean wind.",
771
- style={"color": "#8f98a3", "fontSize": "11px"}),
772
- _help("Wind levels: 0, 1, 2, 3, 4, 5, 6, 8, 10 km. "
773
- "Storm motion (RM/LM) is computed using Bunkers et al. (2000): "
774
- "mean 0–6 km wind ± 7.5 m/s perpendicular to the 0–6 km shear vector. "
775
- "SRH is computed relative to the right-mover."),
776
- ], style={"display": "flex", "alignItems": "flex-start", "gap": "6px",
777
- "marginTop": "6px"}),
778
- ])),
 
 
 
 
 
 
 
 
 
 
 
 
779
  ]),
780
  ])
781
 
782
- # ---- Center panel ----
783
- center = html.Div(className="uf-center", children=[
784
- html.Div(className="uf-field-controls", children=[
785
- dcc.Dropdown(
786
- id="field-select",
787
- options=[{"label": v, "value": k} for k, v in FIELD_LABELS.items()],
788
- value="w", clearable=False,
789
- style={"width": "240px", "fontSize": "13px"},
790
- ),
791
- dcc.Tabs(id="view-tabs", value="plan", className="uf-view-tabs", children=[
792
- dcc.Tab(label="Plan view", value="plan", className="uf-vtab", selected_className="uf-vtab-sel"),
793
- dcc.Tab(label="X cross-sec", value="xcross", className="uf-vtab", selected_className="uf-vtab-sel"),
794
- dcc.Tab(label="Y cross-sec", value="ycross", className="uf-vtab", selected_className="uf-vtab-sel"),
 
 
 
 
 
 
 
 
 
795
  ]),
 
796
  ]),
797
- html.Div(className="uf-slice-row", children=[
798
- html.Span("Slice: ", className="uf-slider-label"),
799
- dcc.Slider(id="slice-slider", min=0, max=NZ - 1, step=1, value=NZ // 4,
800
- marks=None, tooltip={"always_visible": False},
801
- className="uf-slice-slider"),
802
- html.Span(id="slice-label", children="z = 4.0 km",
803
- style={"color": "#6ecbff", "fontSize": "12px", "minWidth": "80px"}),
804
  ]),
805
- dcc.Graph(id="main-heatmap", config={"displayModeBar": False}),
806
- html.Div(className="uf-section-title", style={"marginTop": "14px"},
807
- children="Diagnosed w(z) from parcel model"),
808
- dcc.Graph(id="w-profile-graph", config={"displayModeBar": False}),
809
  ])
810
 
811
- # ---- Right panel ----
812
- right = html.Div(className="uf-right", children=[
813
- html.Div("Sounding Parameters", className="uf-section-title"),
814
- html.Table(id="diag-table", style={"width": "100%", "borderCollapse": "collapse"}),
815
- html.Div("Buoyancy B(z)", className="uf-section-title", style={"marginTop": "14px"}),
816
- dcc.Graph(id="buoy-profile", config={"displayModeBar": False}),
817
- html.Div("Vertical accelerations", className="uf-section-title", style={"marginTop": "10px"}),
818
- dcc.Graph(id="accel-profile", config={"displayModeBar": False}),
 
819
  ])
820
 
821
- model_layout = html.Div(className="uf-main", children=[left, center, right])
822
-
823
  return html.Div(className="uf-root", children=[
824
  html.Div(className="uf-header", children=[
825
- html.H1("UpdraftForcing", style={"margin": "0 0 4px 0", "fontSize": "22px"}),
826
- html.Div("Convective updraft wind shear diagnostics",
827
- style={"color": "#9aa3ad", "fontSize": "12px"}),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
828
  ]),
829
- dcc.Tabs(id="page-tabs", value="model", className="uf-page-tabs", children=[
830
- dcc.Tab(label="Model", value="model", className="uf-ptab", selected_className="uf-ptab-sel",
831
- children=model_layout),
 
 
832
  dcc.Tab(label="Getting Started", value="help", className="uf-ptab", selected_className="uf-ptab-sel",
833
  children=_getting_started_content()),
834
  dcc.Tab(label="Theory", value="theory", className="uf-ptab", selected_className="uf-ptab-sel",
@@ -889,12 +1203,25 @@ def _register_callbacks(app):
889
  return no_update
890
  u = list(store["u"]); v = list(store["v"]); changed = False
891
  for i in range(len(u)):
892
- for key, lst in [(f"shapes[{i}].xanchor", u), (f"shapes[{i}].yanchor", v)]:
893
- if key in relay:
894
- try:
895
- lst[i] = max(-60.0, min(60.0, float(relay[key]))); changed = True
896
- except (TypeError, ValueError):
897
- pass
 
 
 
 
 
 
 
 
 
 
 
 
 
898
  return {"u": u, "v": v} if changed else no_update
899
 
900
  @app.callback(
@@ -908,12 +1235,14 @@ def _register_callbacks(app):
908
  return no_update
909
  zeta = list(store["zeta"]); changed = False
910
  for i in range(len(zeta)):
911
- kxa = f"shapes[{i}].xanchor"
912
- if kxa in relay:
 
 
 
913
  try:
914
- zeta[i] = max(-0.10, min(0.10, float(relay[kxa]))); changed = True
915
- except (TypeError, ValueError):
916
- pass
917
  return {"zeta": zeta} if changed else no_update
918
 
919
  @app.callback(
@@ -927,6 +1256,13 @@ def _register_callbacks(app):
927
  lm_u=d.get("lm_u"), lm_v=d.get("lm_v"),
928
  mean_u=d.get("mean_u"), mean_v=d.get("mean_v"))
929
 
 
 
 
 
 
 
 
930
  @app.callback(
931
  Output("model-rev", "data"),
932
  [Input("snd-theta-ml", "value"), Input("snd-qv-ml", "value"),
@@ -1054,6 +1390,13 @@ def _register_callbacks(app):
1054
  ]
1055
  return html.Tbody(rows)
1056
 
 
 
 
 
 
 
 
1057
  @app.callback(
1058
  [Output("buoy-profile", "figure"), Output("accel-profile", "figure")],
1059
  Input("model-rev", "data"),
@@ -1103,7 +1446,7 @@ body { background: #0b0e14; color: #dfe3ea; font-family: -apple-system, system-u
1103
  .uf-center{ background: #11161f; border-radius: 8px; padding: 12px; }
1104
  .uf-right { background: #11161f; border-radius: 8px; padding: 12px; }
1105
 
1106
- .uf-section-title { font-size: 11px; font-weight: 600; color: #8d97a2; letter-spacing: 0.08em; text-transform: uppercase; margin-bottom: 8px; margin-top: 4px; }
1107
  .uf-slider-row { margin-bottom: 10px; }
1108
  .uf-slider-header { display: flex; justify-content: space-between; align-items: center; font-size: 12px; margin-bottom: 2px; }
1109
  .uf-slider-label { color: #c7ced6; }
@@ -1173,15 +1516,35 @@ table tr:nth-child(even) td { background: #161d29; }
1173
  .assume-check { color: #5ac87a; font-size: 13px; padding: 5px 10px 5px 6px; width: 18px; vertical-align: top; }
1174
  .assume-text { color: #dfe3ea; font-weight: 600; padding: 5px 12px 5px 4px; width: 310px; vertical-align: top; }
1175
  .assume-note { color: #8d97a2; padding: 5px 4px; line-height: 1.5; vertical-align: top; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1176
  """
1177
 
1178
 
1179
  def create_app() -> Dash:
1180
- app = Dash(__name__, suppress_callback_exceptions=True)
1181
- app.title = "UpdraftForcing"
 
 
1182
  app.layout = _build_layout()
1183
  app.index_string = f"""<!doctype html><html><head>
1184
- {{%metas%}}<title>{{%title%}}</title>{{%favicon%}}{{%css%}}
 
 
1185
  <style>{_CSS}</style>
1186
  </head><body>{{%app_entry%}}<footer>{{%config%}}{{%scripts%}}{{%renderer%}}</footer></body></html>"""
1187
  _register_callbacks(app)
 
132
  "p_buoy": p_buoy,
133
  **accels,
134
  "snd": snd,
135
+ "T_parcel": wp["T_parcel"],
136
  "w_z": wp["w_z"],
137
  "B_z": wp["B_z"],
138
  "EL_m": wp["EL_m"],
 
172
  )
173
 
174
 
175
+ def _profile_editor_fig(title, values, z_km, xunit, xrange):
176
+ """Return a go.Figure for a draggable profile editor."""
177
  values = np.asarray(values, dtype=float)
178
  z_km = np.asarray(z_km, dtype=float)
179
  vlo, vhi = xrange
 
206
  yaxis=dict(range=[0, z_km[-1] + 0.5], fixedrange=True),
207
  shapes=shapes,
208
  )
209
+ return fig
210
+
211
+
212
+ def _profile_editor(fig_id, title, values, z_km, xunit, xrange):
213
+ """Draggable profile graph (Mountain Waves pattern)."""
214
  return dcc.Graph(
215
+ id=fig_id,
216
+ figure=_profile_editor_fig(title, values, z_km, xunit, xrange),
217
  config={"edits": {"shapePosition": True}, "displayModeBar": False, "scrollZoom": False},
218
  )
219
 
 
675
  ])
676
 
677
 
678
+ # ---------------------------------------------------------------------------
679
+ # Skew-T helpers
680
+ # ---------------------------------------------------------------------------
681
+
682
+ _SKEW = 45.0 # °C shift per log10-pressure decade
683
+ _BARB_X = 60.0 # x anchor for wind barb tips (skewed °C)
684
+ _BARB_ASPECT = 120.0 # x-units per 1 log10-p unit (matches ~550px / ~4.5px/unit plot)
685
+ _BARB_STAFF = 5.0
686
+ _BARB_FULL = 2.8
687
+ _BARB_HALF = 1.4
688
+ _BARB_SEP = 0.91
689
+
690
+
691
+ def _sx(T_C, p_hPa):
692
+ """Skew-T x coordinate."""
693
+ return np.asarray(T_C, float) + _SKEW * np.log10(1000.0 / np.asarray(p_hPa, float))
694
+
695
+
696
+ def _wind_barbs_trace(env_u, env_v, p_snd):
697
+ """
698
+ Return (xs, ys) lists (with None separators) for a single wind-barb
699
+ go.Scatter trace. NH convention: staff points upwind; flags on the right
700
+ when looking from tip toward tail (CW 90° from the upwind direction).
701
+ """
702
+ barb_lvls = [1000, 925, 850, 700, 600, 500, 400, 300, 250, 200, 150, 100]
703
+ # np.interp requires xp increasing; p_snd decreases, so reverse
704
+ p_rev = p_snd[::-1]
705
+ u_rev = env_u[::-1]
706
+ v_rev = env_v[::-1]
707
+
708
+ xs: list = []
709
+ ys: list = []
710
+
711
+ def screen(ex, en, length):
712
+ """(east, north) direction + length → (Δx_skewed, Δlog10p)."""
713
+ return ex * length, -en * length / _BARB_ASPECT
714
+
715
+ for p_tgt in barb_lvls:
716
+ if p_tgt > p_rev[-1] or p_tgt < p_rev[0]:
717
+ continue
718
+ u = float(np.interp(p_tgt, p_rev, u_rev))
719
+ v = float(np.interp(p_tgt, p_rev, v_rev))
720
+ spd = float(np.hypot(u, v))
721
+ spd_kt = spd * 1.9438
722
+ log_p0 = np.log10(float(p_tgt))
723
+
724
+ if spd < 0.5: # calm: tiny tick
725
+ xs.extend([_BARB_X - 0.4, _BARB_X + 0.4, None])
726
+ ys.extend([p_tgt, p_tgt, None])
727
+ continue
728
+
729
+ ux = -u / spd; uy = -v / spd # upwind unit vector (E, N components)
730
+ pex = uy; pen = -ux # perpendicular = CW 90° of upwind (NH convention)
731
+
732
+ # Staff (tip → tail)
733
+ dx_s, dl_s = screen(ux, uy, _BARB_STAFF)
734
+ xs.extend([_BARB_X, _BARB_X + dx_s, None])
735
+ ys.extend([float(p_tgt), float(10 ** (log_p0 + dl_s)), None])
736
+
737
+ n50 = int(spd_kt // 50)
738
+ rem = spd_kt - n50 * 50
739
+ n10 = int(rem // 10); rem -= n10 * 10
740
+ n5 = 1 if rem >= 5 else 0
741
+
742
+ pos = _BARB_STAFF # current position along staff from tip
743
+ for _ in range(n50): # pennants
744
+ dx_b, dl_b = screen(ux, uy, pos)
745
+ b1x, b1l = _BARB_X + dx_b, log_p0 + dl_b
746
+ dx_e, dl_e = screen(pex, pen, _BARB_FULL)
747
+ b2x, b2l = b1x + dx_e, b1l + dl_e
748
+ dx_b3, dl_b3 = screen(ux, uy, pos - 2 * _BARB_SEP)
749
+ b3x, b3l = _BARB_X + dx_b3, log_p0 + dl_b3
750
+ xs.extend([b1x, b2x, b3x, b1x, None])
751
+ ys.extend([10**b1l, 10**b2l, 10**b3l, 10**b1l, None])
752
+ pos -= 2 * _BARB_SEP
753
+ for _ in range(n10): # full barbs
754
+ dx_b, dl_b = screen(ux, uy, pos)
755
+ b1x, b1l = _BARB_X + dx_b, log_p0 + dl_b
756
+ dx_e, dl_e = screen(pex, pen, _BARB_FULL)
757
+ xs.extend([b1x, b1x + dx_e, None])
758
+ ys.extend([10**b1l, 10**(b1l + dl_e), None])
759
+ pos -= _BARB_SEP
760
+ if n5: # half barb
761
+ dx_b, dl_b = screen(ux, uy, pos)
762
+ b1x, b1l = _BARB_X + dx_b, log_p0 + dl_b
763
+ dx_e, dl_e = screen(pex, pen, _BARB_HALF)
764
+ xs.extend([b1x, b1x + dx_e, None])
765
+ ys.extend([10**b1l, 10**(b1l + dl_e), None])
766
+
767
+ return xs, ys
768
+
769
+
770
+ def _skewt_figure() -> go.Figure:
771
+ """Build a Skew-T log-P diagram from the current _C cache."""
772
+ snd = _C.get("snd")
773
+ if snd is None:
774
+ return go.Figure()
775
+
776
+ Rd = 287.04; Rv = 461.5; Lv = 2.501e6; Cp = 1005.7
777
+ eps = Rd / Rv; KAPPA = Rd / Cp
778
+
779
+ # Background pressure grid (surface → top, decreasing)
780
+ p_bg = np.concatenate([np.arange(1050, 500, -5.0), np.arange(500, 95, -2.5)])
781
+
782
+ fig = go.Figure()
783
+
784
+ # ---- isotherms ----
785
+ for T_iso in range(-100, 61, 10):
786
+ fig.add_trace(go.Scatter(
787
+ x=_sx(T_iso, p_bg), y=p_bg, mode="lines", hoverinfo="skip", showlegend=False,
788
+ line=dict(color="rgba(90,120,150,0.55)" if T_iso == 0 else "rgba(65,90,115,0.3)",
789
+ width=0.9 if T_iso == 0 else 0.55)))
790
+
791
+ # ---- dry adiabats ----
792
+ for theta_K in [255, 265, 275, 285, 295, 305, 315, 325, 340, 360, 390]:
793
+ T_C = theta_K * (p_bg / 1000.0) ** KAPPA - 273.15
794
+ fig.add_trace(go.Scatter(
795
+ x=_sx(T_C, p_bg), y=p_bg, mode="lines", hoverinfo="skip", showlegend=False,
796
+ line=dict(color="rgba(210,155,55,0.38)", width=0.6, dash="dash")))
797
+
798
+ # ---- moist adiabats ----
799
+ def _ma(T0_C, p_arr):
800
+ T = T0_C + 273.15
801
+ out = []
802
+ for i, p in enumerate(p_arr):
803
+ out.append(T - 273.15)
804
+ if i < len(p_arr) - 1:
805
+ dp = p_arr[i + 1] - p
806
+ T_c = T - 273.15
807
+ es = 6.112 * np.exp(17.67 * T_c / (T_c + 243.5))
808
+ rs = eps * es / max(p - es, 0.1)
809
+ # dT/dp = (T/p) * (Rd + Lv*rs/T) / (Cp + Lv²*rs/(Rv*T²))
810
+ T = max(T + (T / p) * (Rd + Lv * rs / T) /
811
+ (Cp + Lv**2 * rs / (Rv * T**2)) * dp, 100.0)
812
+ return np.array(out)
813
+
814
+ for T0 in [-25, -15, -5, 5, 15, 25, 35]:
815
+ T_ma = _ma(T0, p_bg)
816
+ mask = T_ma > -85
817
+ if mask.sum() >= 3:
818
+ fig.add_trace(go.Scatter(
819
+ x=_sx(T_ma[mask], p_bg[mask]), y=p_bg[mask], mode="lines",
820
+ hoverinfo="skip", showlegend=False,
821
+ line=dict(color="rgba(70,185,115,0.40)", width=0.6, dash="dash")))
822
+
823
+ # ---- mixing-ratio lines (below 550 hPa) ----
824
+ p_low = p_bg[p_bg >= 550]
825
+ for w_gkg in [2, 4, 7, 10, 16]:
826
+ w = w_gkg / 1000.0
827
+ e = p_low * w / (eps + w)
828
+ ln_e = np.log(np.maximum(e, 1e-6) / 6.112)
829
+ T_C = 243.5 * ln_e / (17.67 - ln_e)
830
+ mask = (T_C > -40) & (T_C < 40)
831
+ if mask.sum() >= 2:
832
+ fig.add_trace(go.Scatter(
833
+ x=_sx(T_C[mask], p_low[mask]), y=p_low[mask], mode="lines",
834
+ hoverinfo="skip", showlegend=False,
835
+ line=dict(color="rgba(65,150,215,0.40)", width=0.5, dash="dot")))
836
+
837
+ # ---- sounding profiles ----
838
+ p_snd = snd["p_hPa"]
839
+ T_env_C = snd["T_K"] - 273.15
840
+ Td_env_C= snd["Td_K"] - 273.15
841
+
842
+ mask_snd = (p_snd >= 98) & (p_snd <= 1060)
843
+ p_s = p_snd[mask_snd]
844
+ T_s = T_env_C[mask_snd]
845
+ Td_s = Td_env_C[mask_snd]
846
+
847
+ T_pcl_K = _C.get("T_parcel")
848
+ T_pcl_C = (T_pcl_K[mask_snd] - 273.15) if T_pcl_K is not None else None
849
+
850
+ diag = _C.get("diag", {})
851
+ def _z2p(z_m):
852
+ return float(np.interp(float(z_m or 0), Z_GRID, p_snd))
853
+
854
+ p_LCL = _z2p(diag.get("LCL_m", 0))
855
+ p_LFC = _z2p(diag.get("LFC_m", 0))
856
+ p_EL = _z2p(_C.get("EL_m", 0))
857
+ p_top = _z2p(_C.get("z_top_m", 0))
858
+
859
+ # CAPE shading
860
+ if T_pcl_C is not None:
861
+ cm = (p_s <= p_LFC + 5) & (p_s >= p_EL - 5) & (T_pcl_C > T_s)
862
+ if cm.sum() >= 2:
863
+ xenv, xpcl = _sx(T_s[cm], p_s[cm]), _sx(T_pcl_C[cm], p_s[cm])
864
+ fig.add_trace(go.Scatter(
865
+ x=list(xenv) + list(xpcl[::-1]), y=list(p_s[cm]) + list(p_s[cm][::-1]),
866
+ fill="toself", fillcolor="rgba(50,200,70,0.22)",
867
+ line=dict(width=0), showlegend=False, hoverinfo="skip"))
868
+ # CIN shading
869
+ cm2 = (p_s >= p_LFC - 5) & (T_pcl_C < T_s)
870
+ if cm2.sum() >= 2:
871
+ xenv, xpcl = _sx(T_s[cm2], p_s[cm2]), _sx(T_pcl_C[cm2], p_s[cm2])
872
+ fig.add_trace(go.Scatter(
873
+ x=list(xenv) + list(xpcl[::-1]), y=list(p_s[cm2]) + list(p_s[cm2][::-1]),
874
+ fill="toself", fillcolor="rgba(180,180,180,0.18)",
875
+ line=dict(width=0), showlegend=False, hoverinfo="skip"))
876
+
877
+ fig.add_trace(go.Scatter(
878
+ x=_sx(T_s, p_s), y=p_s, mode="lines", name="Temp",
879
+ line=dict(color="#e06c6c", width=2.5),
880
+ hovertemplate="%{text}°C @ %{y:.0f} hPa<extra>T</extra>",
881
+ text=[f"{t:.1f}" for t in T_s]))
882
+ fig.add_trace(go.Scatter(
883
+ x=_sx(Td_s, p_s), y=p_s, mode="lines", name="Dewpt",
884
+ line=dict(color="#5ac85a", width=2.5),
885
+ hovertemplate="%{text}°C @ %{y:.0f} hPa<extra>Td</extra>",
886
+ text=[f"{t:.1f}" for t in Td_s]))
887
+ if T_pcl_C is not None:
888
+ fig.add_trace(go.Scatter(
889
+ x=_sx(T_pcl_C, p_s), y=p_s, mode="lines", name="Parcel",
890
+ line=dict(color="#ffd685", width=1.8, dash="dash"),
891
+ hovertemplate="%{text}°C @ %{y:.0f} hPa<extra>Parcel</extra>",
892
+ text=[f"{t:.1f}" for t in T_pcl_C]))
893
+
894
+ # Level lines
895
+ for p_lvl, label, color in [
896
+ (p_LCL, "LCL", "#9abfff"), (p_LFC, "LFC", "#ffaa44"),
897
+ (p_EL, "EL", "#ffd685"), (p_top, "Top", "#ff8c00"),
898
+ ]:
899
+ if 98 < p_lvl < 1060:
900
+ fig.add_hline(y=p_lvl, line=dict(color=color, width=0.9, dash="dot"),
901
+ annotation_text=label, annotation_position="top left",
902
+ annotation_font=dict(color=color, size=10))
903
+
904
+ # Wind barbs
905
+ env_u = _C.get("env_u"); env_v = _C.get("env_v")
906
+ if env_u is not None and env_v is not None:
907
+ bx, by = _wind_barbs_trace(env_u, env_v, p_snd)
908
+ if bx:
909
+ fig.add_trace(go.Scatter(x=bx, y=by, mode="lines",
910
+ line=dict(color="#ccd3db", width=1.2),
911
+ showlegend=False, hoverinfo="skip"))
912
+ # Vertical guide line for barb column
913
+ fig.add_vline(x=_BARB_X - _BARB_STAFF - 0.5,
914
+ line=dict(color="rgba(80,100,120,0.3)", width=0.6))
915
+
916
+ p_ticks = [1000, 925, 850, 700, 600, 500, 400, 300, 250, 200, 150, 100]
917
+ x_lo = min(float(_sx(T_s.min() - 5, 1050)), -55.0)
918
+ x_hi = max(float(_sx(T_s.max() + 2, min(p_s))), _BARB_X + _BARB_FULL + 2)
919
+
920
+ fig.update_layout(
921
+ template="plotly_dark",
922
+ height=620,
923
+ margin=dict(l=60, r=15, t=18, b=40),
924
+ paper_bgcolor="#11161f",
925
+ plot_bgcolor="#0a0f1a",
926
+ xaxis=dict(
927
+ title="Temperature (°C)",
928
+ range=[x_lo, x_hi],
929
+ tickmode="array",
930
+ tickvals=list(range(-80, 61, 10)),
931
+ ticktext=[str(t) for t in range(-80, 61, 10)],
932
+ showgrid=False, zeroline=False,
933
+ ),
934
+ yaxis=dict(
935
+ title="Pressure (hPa)",
936
+ type="log",
937
+ range=[np.log10(1025), np.log10(98)],
938
+ tickmode="array",
939
+ tickvals=p_ticks,
940
+ ticktext=[str(p) for p in p_ticks],
941
+ showgrid=False,
942
+ ),
943
+ legend=dict(x=0.01, y=0.01, font=dict(size=11),
944
+ bgcolor="rgba(10,15,26,0.8)",
945
+ bordercolor="#2d3a4b", borderwidth=1),
946
+ )
947
+ for p_ref in [1000, 850, 700, 500, 300, 200]:
948
+ fig.add_hline(y=p_ref, line=dict(color="rgba(90,120,150,0.22)", width=0.6),
949
+ annotation_text=f"{p_ref}",
950
+ annotation_position="left",
951
+ annotation_font=dict(size=9, color="rgba(130,155,185,0.7)"))
952
+ return fig
953
+
954
+
955
  # ---------------------------------------------------------------------------
956
  # Layout
957
  # ---------------------------------------------------------------------------
 
959
  def _build_layout():
960
  diag = _run_model(SND_DEFAULTS, UPD_DEFAULTS, WK_U, WK_V, ZETA_DEFAULTS)
961
 
962
+ # =========================================================
963
+ # INPUTS TAB — three columns: Sounding | Hodograph | Updraft
964
+ # =========================================================
965
+ snd_col = html.Div(className="inp-col", children=[
966
+ html.Div("Weisman-Klemp Sounding", className="uf-section-title"),
967
+ _slider("snd-theta-ml", "θ_ml (K)", 295, 315, 0.5, SND_DEFAULTS["theta_ml"],
968
+ tip="Mixed-layer potential temperature. Higher values give a warmer "
969
+ "boundary layer and more buoyancy."),
970
+ _slider("snd-qv-ml", "qv_ml (g/kg)", 8, 20, 0.5, SND_DEFAULTS["qv_ml"],
971
+ tip="Boundary-layer water vapor mixing ratio. More moisture lowers "
972
+ "the LCL and increases CAPE."),
973
+ _slider("snd-z-ml", "BL depth (m)", 500, 2000, 100, SND_DEFAULTS["z_ml"], " m",
974
+ tip="Depth of the well-mixed layer. The constant-θ and qv profile "
975
+ "extends to this height."),
976
+ _slider("snd-z-trop", "Tropopause ht. (m)", 9000, 14000, 250,
977
+ SND_DEFAULTS["z_trop"], " m",
978
+ tip="Height of the tropopause. A higher tropopause allows a deeper "
979
+ "updraft and more CAPE."),
980
+ _slider("snd-T-trop", "Tropopause T (K)", 195, 220, 1,
981
+ SND_DEFAULTS["T_trop"], " K",
982
+ tip="Temperature at the tropopause. Colder tropopause → larger "
983
+ "temperature difference from the surface → more CAPE."),
984
+ _slider("snd-gamma", "Lapse rate exp.", 0.8, 1.8, 0.05,
985
+ SND_DEFAULTS["gamma_ft"],
986
+ tip="Shape exponent γ for the free-troposphere θ profile: "
987
+ "θ = θ_ml + (θ_trop−θ_ml)·((z−z_ml)/(z_trop−z_ml))^γ. "
988
+ "γ=1 linear; γ<1 more unstable near surface; γ>1 near tropopause."),
989
+ html.Div(className="uf-preset-row", children=[
990
+ html.Button("WK Supercell", id="preset-wk", className="uf-btn"),
991
+ html.Button("Linear shear", id="preset-weak", className="uf-btn"),
992
+ html.Button("Reset", id="preset-reset", className="uf-btn"),
993
+ ]),
994
+ ])
 
 
995
 
996
+ hodo_col = html.Div(className="inp-col", style={"gridColumn": "2", "gridRow": "1 / span 2"}, children=[
997
+ html.Div("Environmental Hodograph", className="uf-section-title"),
998
+ dcc.Graph(id="hodograph",
999
+ figure=_hodo_figure(WK_U, WK_V,
1000
+ storm_u=diag["storm_u"], storm_v=diag["storm_v"],
1001
+ lm_u=diag["lm_u"], lm_v=diag["lm_v"],
1002
+ mean_u=diag["mean_u"], mean_v=diag["mean_v"]),
1003
+ config={"edits": {"shapePosition": True},
1004
+ "displayModeBar": False, "scrollZoom": False}),
1005
+ html.Div([
1006
+ html.Span("Drag gold circles to edit wind at each level. "
1007
+ "RM = right-mover, LM = left-mover, × = mean wind.",
1008
+ style={"color": "#8f98a3", "fontSize": "11px"}),
1009
+ _help("Wind levels: 0, 1, 2, 3, 4, 5, 6, 8, 10 km. "
1010
+ "Storm motion from Bunkers et al. (2000): "
1011
+ "mean 0–6 km wind ± 7.5 m/s to shear vector. "
1012
+ "SRH computed relative to the right-mover."),
1013
+ ], style={"display": "flex", "alignItems": "flex-start", "gap": "6px",
1014
+ "marginTop": "6px"}),
1015
+ html.Div([
1016
+ html.Div("Prescribed Mesocyclone ζ(z)",
1017
+ className="uf-section-title", style={"marginTop": "14px"}),
1018
+ _help("Vertical vorticity profile representing rotation the storm has built "
1019
+ "up. Drag circles rightward for cyclonic (positive) rotation. "
1020
+ "Drives p'_spin and updraft helicity."),
1021
+ ], style={"display": "flex", "alignItems": "center", "gap": "6px"}),
1022
+ _profile_editor("zeta-profile", "ζ(z) (s⁻¹)",
1023
+ ZETA_DEFAULTS, ZETA_Z_KM, "s⁻¹", (-0.05, 0.05)),
1024
+ ])
1025
+
1026
+ upd_col = html.Div(className="inp-col", children=[
1027
+ html.Div("Updraft Core", className="uf-section-title"),
1028
+ html.Div([
1029
+ html.Span("ΔT surface (K)", className="uf-slider-label"),
1030
+ _help("Temperature excess of the surface parcel above the environment. "
1031
+ "Drives the diagnosed w(z): w²(z) = max(0, 2·∫B dz). "
1032
+ "Larger ΔT more KE to overcome CIN → stronger updraft."),
1033
+ dcc.Input(id="upd-delta-T", type="number", value=UPD_DEFAULTS["delta_T"],
1034
+ step=0.1, min=0.1, max=10.0,
1035
+ style={"width": "80px", "marginLeft": "8px",
1036
+ "background": "#0f1520", "border": "1px solid #2d3a4b",
1037
+ "color": "#dfe3ea", "padding": "4px 8px",
1038
+ "borderRadius": "4px"}),
1039
+ ], style={"display": "flex", "alignItems": "center", "marginBottom": "10px"}),
1040
+ _slider("upd-r0", "Radius (m)", 500, 5000, 100, UPD_DEFAULTS["r0"], " m",
1041
+ tip="Updraft core radius. The radial shape function tapers w and ζ "
1042
+ "to zero at this distance from center."),
1043
+ html.Div([
1044
+ html.Span("Core shape", className="uf-slider-label"),
1045
+ _help("Top-hat: uniform w inside 90% of r₀ with cosine taper in the outer 10%. "
1046
+ "Cosine bell: w(r) = cos(π·r/2r₀), smooth with no flat core."),
1047
+ ], style={"display": "flex", "alignItems": "center", "gap": "6px",
1048
+ "marginTop": "10px"}),
1049
+ dcc.RadioItems(
1050
+ id="upd-shape",
1051
+ options=[{"label": " Top-hat", "value": "tophat"},
1052
+ {"label": " Cosine", "value": "cosine"}],
1053
+ value=UPD_DEFAULTS["shape"],
1054
+ labelStyle={"marginRight": "14px", "color": "#dfe3ea", "fontSize": "13px"},
1055
+ style={"marginBottom": "10px"},
1056
+ ),
1057
+ ])
1058
+
1059
+ inputs_tab = html.Div(className="inp-grid", children=[snd_col, hodo_col, upd_col])
1060
+
1061
+ # =========================================================
1062
+ # OUTPUTS TAB — sub-tabs: Skew-T | Model Results
1063
+ # =========================================================
1064
+ skewt_subtab = html.Div(className="skewt-layout", children=[
1065
+ dcc.Graph(id="skewt-graph", figure=_skewt_figure(),
1066
+ config={"displayModeBar": False}),
1067
+ html.Div(className="skewt-side", children=[
1068
+ html.Div("Sounding Parameters", className="uf-section-title"),
1069
+ html.Table(id="diag-table", style={"width": "100%", "borderCollapse": "collapse"}),
1070
+ html.Div("Diagnosed w(z)", className="uf-section-title",
1071
+ style={"marginTop": "14px"}),
1072
+ dcc.Graph(id="w-profile-graph", config={"displayModeBar": False}),
1073
  ]),
1074
  ])
1075
 
1076
+ results_subtab = html.Div(className="results-layout", children=[
1077
+ html.Div(className="results-center", children=[
1078
+ html.Div(className="uf-field-controls", children=[
1079
+ dcc.Dropdown(
1080
+ id="field-select",
1081
+ options=[{"label": v, "value": k} for k, v in FIELD_LABELS.items()],
1082
+ value="w", clearable=False,
1083
+ style={"width": "240px", "fontSize": "13px", "color": "#111"},
1084
+ ),
1085
+ dcc.Tabs(id="view-tabs", value="plan", className="uf-view-tabs", children=[
1086
+ dcc.Tab(label="Plan view", value="plan", className="uf-vtab", selected_className="uf-vtab-sel"),
1087
+ dcc.Tab(label="X cross-sec", value="xcross", className="uf-vtab", selected_className="uf-vtab-sel"),
1088
+ dcc.Tab(label="Y cross-sec", value="ycross", className="uf-vtab", selected_className="uf-vtab-sel"),
1089
+ ]),
1090
+ ]),
1091
+ html.Div(className="uf-slice-row", children=[
1092
+ html.Span("Slice: ", className="uf-slider-label"),
1093
+ dcc.Slider(id="slice-slider", min=0, max=NZ - 1, step=1, value=NZ // 4,
1094
+ marks=None, tooltip={"always_visible": False},
1095
+ className="uf-slice-slider"),
1096
+ html.Span(id="slice-label", children="z = 4.0 km",
1097
+ style={"color": "#6ecbff", "fontSize": "12px", "minWidth": "80px"}),
1098
  ]),
1099
+ dcc.Graph(id="main-heatmap", config={"displayModeBar": False}),
1100
  ]),
1101
+ html.Div(className="results-side", children=[
1102
+ html.Div("Buoyancy B(z)", className="uf-section-title"),
1103
+ dcc.Graph(id="buoy-profile", config={"displayModeBar": False}),
1104
+ html.Div("Vertical accelerations", className="uf-section-title",
1105
+ style={"marginTop": "10px"}),
1106
+ dcc.Graph(id="accel-profile", config={"displayModeBar": False}),
 
1107
  ]),
 
 
 
 
1108
  ])
1109
 
1110
+ outputs_tab = html.Div(children=[
1111
+ dcc.Tabs(id="out-tabs", value="skewt", className="uf-tabs", children=[
1112
+ dcc.Tab(label="Skew-T", value="skewt",
1113
+ className="uf-tab", selected_className="uf-tab-sel",
1114
+ children=skewt_subtab),
1115
+ dcc.Tab(label="Model Results", value="results",
1116
+ className="uf-tab", selected_className="uf-tab-sel",
1117
+ children=results_subtab),
1118
+ ]),
1119
  ])
1120
 
 
 
1121
  return html.Div(className="uf-root", children=[
1122
  html.Div(className="uf-header", children=[
1123
+ html.Div(style={"display": "flex", "alignItems": "center", "gap": "14px"}, children=[
1124
+ html.Span("🌩️", style={"fontSize": "32px", "lineHeight": "1"}),
1125
+ html.Div([
1126
+ html.H1("Updraft Forcing: A parcel-grid model",
1127
+ style={"margin": "0 0 2px 0", "fontSize": "22px"}),
1128
+ html.Div("A kinematic-diagnostic model to examine the effects of environmental wind shear and mesocyclone intensity on perturbation pressure and vertical accelerations",
1129
+ style={"color": "#9aa3ad", "fontSize": "12px"}),
1130
+ ]),
1131
+ html.Div(style={"marginLeft": "auto"}, children=[
1132
+ html.A(href="https://climas.illinois.edu/", target="_blank",
1133
+ children=html.Img(
1134
+ src="/assets/climas_logo.jpg",
1135
+ alt="UIUC CliMAS",
1136
+ style={"height": "44px", "borderRadius": "8px",
1137
+ "display": "block"})),
1138
+ ]),
1139
+ ]),
1140
  ]),
1141
+ dcc.Tabs(id="page-tabs", value="inputs", className="uf-page-tabs", children=[
1142
+ dcc.Tab(label="Inputs", value="inputs", className="uf-ptab", selected_className="uf-ptab-sel",
1143
+ children=inputs_tab),
1144
+ dcc.Tab(label="Outputs", value="outputs", className="uf-ptab", selected_className="uf-ptab-sel",
1145
+ children=outputs_tab),
1146
  dcc.Tab(label="Getting Started", value="help", className="uf-ptab", selected_className="uf-ptab-sel",
1147
  children=_getting_started_content()),
1148
  dcc.Tab(label="Theory", value="theory", className="uf-ptab", selected_className="uf-ptab-sel",
 
1203
  return no_update
1204
  u = list(store["u"]); v = list(store["v"]); changed = False
1205
  for i in range(len(u)):
1206
+ # Plotly emits xanchor/yanchor for pixel-mode shapes; after conversion
1207
+ # it may emit x0/x1/y0/y1 in data coords — handle both.
1208
+ if f"shapes[{i}].xanchor" in relay:
1209
+ try:
1210
+ u[i] = max(-60.0, min(60.0, float(relay[f"shapes[{i}].xanchor"]))); changed = True
1211
+ except (TypeError, ValueError): pass
1212
+ elif f"shapes[{i}].x0" in relay and f"shapes[{i}].x1" in relay:
1213
+ try:
1214
+ u[i] = max(-60.0, min(60.0, (float(relay[f"shapes[{i}].x0"]) + float(relay[f"shapes[{i}].x1"])) / 2)); changed = True
1215
+ except (TypeError, ValueError): pass
1216
+
1217
+ if f"shapes[{i}].yanchor" in relay:
1218
+ try:
1219
+ v[i] = max(-60.0, min(60.0, float(relay[f"shapes[{i}].yanchor"]))); changed = True
1220
+ except (TypeError, ValueError): pass
1221
+ elif f"shapes[{i}].y0" in relay and f"shapes[{i}].y1" in relay:
1222
+ try:
1223
+ v[i] = max(-60.0, min(60.0, (float(relay[f"shapes[{i}].y0"]) + float(relay[f"shapes[{i}].y1"])) / 2)); changed = True
1224
+ except (TypeError, ValueError): pass
1225
  return {"u": u, "v": v} if changed else no_update
1226
 
1227
  @app.callback(
 
1235
  return no_update
1236
  zeta = list(store["zeta"]); changed = False
1237
  for i in range(len(zeta)):
1238
+ if f"shapes[{i}].xanchor" in relay:
1239
+ try:
1240
+ zeta[i] = max(-0.10, min(0.10, float(relay[f"shapes[{i}].xanchor"]))); changed = True
1241
+ except (TypeError, ValueError): pass
1242
+ elif f"shapes[{i}].x0" in relay and f"shapes[{i}].x1" in relay:
1243
  try:
1244
+ zeta[i] = max(-0.10, min(0.10, (float(relay[f"shapes[{i}].x0"]) + float(relay[f"shapes[{i}].x1"])) / 2)); changed = True
1245
+ except (TypeError, ValueError): pass
 
1246
  return {"zeta": zeta} if changed else no_update
1247
 
1248
  @app.callback(
 
1256
  lm_u=d.get("lm_u"), lm_v=d.get("lm_v"),
1257
  mean_u=d.get("mean_u"), mean_v=d.get("mean_v"))
1258
 
1259
+ @app.callback(
1260
+ Output("zeta-profile", "figure"),
1261
+ Input("zeta-store", "data"),
1262
+ )
1263
+ def _redraw_zeta(store):
1264
+ return _profile_editor_fig("ζ(z) (s⁻¹)", store["zeta"], ZETA_Z_KM, "s⁻¹", (-0.05, 0.05))
1265
+
1266
  @app.callback(
1267
  Output("model-rev", "data"),
1268
  [Input("snd-theta-ml", "value"), Input("snd-qv-ml", "value"),
 
1390
  ]
1391
  return html.Tbody(rows)
1392
 
1393
+ @app.callback(
1394
+ Output("skewt-graph", "figure"),
1395
+ Input("model-rev", "data"),
1396
+ )
1397
+ def _skewt_cb(_rev):
1398
+ return _skewt_figure()
1399
+
1400
  @app.callback(
1401
  [Output("buoy-profile", "figure"), Output("accel-profile", "figure")],
1402
  Input("model-rev", "data"),
 
1446
  .uf-center{ background: #11161f; border-radius: 8px; padding: 12px; }
1447
  .uf-right { background: #11161f; border-radius: 8px; padding: 12px; }
1448
 
1449
+ .uf-section-title { font-size: 11px; font-weight: 600; color: #8d97a2; letter-spacing: 0.05em; margin-bottom: 8px; margin-top: 4px; }
1450
  .uf-slider-row { margin-bottom: 10px; }
1451
  .uf-slider-header { display: flex; justify-content: space-between; align-items: center; font-size: 12px; margin-bottom: 2px; }
1452
  .uf-slider-label { color: #c7ced6; }
 
1516
  .assume-check { color: #5ac87a; font-size: 13px; padding: 5px 10px 5px 6px; width: 18px; vertical-align: top; }
1517
  .assume-text { color: #dfe3ea; font-weight: 600; padding: 5px 12px 5px 4px; width: 310px; vertical-align: top; }
1518
  .assume-note { color: #8d97a2; padding: 5px 4px; line-height: 1.5; vertical-align: top; }
1519
+
1520
+ /* New 4-tab layout grids */
1521
+ .inp-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; padding: 4px 0; }
1522
+ .inp-col { background: #11161f; border-radius: 8px; padding: 12px; }
1523
+ .skewt-layout { display: grid; grid-template-columns: 1fr 260px; gap: 16px; padding: 4px 0; }
1524
+ .skewt-side { background: #11161f; border-radius: 8px; padding: 12px; overflow-y: auto; }
1525
+ .results-layout { display: grid; grid-template-columns: 1fr 280px; gap: 16px; padding: 4px 0; }
1526
+ .results-center { background: #11161f; border-radius: 8px; padding: 12px; }
1527
+ .results-side { background: #11161f; border-radius: 8px; padding: 12px; }
1528
+
1529
+ /* Field-select dropdown — force black text inside the React-Select widget */
1530
+ #field-select .Select-value-label,
1531
+ #field-select .Select-placeholder,
1532
+ #field-select .Select-option,
1533
+ #field-select input { color: #111 !important; }
1534
+ #field-select .VirtualizedSelectOption { color: #111 !important; }
1535
  """
1536
 
1537
 
1538
  def create_app() -> Dash:
1539
+ import os
1540
+ _assets = os.path.join(os.path.dirname(__file__), "assets")
1541
+ app = Dash(__name__, suppress_callback_exceptions=True, assets_folder=_assets)
1542
+ app.title = "Updraft Forcing: A parcel-grid model"
1543
  app.layout = _build_layout()
1544
  app.index_string = f"""<!doctype html><html><head>
1545
+ {{%metas%}}<title>{{%title%}}</title>
1546
+ <link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
1547
+ {{%css%}}
1548
  <style>{_CSS}</style>
1549
  </head><body>{{%app_entry%}}<footer>{{%config%}}{{%scripts%}}{{%renderer%}}</footer></body></html>"""
1550
  _register_callbacks(app)
python/updraft_forcing/assets/climas_logo.jpg ADDED
python/updraft_forcing/assets/favicon.svg ADDED
python/updraft_forcing/colortables.py CHANGED
@@ -7,17 +7,17 @@ FIELD_META: dict[str, tuple[str, bool, str, str]] = {
7
  "w": ("RdBu_r", True, "Vertical velocity", "m s⁻¹"),
8
  "u": ("PuOr", False, "U-wind (zonal)", "m s⁻¹"),
9
  "v": ("PuOr", False, "V-wind (meridional)", "m s⁻¹"),
10
- "zeta": ("PRGn", True, "Vertical vorticity ζ_z", "s⁻¹"),
11
- "p_total": ("PuOr", True, "p' total", "Pa"),
12
- "p_lin": ("PuOr", True, "p' linear (shear)", "Pa"),
13
  "p_spin": ("RdBu_r", True, "p' nonlinear (spin)", "Pa"),
14
- "p_splat": ("BrBG", True, "p' nonlinear (splat)", "Pa"),
15
  "p_buoy": ("RdBu_r", True, "p' buoyancy", "Pa"),
16
- "a_lin": ("PuOr", True, "Accel. linear", "m s⁻²"),
17
  "a_spin": ("RdBu_r", True, "Accel. spin", "m s⁻²"),
18
- "a_splat": ("BrBG", True, "Accel. splat", "m s⁻²"),
19
  "a_buoy": ("RdBu_r", True, "Accel. buoyancy", "m s⁻²"),
20
- "a_total": ("PuOr", True, "Total dynamic accel.", "m s⁻²"),
21
  }
22
 
23
  # Friendly display names for the dropdown
 
7
  "w": ("RdBu_r", True, "Vertical velocity", "m s⁻¹"),
8
  "u": ("PuOr", False, "U-wind (zonal)", "m s⁻¹"),
9
  "v": ("PuOr", False, "V-wind (meridional)", "m s⁻¹"),
10
+ "zeta": ("RdBu_r", True, "Vertical vorticity ζ_z", "s⁻¹"),
11
+ "p_total": ("RdBu_r", True, "p' total", "Pa"),
12
+ "p_lin": ("RdBu_r", True, "p' linear (shear)", "Pa"),
13
  "p_spin": ("RdBu_r", True, "p' nonlinear (spin)", "Pa"),
14
+ "p_splat": ("RdBu_r", True, "p' nonlinear (splat)", "Pa"),
15
  "p_buoy": ("RdBu_r", True, "p' buoyancy", "Pa"),
16
+ "a_lin": ("RdBu_r", True, "Accel. linear", "m s⁻²"),
17
  "a_spin": ("RdBu_r", True, "Accel. spin", "m s⁻²"),
18
+ "a_splat": ("RdBu_r", True, "Accel. splat", "m s⁻²"),
19
  "a_buoy": ("RdBu_r", True, "Accel. buoyancy", "m s⁻²"),
20
+ "a_total": ("RdBu_r", True, "Total dynamic accel.", "m s⁻²"),
21
  }
22
 
23
  # Friendly display names for the dropdown