Skip to content

Commit 992be6a

Browse files
committed
Update image in main page
1 parent f9be90b commit 992be6a

File tree

2 files changed

+258
-0
lines changed

2 files changed

+258
-0
lines changed
535 KB
Loading

tests/plot_global_spi.py

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
"""
2+
Plot Global SPI-12 Map
3+
4+
Standalone script to visualize global SPI-12 output from CHIRPS v3 data.
5+
Produces a publication-ready map with WMO 11-category classification,
6+
equal-area projection, and country boundaries.
7+
8+
Usage:
9+
python tests/plot_global_spi.py
10+
11+
Input:
12+
global/output/netcdf/wld_cli_chirps3_d3_spi_gamma_12_month.nc
13+
14+
Output:
15+
docs/images/global-spi12-202512.png
16+
"""
17+
18+
import sys
19+
from pathlib import Path
20+
21+
# Add src to path
22+
sys.path.insert(0, str(Path(__file__).parent.parent / 'src'))
23+
24+
import numpy as np
25+
import xarray as xr
26+
import matplotlib.pyplot as plt
27+
import matplotlib.colors as mcolors
28+
import matplotlib.ticker as mticker
29+
import cartopy.crs as ccrs
30+
import cartopy.feature as cfeature
31+
from cartopy.mpl.gridliner import LONGITUDE_FORMATTER, LATITUDE_FORMATTER
32+
33+
34+
# ============================================================================
35+
# Configuration
36+
# ============================================================================
37+
38+
# Input / output paths
39+
BASE_DIR = Path(__file__).parent.parent
40+
INPUT_FILE = BASE_DIR / 'global' / 'output' / 'netcdf' / 'wld_cli_chirps3_d3_spi_gamma_12_month.nc'
41+
OUTPUT_FILE = BASE_DIR / 'docs' / 'images' / 'global-spi12-202512.png'
42+
43+
# Time slice to plot
44+
TARGET_DATE = '2025-12-21'
45+
46+
# Variable name in the NetCDF
47+
VAR_NAME = 'spi_gamma_12_month'
48+
49+
# Map settings
50+
PROJECTION = ccrs.EqualEarth() # Equal-area projection
51+
DATA_CRS = ccrs.PlateCarree() # Data is in lat/lon
52+
FIGSIZE = (12, 8)
53+
DPI = 200
54+
55+
# ============================================================================
56+
# WMO 11-Category SPI Classification
57+
# ============================================================================
58+
59+
# Boundaries for 11 categories
60+
# -inf, -2.0, -1.5, -1.2, -0.7, -0.5, 0.5, 0.7, 1.2, 1.5, 2.0, +inf
61+
SPI_BOUNDS = [-3.5, -2.0, -1.5, -1.2, -0.7, -0.5, 0.5, 0.7, 1.2, 1.5, 2.0, 3.5]
62+
63+
SPI_COLORS = [
64+
'#760005', # Exceptionally dry (< -2.0)
65+
'#ec0013', # Extremely dry (-2.0 to -1.5)
66+
'#ffa938', # Severely dry (-1.5 to -1.2)
67+
'#fdd28a', # Moderately dry (-1.2 to -0.7)
68+
'#fefe53', # Abnormally dry (-0.7 to -0.5)
69+
'#ffffff', # Near normal (-0.5 to +0.5)
70+
'#a2fd6e', # Abnormally moist (+0.5 to +0.7)
71+
'#00b44a', # Moderately moist (+0.7 to +1.2)
72+
'#008180', # Very moist (+1.2 to +1.5)
73+
'#2a23eb', # Extremely moist (+1.5 to +2.0)
74+
'#a21fec', # Exceptionally moist (> +2.0)
75+
]
76+
77+
SPI_LABELS = [
78+
'Exceptionally\nDry',
79+
'Extremely\nDry',
80+
'Severely\nDry',
81+
'Moderately\nDry',
82+
'Abnormally\nDry',
83+
'Near\nNormal',
84+
'Abnormally\nMoist',
85+
'Moderately\nMoist',
86+
'Very\nMoist',
87+
'Extremely\nMoist',
88+
'Exceptionally\nMoist',
89+
]
90+
91+
92+
def build_colormap():
93+
"""Build a discrete colormap and norm for the WMO 11-category classification."""
94+
cmap = mcolors.ListedColormap(SPI_COLORS)
95+
norm = mcolors.BoundaryNorm(SPI_BOUNDS, cmap.N)
96+
return cmap, norm
97+
98+
99+
def load_data(filepath: Path, target_date: str, var_name: str) -> xr.DataArray:
100+
"""Load a single time slice from the global SPI NetCDF."""
101+
print(f"Opening: {filepath.name}")
102+
print(f" (file size: {filepath.stat().st_size / 1e9:.1f} GB)")
103+
104+
# Open dataset — only decode the time slice we need
105+
ds = xr.open_dataset(filepath)
106+
107+
# Select the target date
108+
da = ds[var_name].sel(time=target_date, method='nearest')
109+
actual_time = str(da.time.values)[:10]
110+
print(f" Selected time: {actual_time}")
111+
112+
# Load into memory (single time slice: ~65 MB for float32 2400x7200)
113+
print(f" Loading data slice ({da.shape[0]} x {da.shape[1]}) ...")
114+
da = da.load()
115+
ds.close()
116+
117+
valid = int(np.isfinite(da.values).sum())
118+
total = da.size
119+
print(f" Valid cells: {valid:,} / {total:,} ({100*valid/total:.1f}%)")
120+
121+
return da
122+
123+
124+
def plot_global_spi(da: xr.DataArray, output_path: Path):
125+
"""Create publication-ready global SPI map."""
126+
print("\nCreating map ...")
127+
128+
cmap, norm = build_colormap()
129+
130+
# Create figure — map fills most of the space, extra room at bottom for legend labels
131+
fig = plt.figure(figsize=FIGSIZE, facecolor='white')
132+
ax = fig.add_axes([0.02, 0.17, 0.96, 0.74], projection=PROJECTION)
133+
134+
# Background
135+
ax.set_global()
136+
ax.set_facecolor('#d9d9d9') # Light gray for ocean/background
137+
138+
# Plot SPI data
139+
im = ax.pcolormesh(
140+
da.lon.values, da.lat.values, da.values,
141+
transform=DATA_CRS,
142+
cmap=cmap,
143+
norm=norm,
144+
shading='auto',
145+
rasterized=True, # Keeps file size reasonable
146+
)
147+
148+
# Country boundaries (50m resolution for better detail)
149+
ax.add_feature(
150+
cfeature.NaturalEarthFeature(
151+
'cultural', 'admin_0_boundary_lines_land', '50m',
152+
edgecolor='#404040', facecolor='none',
153+
),
154+
linewidth=0.3,
155+
alpha=0.7,
156+
)
157+
158+
# Coastlines (50m resolution)
159+
ax.add_feature(
160+
cfeature.NaturalEarthFeature(
161+
'physical', 'coastline', '50m',
162+
edgecolor='#202020', facecolor='none',
163+
),
164+
linewidth=0.4,
165+
)
166+
167+
# Gridlines
168+
gl = ax.gridlines(
169+
draw_labels=False,
170+
linewidth=0.3,
171+
color='gray',
172+
alpha=0.4,
173+
linestyle='--',
174+
)
175+
gl.xlocator = mticker.FixedLocator(range(-180, 181, 30))
176+
gl.ylocator = mticker.FixedLocator(range(-60, 81, 30))
177+
178+
# Title and subtitle with clear spacing
179+
date_label = f"{da.time.dt.strftime('%B %Y').values}"
180+
fig.text(
181+
0.5, 0.97, 'Standardized Precipitation Index (Gamma), 12-month',
182+
ha='center', va='top',
183+
fontsize=14, fontweight='bold',
184+
)
185+
fig.text(
186+
0.5, 0.93, f'as of {date_label}',
187+
ha='center', va='top',
188+
fontsize=11, color='#404040',
189+
)
190+
191+
# Colorbar — horizontal below the map, ticks at class boundaries
192+
cbar_left = 0.12
193+
cbar_width = 0.76
194+
cbar_ax = fig.add_axes([cbar_left, 0.08, cbar_width, 0.018])
195+
cbar = fig.colorbar(
196+
im, cax=cbar_ax, orientation='horizontal',
197+
extend='neither',
198+
ticks=[-2.0, -1.5, -1.2, -0.7, -0.5, 0.5, 0.7, 1.2, 1.5, 2.0],
199+
)
200+
cbar.ax.set_xticklabels(
201+
['-2.0', '-1.5', '-1.2', '-0.7', '-0.5', '0.5', '0.7', '1.2', '1.5', '2.0'],
202+
fontsize=7,
203+
)
204+
cbar.ax.set_xlabel(
205+
'Standardized Precipitation Index (Gamma), 12-month',
206+
fontsize=9, labelpad=6,
207+
)
208+
209+
# Category labels above each color segment — use data coordinates on cbar axis
210+
# BoundaryNorm maps N colors to N+1 boundaries, colorbar x-axis matches data values
211+
label_fontsize = 5.5
212+
213+
for i, label in enumerate(SPI_LABELS):
214+
# Midpoint in SPI data space — the colorbar x-axis IS in data space
215+
mid = (SPI_BOUNDS[i] + SPI_BOUNDS[i + 1]) / 2.0
216+
cbar.ax.text(mid, 1.3, label, transform=cbar.ax.get_xaxis_transform(),
217+
ha='center', va='bottom',
218+
fontsize=label_fontsize, color='#404040')
219+
220+
# Attribution text
221+
fig.text(0.02, 0.02, 'Data: CHIRPS v3', fontsize=8, color='#606060',
222+
ha='left', va='bottom')
223+
fig.text(0.98, 0.02, 'GOST/DEC Data Group/WBG', fontsize=8, color='#606060',
224+
ha='right', va='bottom')
225+
226+
# Save — use pad_inches to control whitespace
227+
plt.savefig(output_path, dpi=DPI, bbox_inches='tight', pad_inches=0.1,
228+
facecolor='white', edgecolor='none')
229+
plt.close()
230+
print(f"Saved: {output_path}")
231+
print(f" File size: {output_path.stat().st_size / 1e6:.1f} MB")
232+
233+
234+
def main():
235+
"""Main entry point."""
236+
print("=" * 60)
237+
print(" GLOBAL SPI-12 MAP — DECEMBER 2025")
238+
print("=" * 60)
239+
240+
# Check input exists
241+
if not INPUT_FILE.exists():
242+
print(f"ERROR: Input file not found: {INPUT_FILE}")
243+
sys.exit(1)
244+
245+
# Ensure output directory exists
246+
OUTPUT_FILE.parent.mkdir(parents=True, exist_ok=True)
247+
248+
# Load data
249+
da = load_data(INPUT_FILE, TARGET_DATE, VAR_NAME)
250+
251+
# Plot
252+
plot_global_spi(da, OUTPUT_FILE)
253+
254+
print("\nDone!")
255+
256+
257+
if __name__ == '__main__':
258+
main()

0 commit comments

Comments
 (0)