|
1 | | -""" pyplots.ai |
| 1 | +""" anyplot.ai |
2 | 2 | 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 |
5 | 5 | """ |
6 | 6 |
|
| 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 | + |
7 | 15 | import numpy as np |
8 | 16 | import pygal |
9 | 17 | from pygal.style import Style |
10 | 18 |
|
11 | 19 |
|
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 |
13 | 26 |
|
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) |
17 | 30 |
|
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]) |
19 | 34 | temperature = np.array([25, 20, 15, 5, -15, -28, -45, -52, -58, -62, -55]) |
20 | | - |
21 | | -# Dewpoint profile (always <= temperature, converges in clouds) |
22 | 35 | dewpoint = np.array([18, 15, 12, -2, -22, -38, -55, -60, -65, -70, -65]) |
23 | 36 |
|
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 |
26 | 37 | log_p = np.log10(1000.0 / pressure) |
27 | | - |
28 | | -# Apply skew transformation to temperature (45 degree isotherms) |
29 | 38 | skew_factor = 35 |
30 | 39 | temp_skewed = temperature + skew_factor * log_p |
31 | 40 | dewpoint_skewed = dewpoint + skew_factor * log_p |
32 | 41 |
|
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 |
34 | 66 | 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, |
54 | 79 | ) |
55 | 80 |
|
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] |
60 | 84 |
|
61 | | -# Create XY chart with explicit margin for legend visibility |
| 85 | +# Plot |
62 | 86 | chart = pygal.XY( |
63 | | - width=4800, |
64 | | - height=3000, |
| 87 | + width=3200, |
| 88 | + height=1800, |
65 | 89 | style=custom_style, |
66 | | - title="skewt-logp-atmospheric · pygal · pyplots.ai", |
| 90 | + title="skewt-logp-atmospheric · python · pygal · anyplot.ai", |
67 | 91 | x_title="Temperature (°C, skewed 45°)", |
68 | 92 | y_title="Pressure (hPa)", |
69 | 93 | show_dots=True, |
70 | | - dots_size=8, |
71 | | - stroke_style={"width": 5}, |
| 94 | + dots_size=6, |
| 95 | + stroke_style={"width": 4}, |
72 | 96 | show_x_guides=True, |
73 | 97 | show_y_guides=True, |
74 | | - x_label_rotation=0, |
75 | 98 | show_legend=True, |
76 | 99 | legend_at_bottom=True, |
77 | | - legend_at_bottom_columns=4, |
78 | | - legend_box_size=28, |
79 | | - margin_bottom=180, |
80 | 100 | 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, |
83 | 103 | truncate_legend=-1, |
| 104 | + legend_box_size=28, |
| 105 | + margin_bottom=220, |
84 | 106 | ) |
85 | 107 |
|
86 | | -# Add temperature profile (solid red line with markers) |
| 108 | +# Temperature profile (green #009E73 — series 1, primary data) |
87 | 109 | 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}) |
89 | 111 |
|
90 | | -# Add dewpoint profile (blue dashed line with markers) |
| 112 | +# Dewpoint profile (vermillion #D55E00 — series 2) |
91 | 113 | 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"}) |
93 | 115 |
|
94 | | -# Add ONE dry adiabat (θ=300K) - green curved line |
| 116 | +# Dry adiabat θ=300K (blue #0072B2 — series 3) |
95 | 117 | theta = 300 |
96 | 118 | dry_adiabat_points = [] |
97 | | -for p in np.linspace(1000, 100, 25): |
| 119 | +for p in np.linspace(1000, 100, 30): |
98 | 120 | 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))) |
103 | 125 | chart.add("Dry Adiabat θ=300K", dry_adiabat_points, show_dots=False, stroke_style={"width": 3, "dasharray": "6,4"}) |
104 | 126 |
|
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) |
106 | 130 | 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): |
109 | 132 | 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))) |
114 | 136 | chart.add("Moist Adiabat", moist_points, show_dots=False, stroke_style={"width": 3, "dasharray": "10,5"}) |
115 | 137 |
|
116 | | -# Add ONE mixing ratio line (r=10 g/kg) - orange nearly vertical line |
| 138 | +# Mixing ratio r=10g/kg (orange #E69F00 — series 5) |
117 | 139 | mr_points = [] |
118 | 140 | mr = 10 |
119 | | -for p in np.linspace(1000, 300, 15): |
| 141 | +for p in np.linspace(1000, 300, 20): |
120 | 142 | lp = np.log10(1000.0 / p) |
121 | 143 | e = mr * p / (622 + mr) |
122 | 144 | if e > 0: |
123 | 145 | td = (243.5 * np.log(e / 6.112)) / (17.67 - np.log(e / 6.112)) |
124 | 146 | td_skewed = td + skew_factor * lp |
125 | | - if -50 <= td_skewed <= 75: |
| 147 | + if -50 <= td_skewed <= 40: |
126 | 148 | mr_points.append((float(td_skewed), float(lp))) |
127 | 149 | chart.add("Mixing Ratio r=10g/kg", mr_points, show_dots=False, stroke_style={"width": 2, "dasharray": "4,6"}) |
128 | 150 |
|
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