|
| 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