Skip to content

Commit cd6c71e

Browse files
feat(plotly): implement skewt-logp-atmospheric (#7563)
## Implementation: `skewt-logp-atmospheric` - python/plotly Implements the **python/plotly** version of `skewt-logp-atmospheric`. **File:** `plots/skewt-logp-atmospheric/implementations/python/plotly.py` **Parent Issue:** #3802 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/anyplot/actions/runs/26196176895)* --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com>
1 parent 3a1b1a7 commit cd6c71e

2 files changed

Lines changed: 587 additions & 0 deletions

File tree

Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
1+
""" anyplot.ai
2+
skewt-logp-atmospheric: Skew-T Log-P Atmospheric Diagram
3+
Library: plotly 6.7.0 | Python 3.13.13
4+
Quality: 87/100 | Updated: 2026-05-21
5+
"""
6+
7+
import os
8+
9+
import numpy as np
10+
import plotly.graph_objects as go
11+
12+
13+
# Theme tokens
14+
THEME = os.getenv("ANYPLOT_THEME", "light")
15+
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
16+
ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420"
17+
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
18+
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
19+
GRID = "rgba(26,26,23,0.10)" if THEME == "light" else "rgba(240,239,232,0.10)"
20+
21+
BRAND = "#009E73" # Okabe-Ito pos 1 — temperature profile
22+
C_DEWPT = "#D55E00" # Okabe-Ito pos 2 — dewpoint profile
23+
24+
# Reference line colors: subtle, theme-adaptive
25+
C_ISO = "rgba(80,80,80,0.22)" if THEME == "light" else "rgba(180,180,180,0.28)"
26+
C_DRY = "rgba(213,94,0,0.22)" if THEME == "light" else "rgba(213,94,0,0.38)"
27+
C_MOIST = "rgba(0,114,178,0.22)" if THEME == "light" else "rgba(0,114,178,0.38)"
28+
C_MIX = "rgba(0,158,115,0.22)" if THEME == "light" else "rgba(0,158,115,0.38)"
29+
30+
# Skew-T transform: °C shift per log10 decade of pressure from 1000 hPa
31+
SKEW = 30.0
32+
33+
34+
def skew_x(T, P):
35+
return T + SKEW * np.log10(1000.0 / P)
36+
37+
38+
# Atmospheric constants
39+
Rd = 287.05 # J/(kg·K)
40+
Cp = 1004.0 # J/(kg·K)
41+
Lv = 2.5e6 # J/kg
42+
Rv = 461.5 # J/(kg·K)
43+
Rd_Cp = Rd / Cp # ≈ 0.2854
44+
45+
46+
def moist_adiabat(T0_C, P_start=1000.0, P_end=100.0, n=150):
47+
"""Integrate moist adiabat from P_start to P_end starting at T0_C."""
48+
P = np.linspace(P_start, P_end, n)
49+
T = np.zeros(n)
50+
T[0] = T0_C
51+
for i in range(1, n):
52+
T_K = T[i - 1] + 273.15
53+
es = 6.112 * np.exp(17.67 * T[i - 1] / (T[i - 1] + 243.5))
54+
rs = 0.622 * es / max(P[i - 1] - es, 0.001)
55+
num = Rd * T_K + Lv * rs
56+
den = P[i - 1] * (Cp + Lv**2 * rs / (Rv * T_K**2))
57+
T[i] = T[i - 1] + (num / den) * (P[i] - P[i - 1])
58+
return P, T
59+
60+
61+
# Data: synthetic warm-moist sounding, US Great Plains summer
62+
pressure = np.array(
63+
[1000, 975, 950, 925, 900, 850, 800, 750, 700, 650, 600, 550, 500, 450, 400, 350, 300, 250, 200, 150, 100]
64+
)
65+
temperature = np.array(
66+
[
67+
32.0,
68+
29.0,
69+
26.0,
70+
23.0,
71+
20.5,
72+
16.0,
73+
11.0,
74+
6.0,
75+
2.0,
76+
-3.5,
77+
-8.5,
78+
-15.0,
79+
-21.5,
80+
-29.0,
81+
-37.5,
82+
-48.0,
83+
-56.0,
84+
-62.0,
85+
-64.5,
86+
-68.0,
87+
-73.0,
88+
]
89+
)
90+
dewpoint = np.array(
91+
[
92+
24.0,
93+
22.0,
94+
20.5,
95+
18.0,
96+
14.5,
97+
9.0,
98+
2.5,
99+
-5.0,
100+
-13.0,
101+
-23.0,
102+
-33.0,
103+
-43.0,
104+
-53.0,
105+
-62.0,
106+
-70.0,
107+
-76.0,
108+
-81.0,
109+
-84.0,
110+
-86.0,
111+
-88.0,
112+
-90.0,
113+
]
114+
)
115+
116+
# Lifting Condensation Level (LCL) — surface parcel T=32°C, Td=24°C
117+
# At DALR=9.8°C/km and dewpoint cooling ~1.8°C/km, they meet at z≈1 km ≈ 900 hPa
118+
LCL_P = 900.0
119+
LCL_T = 22.0
120+
121+
# Pressure array for reference lines
122+
P_ref = np.linspace(100.0, 1000.0, 200)
123+
124+
fig = go.Figure()
125+
126+
# --- Reference lines (background layer) ---
127+
128+
# Isotherms (constant temperature, appear as diagonal lines when skewed)
129+
for idx, T_iso in enumerate(range(-50, 55, 10)):
130+
fig.add_trace(
131+
go.Scatter(
132+
x=skew_x(T_iso * np.ones_like(P_ref), P_ref),
133+
y=P_ref,
134+
mode="lines",
135+
line=dict(color=C_ISO, width=0.8),
136+
legendgroup="iso",
137+
showlegend=(idx == 0),
138+
name="Isotherms",
139+
hoverinfo="skip",
140+
)
141+
)
142+
143+
# Dry adiabats (constant potential temperature θ)
144+
for idx, theta in enumerate(range(290, 430, 10)):
145+
T_dry = theta * (P_ref / 1000.0) ** Rd_Cp - 273.15
146+
mask = (T_dry > -55) & (T_dry < 55)
147+
x_d = np.where(mask, skew_x(T_dry, P_ref), np.nan)
148+
if not np.all(np.isnan(x_d)):
149+
fig.add_trace(
150+
go.Scatter(
151+
x=x_d,
152+
y=P_ref,
153+
mode="lines",
154+
line=dict(color=C_DRY, width=0.8),
155+
legendgroup="dry",
156+
showlegend=(idx == 0),
157+
name="Dry Adiabats",
158+
hoverinfo="skip",
159+
)
160+
)
161+
162+
# Moist adiabats (saturated adiabatic lapse rate, numerically integrated)
163+
for idx, T0 in enumerate(range(-10, 45, 10)):
164+
P_m, T_m = moist_adiabat(T0)
165+
fig.add_trace(
166+
go.Scatter(
167+
x=skew_x(T_m, P_m),
168+
y=P_m,
169+
mode="lines",
170+
line=dict(color=C_MOIST, width=0.8, dash="dot"),
171+
legendgroup="moist",
172+
showlegend=(idx == 0),
173+
name="Moist Adiabats",
174+
hoverinfo="skip",
175+
)
176+
)
177+
178+
# Mixing ratio lines (constant water vapor mixing ratio)
179+
for idx, r_gkg in enumerate([1, 2, 4, 8, 16]):
180+
r = r_gkg / 1000.0
181+
es = r * P_ref / (r + 0.622)
182+
with np.errstate(divide="ignore", invalid="ignore"):
183+
log_r = np.log(es / 6.112)
184+
T_mix = 243.5 * log_r / (17.67 - log_r)
185+
mask = np.isfinite(T_mix) & (T_mix > -50) & (T_mix < 35)
186+
x_mix = np.where(mask, skew_x(T_mix, P_ref), np.nan)
187+
fig.add_trace(
188+
go.Scatter(
189+
x=x_mix,
190+
y=P_ref,
191+
mode="lines",
192+
line=dict(color=C_MIX, width=0.8, dash="dash"),
193+
legendgroup="mix",
194+
showlegend=(idx == 0),
195+
name="Mixing Ratio",
196+
hoverinfo="skip",
197+
)
198+
)
199+
200+
# --- Atmospheric sounding profiles ---
201+
202+
# Dewpoint (dashed blue)
203+
fig.add_trace(
204+
go.Scatter(
205+
x=skew_x(dewpoint, pressure),
206+
y=pressure,
207+
mode="lines+markers",
208+
name="Dewpoint",
209+
line=dict(color=C_DEWPT, width=2.5, dash="dash"),
210+
marker=dict(color=C_DEWPT, size=7),
211+
customdata=np.column_stack([dewpoint, pressure]),
212+
hovertemplate="%{customdata[1]:.0f} hPa | Td: %{customdata[0]:.1f}°C<extra></extra>",
213+
)
214+
)
215+
216+
# Temperature (solid green, Okabe-Ito pos 1)
217+
fig.add_trace(
218+
go.Scatter(
219+
x=skew_x(temperature, pressure),
220+
y=pressure,
221+
mode="lines+markers",
222+
name="Temperature",
223+
line=dict(color=BRAND, width=3.0),
224+
marker=dict(color=BRAND, size=7),
225+
customdata=np.column_stack([temperature, pressure]),
226+
hovertemplate="%{customdata[1]:.0f} hPa | T: %{customdata[0]:.1f}°C<extra></extra>",
227+
)
228+
)
229+
230+
# --- Layout ---
231+
232+
# x-axis ticks: at P=1000 hPa, skew=0 so tick position = actual temperature
233+
tick_T = list(range(-50, 55, 10))
234+
235+
fig.update_layout(
236+
autosize=False,
237+
paper_bgcolor=PAGE_BG,
238+
plot_bgcolor=PAGE_BG,
239+
title=dict(
240+
text="skewt-logp-atmospheric · python · plotly · anyplot.ai",
241+
font=dict(size=16, color=INK),
242+
x=0.5,
243+
xanchor="center",
244+
),
245+
xaxis=dict(
246+
title=dict(text="Temperature (°C)", font=dict(size=12, color=INK)),
247+
tickfont=dict(size=10, color=INK_SOFT),
248+
linecolor=INK_SOFT,
249+
zeroline=False,
250+
showgrid=False,
251+
tickmode="array",
252+
tickvals=[float(t) for t in tick_T],
253+
ticktext=[f"{t}°" for t in tick_T],
254+
range=[-50.0, 75.0],
255+
),
256+
yaxis=dict(
257+
title=dict(text="Pressure (hPa)", font=dict(size=12, color=INK)),
258+
tickfont=dict(size=10, color=INK_SOFT),
259+
gridcolor=GRID,
260+
linecolor=INK_SOFT,
261+
zeroline=False,
262+
type="log",
263+
range=[3.0, 2.0], # log10(1000)=3 at bottom → 1000 hPa, log10(100)=2 at top
264+
tickmode="array",
265+
tickvals=[100, 150, 200, 250, 300, 400, 500, 600, 700, 850, 925, 1000],
266+
ticktext=["100", "150", "200", "250", "300", "400", "500", "600", "700", "850", "925", "1000"],
267+
showgrid=True,
268+
),
269+
legend=dict(
270+
bgcolor=ELEVATED_BG,
271+
bordercolor=INK_SOFT,
272+
borderwidth=1,
273+
font=dict(size=10, color=INK_SOFT),
274+
x=0.02,
275+
y=0.98,
276+
xanchor="left",
277+
yanchor="top",
278+
),
279+
margin=dict(l=80, r=40, t=80, b=60),
280+
annotations=[
281+
dict(
282+
x=skew_x(LCL_T, LCL_P),
283+
y=LCL_P,
284+
xref="x",
285+
yref="y",
286+
text="LCL ≈ 900 hPa",
287+
showarrow=True,
288+
arrowhead=2,
289+
arrowcolor=INK_SOFT,
290+
arrowwidth=1.5,
291+
ax=55,
292+
ay=-30,
293+
font=dict(size=10, color=INK),
294+
bgcolor=ELEVATED_BG,
295+
bordercolor=INK_SOFT,
296+
borderwidth=1,
297+
borderpad=4,
298+
opacity=0.9,
299+
)
300+
],
301+
)
302+
303+
# Save
304+
fig.write_image(f"plot-{THEME}.png", width=800, height=450, scale=4)
305+
fig.write_html(f"plot-{THEME}.html", include_plotlyjs="cdn")

0 commit comments

Comments
 (0)