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/__init__.py +118 -0
- climplot/colormaps.py +254 -0
- climplot/data/presentation.mplstyle +37 -0
- climplot/data/publication.mplstyle +37 -0
- climplot/maps.py +230 -0
- climplot/metrics.py +404 -0
- climplot/panels.py +295 -0
- climplot/style.py +201 -0
- climplot/timeseries.py +110 -0
- climplot-0.1.0.dist-info/METADATA +198 -0
- climplot-0.1.0.dist-info/RECORD +14 -0
- climplot-0.1.0.dist-info/WHEEL +5 -0
- climplot-0.1.0.dist-info/licenses/LICENSE +21 -0
- climplot-0.1.0.dist-info/top_level.txt +1 -0
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
|