Skip to content

Commit e4a69c6

Browse files
feat(makie): implement skewt-logp-atmospheric (#7615)
## Implementation: `skewt-logp-atmospheric` - julia/makie Implements the **julia/makie** version of `skewt-logp-atmospheric`. **File:** `plots/skewt-logp-atmospheric/implementations/julia/makie.jl` **Parent Issue:** #3802 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/anyplot/actions/runs/26312661516)* --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent 56c04ed commit e4a69c6

2 files changed

Lines changed: 576 additions & 0 deletions

File tree

Lines changed: 323 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,323 @@
1+
# anyplot.ai
2+
# skewt-logp-atmospheric: Skew-T Log-P Atmospheric Diagram
3+
# Library: makie 0.22.10 | Julia 1.11.9
4+
# Quality: 85/100 | Created: 2026-05-22
5+
6+
using CairoMakie
7+
using Colors
8+
using Random
9+
10+
Random.seed!(42)
11+
12+
const THEME = get(ENV, "ANYPLOT_THEME", "light")
13+
const PAGE_BG = THEME == "light" ? colorant"#FAF8F1" : colorant"#1A1A17"
14+
const ELEVATED_BG = THEME == "light" ? colorant"#FFFDF6" : colorant"#242420"
15+
const INK = THEME == "light" ? colorant"#1A1A17" : colorant"#F0EFE8"
16+
const INK_SOFT = THEME == "light" ? colorant"#4A4A44" : colorant"#B8B7B0"
17+
const OKABE_ITO = [
18+
colorant"#009E73",
19+
colorant"#D55E00",
20+
colorant"#0072B2",
21+
colorant"#CC79A7",
22+
colorant"#E69F00",
23+
colorant"#56B4E9",
24+
colorant"#F0E442",
25+
]
26+
27+
# Skew-T parameters and thermodynamic constants
28+
const SKEW = 45.0
29+
const Lv_CONST = 2.501e6
30+
const Rd_CONST = 287.0
31+
const Rv_CONST = 461.5
32+
const Cp_CONST = 1005.0
33+
34+
# Coordinate helpers
35+
skew_x(T_C, P_hPa) = T_C + SKEW * log10(1000.0 / P_hPa)
36+
y_p(P_hPa) = log10(P_hPa)
37+
38+
# Saturation vapor pressure via Bolton (1980), hPa
39+
sat_es(T_C) = 6.112 * exp(17.67 * T_C / (T_C + 243.5))
40+
41+
# Saturation mixing ratio, g/kg
42+
ws_sat(T_C, P_hPa) = 622.0 * sat_es(T_C) / (P_hPa - sat_es(T_C))
43+
44+
# Log-spaced pressure array from 1000 → 100 hPa
45+
const P_FINE = collect(exp10.(LinRange(log10(1000.0), log10(100.0), 300)))
46+
47+
# Tropical convective sounding (standard atmosphere with instability)
48+
const P_OBS = [1000.0, 950.0, 925.0, 900.0, 850.0, 800.0, 750.0, 700.0,
49+
650.0, 600.0, 550.0, 500.0, 450.0, 400.0, 350.0, 300.0,
50+
250.0, 200.0, 150.0, 100.0]
51+
52+
const T_OBS = [28.4, 25.6, 23.8, 21.6, 17.2, 12.4, 8.0, 3.2,
53+
-1.8, -7.2, -12.8, -19.2, -25.6, -33.2, -41.6, -50.4,
54+
-59.2, -65.8, -68.4, -72.6]
55+
56+
const TD_OBS = [24.8, 21.4, 19.6, 16.8, 10.4, 5.2, -0.8, -8.4,
57+
-15.2, -22.6, -30.2, -38.4, -46.8, -52.6, -58.8, -63.2,
58+
-68.4, -72.0, -74.2, -77.6]
59+
60+
# Reference line helpers
61+
62+
function isotherm_line(T0, p_arr)
63+
return [skew_x(T0, P) for P in p_arr], y_p.(p_arr)
64+
end
65+
66+
function dry_adiabat_line(theta_K, p_arr)
67+
Rcp = Rd_CONST / Cp_CONST
68+
return [skew_x(theta_K * (P / 1000.0)^Rcp - 273.15, P) for P in p_arr], y_p.(p_arr)
69+
end
70+
71+
function mixing_ratio_line(ws_gkg, p_arr)
72+
xs = map(p_arr) do P
73+
es_t = ws_gkg * P / (622.0 + ws_gkg)
74+
es_t <= 0.0 && return NaN
75+
a = log(es_t / 6.112)
76+
skew_x(243.5 * a / (17.67 - a), P)
77+
end
78+
return xs, y_p.(p_arr)
79+
end
80+
81+
function moist_adiabat_line(T0_C, P0_hPa, p_arr)
82+
xs = Float64[]
83+
ys = Float64[]
84+
T_K = T0_C + 273.15
85+
for i in 1:length(p_arr)
86+
P = p_arr[i]
87+
P > P0_hPa + 0.5 && continue
88+
push!(xs, skew_x(T_K - 273.15, P))
89+
push!(ys, y_p(P))
90+
if i < length(p_arr)
91+
ws = max(0.0, ws_sat(T_K - 273.15, P) / 1000.0)
92+
dT_dp = (Rd_CONST * T_K + Lv_CONST * ws) /
93+
(P * (Cp_CONST + Lv_CONST^2 * ws / (Rv_CONST * T_K^2)))
94+
T_K = T_K + dT_dp * (p_arr[i + 1] - P)
95+
end
96+
end
97+
return xs, ys
98+
end
99+
100+
# Compute lifted parcel temperatures (Bolton 1980) and return LCL metadata.
101+
# p_arr must be sorted descending (1000 → 100 hPa).
102+
function lifted_parcel_temps(T_surf_C, Td_surf_C, P_surf_hPa, p_arr)
103+
T_K = T_surf_C + 273.15
104+
Td_K = Td_surf_C + 273.15
105+
Rcp = Rd_CONST / Cp_CONST
106+
107+
# LCL temperature and pressure (Bolton 1980)
108+
T_LCL_K = 56.0 + 1.0 / (1.0 / (Td_K - 56.0) + log(T_K / Td_K) / 800.0)
109+
P_LCL = P_surf_hPa * (T_LCL_K / T_K)^(Cp_CONST / Rd_CONST)
110+
111+
T_moist = T_LCL_K
112+
prev_P = P_LCL
113+
T_out = Float64[]
114+
115+
for P in p_arr
116+
if P >= P_LCL
117+
# Dry adiabatic lifting
118+
push!(T_out, T_K * (P / P_surf_hPa)^Rcp - 273.15)
119+
else
120+
# Moist adiabatic lifting — one Euler step from prev level
121+
ws = max(0.0, ws_sat(T_moist - 273.15, prev_P) / 1000.0)
122+
dT_dp = (Rd_CONST * T_moist + Lv_CONST * ws) /
123+
(prev_P * (Cp_CONST + Lv_CONST^2 * ws / (Rv_CONST * T_moist^2)))
124+
T_moist = T_moist + dT_dp * (P - prev_P)
125+
prev_P = P
126+
push!(T_out, T_moist - 273.15)
127+
end
128+
end
129+
return T_out, T_LCL_K - 273.15, P_LCL
130+
end
131+
132+
# Parcel path at both resolutions
133+
parcel_Ts_obs, T_LCL_C, P_LCL_hPa = lifted_parcel_temps(
134+
T_OBS[1], TD_OBS[1], P_OBS[1], P_OBS)
135+
parcel_Ts_fine, _, _ = lifted_parcel_temps(
136+
T_OBS[1], TD_OBS[1], P_OBS[1], P_FINE)
137+
138+
# Figure
139+
fig = Figure(
140+
size = (1600, 900),
141+
fontsize = 14,
142+
backgroundcolor = PAGE_BG,
143+
)
144+
145+
ax = Axis(
146+
fig[1, 1];
147+
title = "skewt-logp-atmospheric · julia · makie · anyplot.ai",
148+
titlesize = 20,
149+
titlecolor = INK,
150+
xlabel = "Temperature (°C)",
151+
ylabel = "Pressure (hPa)",
152+
xlabelsize = 14,
153+
ylabelsize = 14,
154+
xticklabelsize = 12,
155+
yticklabelsize = 12,
156+
xlabelcolor = INK,
157+
ylabelcolor = INK,
158+
xticklabelcolor = INK_SOFT,
159+
yticklabelcolor = INK_SOFT,
160+
xtickcolor = INK_SOFT,
161+
ytickcolor = INK_SOFT,
162+
backgroundcolor = PAGE_BG,
163+
leftspinecolor = INK_SOFT,
164+
bottomspinecolor = INK_SOFT,
165+
topspinevisible = false,
166+
rightspinevisible = false,
167+
xgridvisible = false,
168+
ygridvisible = false,
169+
yreversed = true,
170+
)
171+
172+
# Y-axis: log10(P) with 1000 hPa at bottom, 100 hPa at top
173+
ylims!(ax, y_p(100.0) - 0.02, y_p(1000.0) + 0.02)
174+
const P_YTICKS = [1000.0, 925.0, 850.0, 700.0, 500.0, 400.0, 300.0, 200.0, 100.0]
175+
ax.yticks = (y_p.(P_YTICKS), string.(Int.(P_YTICKS)))
176+
177+
# X-axis: skewed temperature coordinate, ticks at surface temps
178+
xlims!(ax, -47.0, 88.0)
179+
const T_XTICKS = Float64[-40, -30, -20, -10, 0, 10, 20, 30, 40]
180+
ax.xticks = (T_XTICKS, string.(Int.(T_XTICKS)) .* "°")
181+
182+
# Background isobars
183+
isobar_col = RGBAf(INK.r, INK.g, INK.b, 0.08f0)
184+
for P in [950.0, 925.0, 850.0, 800.0, 750.0, 700.0, 650.0, 600.0, 550.0,
185+
500.0, 450.0, 400.0, 350.0, 300.0, 250.0, 200.0, 150.0]
186+
hlines!(ax, y_p(P); color = isobar_col, linewidth = 0.6)
187+
end
188+
189+
# Isotherms (every 10 °C)
190+
iso_col = THEME == "light" ?
191+
RGBAf(0.55f0, 0.55f0, 0.55f0, 0.28f0) :
192+
RGBAf(0.62f0, 0.62f0, 0.62f0, 0.22f0)
193+
194+
for T0 in -60.0:10.0:60.0
195+
xs, ys = isotherm_line(T0, P_FINE)
196+
lines!(ax, xs, ys; color = iso_col, linewidth = 0.7)
197+
x_bot = skew_x(T0, 1000.0)
198+
if -47.0 <= x_bot <= 88.0
199+
text!(ax, x_bot, y_p(1000.0) - 0.014;
200+
text = "$(Int(T0))°",
201+
fontsize = 9,
202+
color = INK_SOFT,
203+
align = (:center, :top))
204+
end
205+
end
206+
207+
# Dry adiabats (potential temperature from -40 to 80 °C, every 10 °C)
208+
dry_col = THEME == "light" ?
209+
RGBAf(0.84f0, 0.37f0, 0.0f0, 0.40f0) :
210+
RGBAf(0.90f0, 0.55f0, 0.20f0, 0.33f0)
211+
212+
for (i, theta_C) in enumerate(-40.0:10.0:80.0)
213+
xs, ys = dry_adiabat_line(theta_C + 273.15, P_FINE)
214+
lbl = i == 1 ? "Dry adiabat" : nothing
215+
if isnothing(lbl)
216+
lines!(ax, xs, ys; color = dry_col, linewidth = 0.8, linestyle = :dash)
217+
else
218+
lines!(ax, xs, ys; color = dry_col, linewidth = 0.8, linestyle = :dash, label = lbl)
219+
end
220+
end
221+
222+
# Moist adiabats (surface start temps -5 to 35 °C, every 5 °C)
223+
moist_col = THEME == "light" ?
224+
RGBAf(0.0f0, 0.447f0, 0.698f0, 0.42f0) :
225+
RGBAf(0.25f0, 0.62f0, 0.87f0, 0.35f0)
226+
227+
for (i, T_start) in enumerate(-5.0:5.0:35.0)
228+
xs, ys = moist_adiabat_line(T_start, 1000.0, P_FINE)
229+
lbl = i == 1 ? "Moist adiabat" : nothing
230+
if isnothing(lbl)
231+
lines!(ax, xs, ys; color = moist_col, linewidth = 0.8, linestyle = :dashdot)
232+
else
233+
lines!(ax, xs, ys; color = moist_col, linewidth = 0.8, linestyle = :dashdot, label = lbl)
234+
end
235+
end
236+
237+
# Mixing ratio lines (g/kg), displayed only below 600 hPa
238+
p_low = P_FINE[P_FINE .>= 599.0]
239+
mix_col = THEME == "light" ?
240+
RGBAf(0.0f0, 0.62f0, 0.45f0, 0.48f0) :
241+
RGBAf(0.0f0, 0.75f0, 0.55f0, 0.40f0)
242+
243+
for (i, ws_gkg) in enumerate([2.0, 4.0, 8.0, 12.0, 20.0])
244+
xs, ys = mixing_ratio_line(ws_gkg, p_low)
245+
valid = .!isnan.(xs) .& isfinite.(xs)
246+
lbl = i == 1 ? "Mixing ratio" : nothing
247+
if any(valid)
248+
if isnothing(lbl)
249+
lines!(ax, xs[valid], ys[valid]; color = mix_col, linewidth = 0.7, linestyle = :dot)
250+
else
251+
lines!(ax, xs[valid], ys[valid]; color = mix_col, linewidth = 0.7, linestyle = :dot, label = lbl)
252+
end
253+
# Label at top of visible section (600 hPa)
254+
x_top = xs[valid][end]
255+
y_top = ys[valid][end]
256+
if -47.0 <= x_top <= 88.0
257+
text!(ax, x_top, y_top;
258+
text = "$(Int(ws_gkg))",
259+
fontsize = 8,
260+
color = mix_col,
261+
align = (:center, :bottom))
262+
end
263+
end
264+
end
265+
266+
# CAPE region — poly! fills the closed polygon between parcel and environment.
267+
# This uses Makie's native polygon primitive for efficient filled-area rendering.
268+
cape_mask = parcel_Ts_obs .> T_OBS
269+
if any(cape_mask)
270+
ci = findall(cape_mask)
271+
env_xs_c = [skew_x(T_OBS[i], P_OBS[i]) for i in ci]
272+
par_xs_c = [skew_x(parcel_Ts_obs[i], P_OBS[i]) for i in ci]
273+
ys_c = y_p.(P_OBS[ci])
274+
cape_pts = Point2f.(
275+
vcat(par_xs_c, reverse(env_xs_c)),
276+
vcat(ys_c, reverse(ys_c)),
277+
)
278+
cape_fill = RGBAf(OKABE_ITO[5].r, OKABE_ITO[5].g, OKABE_ITO[5].b, 0.22f0)
279+
poly!(ax, cape_pts; color = cape_fill, strokewidth = 0, label = "CAPE")
280+
end
281+
282+
# Lifted parcel trace (smooth, fine resolution)
283+
parcel_xs_fine = [skew_x(T, P) for (T, P) in zip(parcel_Ts_fine, P_FINE)]
284+
lines!(ax, parcel_xs_fine, y_p.(P_FINE);
285+
color = OKABE_ITO[4], linewidth = 2.0, linestyle = :dashdotdot,
286+
label = "Lifted parcel")
287+
288+
# LCL marker
289+
lcl_x = skew_x(T_LCL_C, P_LCL_hPa)
290+
lcl_y = y_p(P_LCL_hPa)
291+
scatter!(ax, [lcl_x], [lcl_y];
292+
color = OKABE_ITO[4], markersize = 10, marker = :diamond, strokewidth = 0)
293+
text!(ax, lcl_x + 1.5, lcl_y;
294+
text = "LCL",
295+
fontsize = 9,
296+
color = OKABE_ITO[4],
297+
align = (:left, :center))
298+
299+
# Sounding profiles
300+
T_xs = [skew_x(T, P) for (T, P) in zip(T_OBS, P_OBS)]
301+
TD_xs = [skew_x(Td, P) for (Td, P) in zip(TD_OBS, P_OBS)]
302+
obs_ys = y_p.(P_OBS)
303+
304+
lines!(ax, T_xs, obs_ys;
305+
color = OKABE_ITO[1], linewidth = 3.5, label = "Temperature")
306+
scatter!(ax, T_xs, obs_ys;
307+
color = OKABE_ITO[1], markersize = 7, strokewidth = 0)
308+
309+
lines!(ax, TD_xs, obs_ys;
310+
color = OKABE_ITO[2], linewidth = 3.5, linestyle = :dash, label = "Dewpoint")
311+
scatter!(ax, TD_xs, obs_ys;
312+
color = OKABE_ITO[2], markersize = 7, strokewidth = 0)
313+
314+
axislegend(ax;
315+
position = :rt,
316+
backgroundcolor = ELEVATED_BG,
317+
labelcolor = INK,
318+
framecolor = INK_SOFT,
319+
framewidth = 0.8,
320+
labelsize = 12,
321+
)
322+
323+
save("plot-$(THEME).png", fig; px_per_unit = 2)

0 commit comments

Comments
 (0)