Skip to content

Commit ca83d5d

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

2 files changed

Lines changed: 279 additions & 206 deletions

File tree

Lines changed: 112 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,140 +1,170 @@
1-
""" pyplots.ai
1+
""" anyplot.ai
22
skewt-logp-atmospheric: Skew-T Log-P Atmospheric Diagram
3-
Library: pygal 3.1.0 | Python 3.13.11
4-
Quality: 68/100 | Created: 2026-01-17
3+
Library: pygal 3.1.0 | Python 3.13.13
4+
Quality: 80/100 | Updated: 2026-05-21
55
"""
66

7+
import os
8+
import sys
9+
10+
11+
# Running as pygal.py: remove script dir from sys.path so 'import pygal' finds the package
12+
_this_dir = os.path.dirname(os.path.abspath(__file__))
13+
sys.path = [p for p in sys.path if os.path.abspath(p or os.getcwd()) != _this_dir]
14+
715
import numpy as np
816
import pygal
917
from pygal.style import Style
1018

1119

12-
np.random.seed(42)
20+
# Theme tokens
21+
THEME = os.getenv("ANYPLOT_THEME", "light")
22+
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
23+
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
24+
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"
25+
NEUTRAL = "#1A1A1A" if THEME == "light" else "#E8E8E0" # position-8 adaptive neutral
1326

14-
# Generate realistic atmospheric sounding data
15-
# Pressure levels from surface (1000 hPa) to upper troposphere (100 hPa)
16-
pressure = np.array([1000, 925, 850, 700, 500, 400, 300, 250, 200, 150, 100])
27+
# Position 7 (yellow #F0E442) is prohibited for thin lines on light surfaces;
28+
# replace with NEUTRAL for the -20°C isotherm reference line
29+
OKABE_ITO = ("#009E73", "#D55E00", "#0072B2", "#CC79A7", "#E69F00", "#56B4E9", NEUTRAL)
1730

18-
# Temperature profile (typical mid-latitude sounding, decreasing with altitude)
31+
# Data — realistic mid-latitude atmospheric sounding
32+
np.random.seed(42)
33+
pressure = np.array([1000, 925, 850, 700, 500, 400, 300, 250, 200, 150, 100])
1934
temperature = np.array([25, 20, 15, 5, -15, -28, -45, -52, -58, -62, -55])
20-
21-
# Dewpoint profile (always <= temperature, converges in clouds)
2235
dewpoint = np.array([18, 15, 12, -2, -22, -38, -55, -60, -65, -70, -65])
2336

24-
# For Skew-T Log-P: Y-axis uses log of pressure
25-
# log_p will be 0 at 1000 hPa and positive going up to lower pressures
2637
log_p = np.log10(1000.0 / pressure)
27-
28-
# Apply skew transformation to temperature (45 degree isotherms)
2938
skew_factor = 35
3039
temp_skewed = temperature + skew_factor * log_p
3140
dewpoint_skewed = dewpoint + skew_factor * log_p
3241

33-
# Create custom style with LARGER fonts for 4800x2700 canvas
42+
# Physical constants for moist adiabatic lapse rate
43+
LV = 2.501e6 # Latent heat of vaporization (J/kg)
44+
RD = 287.05 # Gas constant for dry air (J/kg/K)
45+
RV = 461.5 # Gas constant for water vapor (J/kg/K)
46+
CP = 1004.0 # Specific heat of dry air (J/kg/K)
47+
G = 9.81 # Gravity (m/s^2)
48+
49+
50+
def moist_adiabat(T0_C, pressures):
51+
"""Integrate proper moist adiabatic lapse rate (MALR) along pressure levels."""
52+
temps = [T0_C]
53+
T = T0_C + 273.15
54+
for i in range(len(pressures) - 1):
55+
p = pressures[i]
56+
es = 6.112 * np.exp(17.67 * (T - 273.15) / ((T - 273.15) + 243.5))
57+
ws = 0.622 * es / (p - es) # saturation mixing ratio kg/kg (p, es both hPa)
58+
malr = G * (1 + LV * ws / (RD * T)) / (CP + LV**2 * ws / (RV * T**2)) # K/m
59+
# dT/dp_hPa = MALR * Rd*T / (g*p_hPa) — hPa units cancel via hydrostatic
60+
T += malr * RD * T / (G * p) * (pressures[i + 1] - p)
61+
temps.append(T - 273.15)
62+
return np.array(temps)
63+
64+
65+
# Style
3466
custom_style = Style(
35-
background="white",
36-
plot_background="#f8f9fa",
37-
foreground="#2c3e50",
38-
foreground_strong="#1a252f",
39-
foreground_subtle="#5d6d7e",
40-
colors=(
41-
"#c0392b", # Temperature - dark red
42-
"#2471a3", # Dewpoint - blue
43-
"#229954", # Dry adiabat - green
44-
"#7d3c98", # Moist adiabat - purple
45-
"#d35400", # Mixing ratio - orange
46-
"#95a5a6", # Isotherms - gray
47-
),
48-
title_font_size=72,
49-
label_font_size=44,
50-
major_label_font_size=40,
51-
legend_font_size=40,
52-
value_font_size=32,
53-
stroke_width=4,
67+
background=PAGE_BG,
68+
plot_background=PAGE_BG,
69+
foreground=INK,
70+
foreground_strong=INK,
71+
foreground_subtle=INK_MUTED,
72+
colors=OKABE_ITO,
73+
title_font_size=66,
74+
label_font_size=56,
75+
major_label_font_size=44,
76+
legend_font_size=44,
77+
value_font_size=36,
78+
stroke_width=2.5,
5479
)
5580

56-
# Create mapping from log_p values to pressure labels
57-
# These values correspond to standard pressure levels
58-
log_p_values = [0, 0.033, 0.07, 0.155, 0.301, 0.398, 0.523, 0.602, 0.699, 0.824, 1.0]
59-
p_labels = ["1000", "925", "850", "700", "500", "400", "300", "250", "200", "150", "100"]
81+
# Reduced Y-axis label set — skip 925/400/250/150 to avoid crowding at log-compressed bottom
82+
key_pressures = [1000, 850, 700, 500, 300, 200, 100]
83+
y_labels = [{"value": float(np.log10(1000.0 / p)), "label": str(p)} for p in key_pressures]
6084

61-
# Create XY chart with explicit margin for legend visibility
85+
# Plot
6286
chart = pygal.XY(
63-
width=4800,
64-
height=3000,
87+
width=3200,
88+
height=1800,
6589
style=custom_style,
66-
title="skewt-logp-atmospheric · pygal · pyplots.ai",
90+
title="skewt-logp-atmospheric · python · pygal · anyplot.ai",
6791
x_title="Temperature (°C, skewed 45°)",
6892
y_title="Pressure (hPa)",
6993
show_dots=True,
70-
dots_size=8,
71-
stroke_style={"width": 5},
94+
dots_size=6,
95+
stroke_style={"width": 4},
7296
show_x_guides=True,
7397
show_y_guides=True,
74-
x_label_rotation=0,
7598
show_legend=True,
7699
legend_at_bottom=True,
77-
legend_at_bottom_columns=4,
78-
legend_box_size=28,
79-
margin_bottom=180,
80100
range=(0, 1.02),
81-
xrange=(-50, 75),
82-
y_labels=[{"value": v, "label": lbl} for v, lbl in zip(log_p_values, p_labels, strict=False)],
101+
xrange=(-50, 40), # narrowed from 75 — reference lines reach at most x≈35
102+
y_labels=y_labels,
83103
truncate_legend=-1,
104+
legend_box_size=28,
105+
margin_bottom=220,
84106
)
85107

86-
# Add temperature profile (solid red line with markers)
108+
# Temperature profile (green #009E73 — series 1, primary data)
87109
temp_points = [(float(temp_skewed[i]), float(log_p[i])) for i in range(len(pressure))]
88-
chart.add("Temperature", temp_points, stroke_style={"width": 6})
110+
chart.add("Temperature", temp_points, stroke_style={"width": 7})
89111

90-
# Add dewpoint profile (blue dashed line with markers)
112+
# Dewpoint profile (vermillion #D55E00 — series 2)
91113
dewpoint_points = [(float(dewpoint_skewed[i]), float(log_p[i])) for i in range(len(pressure))]
92-
chart.add("Dewpoint", dewpoint_points, stroke_style={"width": 5, "dasharray": "15,8"})
114+
chart.add("Dewpoint", dewpoint_points, stroke_style={"width": 6, "dasharray": "15,8"})
93115

94-
# Add ONE dry adiabat (θ=300K) - green curved line
116+
# Dry adiabat θ=300K (blue #0072B2 — series 3)
95117
theta = 300
96118
dry_adiabat_points = []
97-
for p in np.linspace(1000, 100, 25):
119+
for p in np.linspace(1000, 100, 30):
98120
lp = np.log10(1000.0 / p)
99-
T_adiabat = theta * (p / 1000.0) ** 0.286 - 273.15
100-
T_skewed = T_adiabat + skew_factor * lp
101-
if -50 <= T_skewed <= 75:
102-
dry_adiabat_points.append((float(T_skewed), float(lp)))
121+
t_adiabat = theta * (p / 1000.0) ** 0.286 - 273.15
122+
t_skewed = t_adiabat + skew_factor * lp
123+
if -50 <= t_skewed <= 40:
124+
dry_adiabat_points.append((float(t_skewed), float(lp)))
103125
chart.add("Dry Adiabat θ=300K", dry_adiabat_points, show_dots=False, stroke_style={"width": 3, "dasharray": "6,4"})
104126

105-
# Add ONE moist adiabat (starting at 20°C) - purple curved line
127+
# Moist adiabat (reddish purple #CC79A7 — series 4, proper MALR integration)
128+
moist_pressures = np.linspace(1000, 150, 25)
129+
moist_temps = moist_adiabat(20.0, moist_pressures)
106130
moist_points = []
107-
t_current = 20.0
108-
for p in np.linspace(1000, 150, 20):
131+
for T_c, p in zip(moist_temps, moist_pressures, strict=False):
109132
lp = np.log10(1000.0 / p)
110-
T_skewed = t_current + skew_factor * lp
111-
if -50 <= T_skewed <= 75:
112-
moist_points.append((float(T_skewed), float(lp)))
113-
t_current -= 4.0
133+
t_skewed = T_c + skew_factor * lp
134+
if -50 <= t_skewed <= 40:
135+
moist_points.append((float(t_skewed), float(lp)))
114136
chart.add("Moist Adiabat", moist_points, show_dots=False, stroke_style={"width": 3, "dasharray": "10,5"})
115137

116-
# Add ONE mixing ratio line (r=10 g/kg) - orange nearly vertical line
138+
# Mixing ratio r=10g/kg (orange #E69F00 — series 5)
117139
mr_points = []
118140
mr = 10
119-
for p in np.linspace(1000, 300, 15):
141+
for p in np.linspace(1000, 300, 20):
120142
lp = np.log10(1000.0 / p)
121143
e = mr * p / (622 + mr)
122144
if e > 0:
123145
td = (243.5 * np.log(e / 6.112)) / (17.67 - np.log(e / 6.112))
124146
td_skewed = td + skew_factor * lp
125-
if -50 <= td_skewed <= 75:
147+
if -50 <= td_skewed <= 40:
126148
mr_points.append((float(td_skewed), float(lp)))
127149
chart.add("Mixing Ratio r=10g/kg", mr_points, show_dots=False, stroke_style={"width": 2, "dasharray": "4,6"})
128150

129-
# Add isotherms (0°C and -20°C) - gray diagonal lines showing the skew
130-
for isotherm in [0, -20]:
131-
isotherm_points = []
132-
for lp in np.linspace(0, 1.0, 15):
133-
T_skewed = isotherm + skew_factor * lp
134-
if -50 <= T_skewed <= 75:
135-
isotherm_points.append((float(T_skewed), float(lp)))
136-
chart.add(f"{isotherm}°C Isotherm", isotherm_points, show_dots=False, stroke_style={"width": 2, "dasharray": "8,4"})
137-
138-
# Render to PNG and HTML
139-
chart.render_to_png("plot.png")
140-
chart.render_to_file("plot.html")
151+
# 0°C Isotherm (sky blue #56B4E9 — series 6)
152+
iso_0_points = []
153+
for lp in np.linspace(0, 1.0, 20):
154+
t_skewed = 0 + skew_factor * lp
155+
if -50 <= t_skewed <= 40:
156+
iso_0_points.append((float(t_skewed), float(lp)))
157+
chart.add("0°C Isotherm", iso_0_points, show_dots=False, stroke_style={"width": 2, "dasharray": "8,4"})
158+
159+
# -20°C Isotherm (NEUTRAL — series 7, replaces yellow which has insufficient contrast on light bg)
160+
iso_m20_points = []
161+
for lp in np.linspace(0, 1.0, 20):
162+
t_skewed = -20 + skew_factor * lp
163+
if -50 <= t_skewed <= 40:
164+
iso_m20_points.append((float(t_skewed), float(lp)))
165+
chart.add("-20°C Isotherm", iso_m20_points, show_dots=False, stroke_style={"width": 2, "dasharray": "8,4"})
166+
167+
# Save
168+
chart.render_to_png(f"plot-{THEME}.png")
169+
with open(f"plot-{THEME}.html", "wb") as f:
170+
f.write(chart.render())

0 commit comments

Comments
 (0)