climplot 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
climplot/metrics.py ADDED
@@ -0,0 +1,404 @@
1
+ """
2
+ Area-weighted statistical metrics for climate data.
3
+
4
+ This module provides functions for calculating area-weighted statistics
5
+ that are essential for accurate global and regional analyses of gridded
6
+ climate data.
7
+
8
+ Why Area Weighting Matters
9
+ --------------------------
10
+ Ocean/atmosphere grid cells near the poles are much smaller than equatorial
11
+ cells. Simple averaging treats all cells equally, over-weighting high
12
+ latitudes. This can produce errors of ~1 cm in global sea level means
13
+ or ~2 cm in RMSE calculations.
14
+
15
+ Examples
16
+ --------
17
+ >>> import climplot
18
+ >>> # Area-weighted global mean
19
+ >>> gmsl = climplot.area_weighted_mean(ssh, areacello, dim=['yh', 'xh'])
20
+
21
+ >>> # Area-weighted RMSE
22
+ >>> rmse = climplot.area_weighted_rmse(model, obs, areacello, dim=['yh', 'xh'])
23
+ """
24
+
25
+ import numpy as np
26
+ import xarray as xr
27
+ from typing import Union, List, Optional
28
+
29
+
30
+ def area_weighted_mean(
31
+ data: xr.DataArray,
32
+ weights: xr.DataArray,
33
+ dim: Optional[Union[str, List[str]]] = None,
34
+ ) -> Union[xr.DataArray, float]:
35
+ """
36
+ Calculate area-weighted mean.
37
+
38
+ Parameters
39
+ ----------
40
+ data : DataArray
41
+ Data to average
42
+ weights : DataArray
43
+ Area weights (e.g., areacello grid cell areas)
44
+ dim : str or list of str, optional
45
+ Dimensions to average over. If None, averages over all.
46
+ Common: dim=['yh', 'xh'] for spatial mean.
47
+
48
+ Returns
49
+ -------
50
+ DataArray or float
51
+ Area-weighted mean
52
+
53
+ Examples
54
+ --------
55
+ >>> # Global mean SSH
56
+ >>> gmsl = climplot.area_weighted_mean(ssh, areacello, dim=['yh', 'xh'])
57
+
58
+ >>> # Time series of global mean
59
+ >>> gmsl_ts = climplot.area_weighted_mean(ssh_3d, areacello, dim=['yh', 'xh'])
60
+
61
+ Notes
62
+ -----
63
+ - NaN values are automatically excluded
64
+ - Weights are normalized internally
65
+ """
66
+ # Mask where data or weights are NaN
67
+ valid_mask = ~(np.isnan(data) | np.isnan(weights))
68
+
69
+ data_masked = data.where(valid_mask)
70
+ weights_masked = weights.where(valid_mask)
71
+
72
+ weighted_sum = (data_masked * weights_masked).sum(dim=dim, skipna=True)
73
+ total_weight = weights_masked.sum(dim=dim, skipna=True)
74
+
75
+ return weighted_sum / total_weight
76
+
77
+
78
+ def area_weighted_bias(
79
+ model: xr.DataArray,
80
+ obs: xr.DataArray,
81
+ weights: xr.DataArray,
82
+ dim: Optional[Union[str, List[str]]] = None,
83
+ ) -> Union[xr.DataArray, float]:
84
+ """
85
+ Calculate area-weighted mean bias (model - obs).
86
+
87
+ Parameters
88
+ ----------
89
+ model : DataArray
90
+ Model data
91
+ obs : DataArray
92
+ Observational data
93
+ weights : DataArray
94
+ Area weights
95
+ dim : str or list of str, optional
96
+ Dimensions to average over
97
+
98
+ Returns
99
+ -------
100
+ DataArray or float
101
+ Mean bias (positive = model too high)
102
+
103
+ Examples
104
+ --------
105
+ >>> bias = climplot.area_weighted_bias(ssh_model, ssh_obs, areacello, dim=['yh', 'xh'])
106
+ """
107
+ diff = model - obs
108
+ return area_weighted_mean(diff, weights, dim=dim)
109
+
110
+
111
+ def area_weighted_rmse(
112
+ model: xr.DataArray,
113
+ obs: xr.DataArray,
114
+ weights: xr.DataArray,
115
+ dim: Optional[Union[str, List[str]]] = None,
116
+ ) -> Union[xr.DataArray, float]:
117
+ """
118
+ Calculate area-weighted root-mean-square error.
119
+
120
+ Parameters
121
+ ----------
122
+ model : DataArray
123
+ Model data
124
+ obs : DataArray
125
+ Observational data
126
+ weights : DataArray
127
+ Area weights
128
+ dim : str or list of str, optional
129
+ Dimensions to average over
130
+
131
+ Returns
132
+ -------
133
+ DataArray or float
134
+ RMSE (always positive)
135
+
136
+ Examples
137
+ --------
138
+ >>> rmse = climplot.area_weighted_rmse(ssh_model, ssh_obs, areacello, dim=['yh', 'xh'])
139
+ """
140
+ squared_diff = (model - obs) ** 2
141
+ mse = area_weighted_mean(squared_diff, weights, dim=dim)
142
+ return np.sqrt(mse)
143
+
144
+
145
+ def area_weighted_std(
146
+ data: xr.DataArray,
147
+ weights: xr.DataArray,
148
+ dim: Optional[Union[str, List[str]]] = None,
149
+ ) -> Union[xr.DataArray, float]:
150
+ """
151
+ Calculate area-weighted standard deviation.
152
+
153
+ Parameters
154
+ ----------
155
+ data : DataArray
156
+ Data to compute std for
157
+ weights : DataArray
158
+ Area weights
159
+ dim : str or list of str, optional
160
+ Dimensions to compute over
161
+
162
+ Returns
163
+ -------
164
+ DataArray or float
165
+ Standard deviation
166
+
167
+ Examples
168
+ --------
169
+ >>> spatial_std = climplot.area_weighted_std(ssh, areacello, dim=['yh', 'xh'])
170
+ """
171
+ mean = area_weighted_mean(data, weights, dim=dim)
172
+ squared_dev = (data - mean) ** 2
173
+ variance = area_weighted_mean(squared_dev, weights, dim=dim)
174
+ return np.sqrt(variance)
175
+
176
+
177
+ def area_weighted_corr(
178
+ x: xr.DataArray,
179
+ y: xr.DataArray,
180
+ weights: xr.DataArray,
181
+ dim: Optional[Union[str, List[str]]] = None,
182
+ ) -> Union[xr.DataArray, float]:
183
+ """
184
+ Calculate area-weighted pattern correlation coefficient.
185
+
186
+ Parameters
187
+ ----------
188
+ x : DataArray
189
+ First field (e.g., model)
190
+ y : DataArray
191
+ Second field (e.g., observations)
192
+ weights : DataArray
193
+ Area weights
194
+ dim : str or list of str, optional
195
+ Dimensions to compute over
196
+
197
+ Returns
198
+ -------
199
+ DataArray or float
200
+ Correlation coefficient (-1 to 1)
201
+
202
+ Examples
203
+ --------
204
+ >>> r = climplot.area_weighted_corr(ssh_model, ssh_obs, areacello, dim=['yh', 'xh'])
205
+ """
206
+ x_mean = area_weighted_mean(x, weights, dim=dim)
207
+ y_mean = area_weighted_mean(y, weights, dim=dim)
208
+
209
+ x_anom = x - x_mean
210
+ y_anom = y - y_mean
211
+
212
+ covariance = area_weighted_mean(x_anom * y_anom, weights, dim=dim)
213
+ x_var = area_weighted_mean(x_anom**2, weights, dim=dim)
214
+ y_var = area_weighted_mean(y_anom**2, weights, dim=dim)
215
+
216
+ return covariance / np.sqrt(x_var * y_var)
217
+
218
+
219
+ # ============================================================================
220
+ # Time Series Metrics (1D arrays, no area weighting)
221
+ # ============================================================================
222
+
223
+
224
+ def timeseries_bias(
225
+ model: Union[np.ndarray, xr.DataArray],
226
+ obs: Union[np.ndarray, xr.DataArray],
227
+ ) -> float:
228
+ """
229
+ Calculate mean bias for time series.
230
+
231
+ Parameters
232
+ ----------
233
+ model : array-like
234
+ Model time series
235
+ obs : array-like
236
+ Observed time series
237
+
238
+ Returns
239
+ -------
240
+ float
241
+ Mean bias (positive = model too high)
242
+
243
+ Examples
244
+ --------
245
+ >>> bias = climplot.timeseries_bias(gmsl_model, gmsl_obs)
246
+ """
247
+ diff = np.array(model) - np.array(obs)
248
+ return float(np.nanmean(diff))
249
+
250
+
251
+ def timeseries_rmse(
252
+ model: Union[np.ndarray, xr.DataArray],
253
+ obs: Union[np.ndarray, xr.DataArray],
254
+ ) -> float:
255
+ """
256
+ Calculate RMSE for time series.
257
+
258
+ Parameters
259
+ ----------
260
+ model : array-like
261
+ Model time series
262
+ obs : array-like
263
+ Observed time series
264
+
265
+ Returns
266
+ -------
267
+ float
268
+ RMSE (always positive)
269
+
270
+ Examples
271
+ --------
272
+ >>> rmse = climplot.timeseries_rmse(gmsl_model, gmsl_obs)
273
+ """
274
+ diff = np.array(model) - np.array(obs)
275
+ return float(np.sqrt(np.nanmean(diff**2)))
276
+
277
+
278
+ def timeseries_corr(
279
+ x: Union[np.ndarray, xr.DataArray],
280
+ y: Union[np.ndarray, xr.DataArray],
281
+ ) -> float:
282
+ """
283
+ Calculate correlation coefficient for time series.
284
+
285
+ Parameters
286
+ ----------
287
+ x : array-like
288
+ First time series
289
+ y : array-like
290
+ Second time series
291
+
292
+ Returns
293
+ -------
294
+ float
295
+ Correlation coefficient (-1 to 1)
296
+
297
+ Examples
298
+ --------
299
+ >>> r = climplot.timeseries_corr(gmsl_model, gmsl_obs)
300
+ """
301
+ x_arr = np.array(x)
302
+ y_arr = np.array(y)
303
+
304
+ valid = ~(np.isnan(x_arr) | np.isnan(y_arr))
305
+ x_valid = x_arr[valid]
306
+ y_valid = y_arr[valid]
307
+
308
+ return float(np.corrcoef(x_valid, y_valid)[0, 1])
309
+
310
+
311
+ def timeseries_std(data: Union[np.ndarray, xr.DataArray]) -> float:
312
+ """
313
+ Calculate standard deviation for time series.
314
+
315
+ Parameters
316
+ ----------
317
+ data : array-like
318
+ Time series data
319
+
320
+ Returns
321
+ -------
322
+ float
323
+ Standard deviation
324
+
325
+ Examples
326
+ --------
327
+ >>> std = climplot.timeseries_std(gmsl)
328
+ """
329
+ return float(np.nanstd(data))
330
+
331
+
332
+ # ============================================================================
333
+ # Summary Functions
334
+ # ============================================================================
335
+
336
+
337
+ def metrics_summary(
338
+ model: xr.DataArray,
339
+ obs: xr.DataArray,
340
+ weights: xr.DataArray,
341
+ dim: Optional[Union[str, List[str]]] = None,
342
+ name: str = "Model",
343
+ ) -> dict:
344
+ """
345
+ Calculate comprehensive validation metrics.
346
+
347
+ Parameters
348
+ ----------
349
+ model : DataArray
350
+ Model data
351
+ obs : DataArray
352
+ Observational data
353
+ weights : DataArray
354
+ Area weights
355
+ dim : str or list of str, optional
356
+ Dimensions to compute over
357
+ name : str, optional
358
+ Model name for output
359
+
360
+ Returns
361
+ -------
362
+ dict
363
+ Dictionary with bias, rmse, correlation, means, and stds
364
+
365
+ Examples
366
+ --------
367
+ >>> metrics = climplot.metrics_summary(ssh_model, ssh_obs, areacello, dim=['yh', 'xh'])
368
+ >>> print(f"RMSE: {metrics['rmse']:.3f}")
369
+ """
370
+ return {
371
+ "bias": float(area_weighted_bias(model, obs, weights, dim=dim)),
372
+ "rmse": float(area_weighted_rmse(model, obs, weights, dim=dim)),
373
+ "correlation": float(area_weighted_corr(model, obs, weights, dim=dim)),
374
+ "model_mean": float(area_weighted_mean(model, weights, dim=dim)),
375
+ "obs_mean": float(area_weighted_mean(obs, weights, dim=dim)),
376
+ "model_std": float(area_weighted_std(model, weights, dim=dim)),
377
+ "obs_std": float(area_weighted_std(obs, weights, dim=dim)),
378
+ }
379
+
380
+
381
+ def print_metrics_summary(metrics: dict, name: str = "Model"):
382
+ """
383
+ Print formatted metrics summary.
384
+
385
+ Parameters
386
+ ----------
387
+ metrics : dict
388
+ Dictionary from metrics_summary()
389
+ name : str
390
+ Model name for display
391
+
392
+ Examples
393
+ --------
394
+ >>> metrics = climplot.metrics_summary(model, obs, weights, dim=['yh', 'xh'])
395
+ >>> climplot.print_metrics_summary(metrics, name='OM5 b01')
396
+ """
397
+ print(f"\n{name} Validation Metrics:")
398
+ print(f" Bias (model - obs): {metrics['bias']:+.4f}")
399
+ print(f" RMSE: {metrics['rmse']:.4f}")
400
+ print(f" Correlation: {metrics['correlation']:.4f}")
401
+ print(f" Model mean: {metrics['model_mean']:.4f}")
402
+ print(f" Obs mean: {metrics['obs_mean']:.4f}")
403
+ print(f" Model std: {metrics['model_std']:.4f}")
404
+ print(f" Obs std: {metrics['obs_std']:.4f}")
climplot/panels.py ADDED
@@ -0,0 +1,295 @@
1
+ """
2
+ Multi-panel figure utilities.
3
+
4
+ This module provides functions for creating and labeling multi-panel
5
+ figures with consistent styling and colorbars.
6
+
7
+ Examples
8
+ --------
9
+ >>> import climplot
10
+ >>> climplot.publication()
11
+ >>> fig, axes = climplot.panel_figure(2, 3) # 2 rows, 3 columns
12
+ """
13
+
14
+ import matplotlib.pyplot as plt
15
+ from pathlib import Path
16
+ from typing import Tuple, Optional, List, Union
17
+ import numpy as np
18
+
19
+
20
+ def panel_figure(
21
+ nrows: int,
22
+ ncols: int,
23
+ figsize: Optional[Tuple[float, float]] = None,
24
+ projection=None,
25
+ constrained_layout: bool = True,
26
+ **kwargs,
27
+ ) -> Tuple[plt.Figure, np.ndarray]:
28
+ """
29
+ Create a multi-panel figure.
30
+
31
+ Parameters
32
+ ----------
33
+ nrows : int
34
+ Number of rows
35
+ ncols : int
36
+ Number of columns
37
+ figsize : tuple, optional
38
+ Figure size. If None, auto-calculated based on panels.
39
+ projection : cartopy.crs.Projection, optional
40
+ Map projection for all panels. If None, creates regular axes.
41
+ constrained_layout : bool, optional
42
+ Use constrained layout for better spacing. Default is True.
43
+ **kwargs
44
+ Additional arguments passed to plt.subplots()
45
+
46
+ Returns
47
+ -------
48
+ fig : Figure
49
+ The figure object
50
+ axes : ndarray
51
+ Array of axes objects (shape: nrows x ncols)
52
+
53
+ Examples
54
+ --------
55
+ >>> # 2x3 panel figure
56
+ >>> fig, axes = climplot.panel_figure(2, 3)
57
+ >>> for ax in axes.flat:
58
+ ... ax.plot([1, 2, 3], [1, 4, 9])
59
+
60
+ >>> # Panel figure with map projections
61
+ >>> import cartopy.crs as ccrs
62
+ >>> fig, axes = climplot.panel_figure(2, 2, projection=ccrs.Robinson())
63
+ """
64
+ if figsize is None:
65
+ # Auto-calculate size
66
+ panel_width = 3.5 if projection is None else 3.0
67
+ panel_height = 2.5 if projection is None else 2.0
68
+ figsize = (ncols * panel_width, nrows * panel_height)
69
+
70
+ # Handle map projections
71
+ if projection is not None:
72
+ kwargs["subplot_kw"] = {"projection": projection}
73
+
74
+ fig, axes = plt.subplots(
75
+ nrows, ncols, figsize=figsize, constrained_layout=constrained_layout, **kwargs
76
+ )
77
+
78
+ # Ensure axes is always 2D array for consistency
79
+ if nrows == 1 and ncols == 1:
80
+ axes = np.array([[axes]])
81
+ elif nrows == 1:
82
+ axes = axes.reshape(1, -1)
83
+ elif ncols == 1:
84
+ axes = axes.reshape(-1, 1)
85
+
86
+ return fig, axes
87
+
88
+
89
+ def add_panel_labels(
90
+ axes: Union[np.ndarray, List[plt.Axes]],
91
+ labels: Optional[List[str]] = None,
92
+ fontsize: int = 10,
93
+ fontweight: str = "bold",
94
+ x: float = 0.02,
95
+ y: float = 0.98,
96
+ ha: str = "left",
97
+ va: str = "top",
98
+ ):
99
+ """
100
+ Add panel labels (a. b. c. d.) to multi-panel figures.
101
+
102
+ Parameters
103
+ ----------
104
+ axes : array-like
105
+ List or array of axes objects
106
+ labels : list of str, optional
107
+ Custom labels. If None, uses lowercase letters with periods.
108
+ fontsize : int, optional
109
+ Font size. Default is 10.
110
+ fontweight : str, optional
111
+ Font weight. Default is 'bold'.
112
+ x, y : float, optional
113
+ Position in axes coordinates. Default is (0.02, 0.98).
114
+ ha, va : str, optional
115
+ Horizontal/vertical alignment. Default is 'left', 'top'.
116
+
117
+ Examples
118
+ --------
119
+ >>> fig, axes = climplot.panel_figure(2, 2)
120
+ >>> climplot.add_panel_labels(axes.flatten())
121
+ """
122
+ # Flatten if needed
123
+ if hasattr(axes, "flatten"):
124
+ axes = axes.flatten()
125
+
126
+ if labels is None:
127
+ labels = [f"{chr(97 + i)}." for i in range(len(axes))]
128
+
129
+ for ax, label in zip(axes, labels):
130
+ ax.text(
131
+ x,
132
+ y,
133
+ label,
134
+ transform=ax.transAxes,
135
+ fontsize=fontsize,
136
+ fontweight=fontweight,
137
+ va=va,
138
+ ha=ha,
139
+ )
140
+
141
+
142
+ def add_colorbar(
143
+ mappable,
144
+ ax: plt.Axes,
145
+ label: str,
146
+ orientation: str = "horizontal",
147
+ extend: str = "both",
148
+ **kwargs,
149
+ ) -> plt.colorbar:
150
+ """
151
+ Add a colorbar with consistent formatting.
152
+
153
+ Parameters
154
+ ----------
155
+ mappable : ScalarMappable
156
+ The plot object (e.g., from pcolormesh)
157
+ ax : Axes
158
+ The axes to attach colorbar to
159
+ label : str
160
+ Colorbar label with units
161
+ orientation : str, optional
162
+ 'horizontal' or 'vertical'. Default is 'horizontal'.
163
+ extend : str, optional
164
+ Out-of-range handling. Default is 'both'.
165
+ **kwargs
166
+ Additional arguments passed to plt.colorbar()
167
+
168
+ Returns
169
+ -------
170
+ Colorbar
171
+ The colorbar object
172
+
173
+ Examples
174
+ --------
175
+ >>> cs = ax.pcolormesh(lon, lat, data)
176
+ >>> cbar = climplot.add_colorbar(cs, ax, 'Temperature (K)')
177
+ """
178
+ if orientation == "horizontal":
179
+ cbar_kwargs = {"orientation": "horizontal", "pad": 0.05, "fraction": 0.046}
180
+ else:
181
+ cbar_kwargs = {"orientation": "vertical", "pad": 0.05, "fraction": 0.046}
182
+
183
+ cbar_kwargs.update(kwargs)
184
+ cbar = plt.colorbar(mappable, ax=ax, extend=extend, **cbar_kwargs)
185
+ cbar.set_label(label, fontsize=8)
186
+ cbar.ax.tick_params(labelsize=8)
187
+
188
+ return cbar
189
+
190
+
191
+ def bottom_colorbar(
192
+ mappable,
193
+ fig: plt.Figure,
194
+ axes: np.ndarray,
195
+ label: str,
196
+ extend: str = "both",
197
+ **kwargs,
198
+ ) -> plt.colorbar:
199
+ """
200
+ Add a single colorbar below all panels.
201
+
202
+ Parameters
203
+ ----------
204
+ mappable : ScalarMappable
205
+ The plot object (e.g., from pcolormesh)
206
+ fig : Figure
207
+ The figure object
208
+ axes : ndarray
209
+ Array of all axes
210
+ label : str
211
+ Colorbar label with units
212
+ extend : str, optional
213
+ Out-of-range handling. Default is 'both'.
214
+ **kwargs
215
+ Additional arguments passed to fig.colorbar()
216
+
217
+ Returns
218
+ -------
219
+ Colorbar
220
+ The colorbar object
221
+
222
+ Examples
223
+ --------
224
+ >>> fig, axes = climplot.panel_figure(2, 2)
225
+ >>> for ax in axes.flat:
226
+ ... cs = ax.pcolormesh(lon, lat, data)
227
+ >>> cbar = climplot.bottom_colorbar(cs, fig, axes, 'SSH (m)')
228
+ """
229
+ # Flatten axes
230
+ if hasattr(axes, "flatten"):
231
+ axes_flat = axes.flatten()
232
+ else:
233
+ axes_flat = axes
234
+
235
+ cbar = fig.colorbar(
236
+ mappable,
237
+ ax=list(axes_flat),
238
+ orientation="horizontal",
239
+ pad=0.05,
240
+ fraction=0.03,
241
+ extend=extend,
242
+ **kwargs,
243
+ )
244
+ cbar.set_label(label, fontsize=8)
245
+ cbar.ax.tick_params(labelsize=8)
246
+
247
+ return cbar
248
+
249
+
250
+ def save_figure(
251
+ filename: str,
252
+ output_dir: str = "figures",
253
+ dpi: int = 300,
254
+ **kwargs,
255
+ ) -> Path:
256
+ """
257
+ Save figure with consistent settings.
258
+
259
+ Parameters
260
+ ----------
261
+ filename : str
262
+ Filename (should include extension)
263
+ output_dir : str or Path, optional
264
+ Directory to save to. Default is 'figures'.
265
+ dpi : int, optional
266
+ Resolution. Default is 300.
267
+ **kwargs
268
+ Additional arguments passed to plt.savefig()
269
+
270
+ Returns
271
+ -------
272
+ Path
273
+ Full path where figure was saved
274
+
275
+ Examples
276
+ --------
277
+ >>> fig, ax = plt.subplots()
278
+ >>> ax.plot([1, 2, 3], [1, 4, 9])
279
+ >>> climplot.save_figure('test_plot.png')
280
+ """
281
+ output_dir = Path(output_dir)
282
+ output_dir.mkdir(exist_ok=True, parents=True)
283
+
284
+ save_path = output_dir / filename
285
+
286
+ save_kwargs = {
287
+ "dpi": dpi,
288
+ "bbox_inches": "tight",
289
+ "facecolor": "white",
290
+ }
291
+ save_kwargs.update(kwargs)
292
+
293
+ plt.savefig(save_path, **save_kwargs)
294
+
295
+ return save_path