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