• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Ouranosinc / figanos / 18509459827

14 Oct 2025 08:37PM UTC coverage: 8.187% (+0.1%) from 8.075%
18509459827

push

github

web-flow
Make get_var_group usable outside (#365)

<!-- Please ensure the PR fulfills the following requirements! -->
<!-- If this is your first PR, make sure to add your details to the
AUTHORS.rst! -->
### Pull Request Checklist:
- [ ] This PR addresses an already opened issue (for bug fixes /
features)
  - This PR fixes #xyz
- [ ] (If applicable) Documentation has been added / updated (for bug
fixes / features).
- [ ] (If applicable) Tests have been added.
- [x] CHANGELOG.rst has been updated (with summary of main changes).
- [x] Link to issue (:issue:`number`) and pull request (:pull:`number`)
has been added.

### What kind of change does this PR introduce?

* I made the json file location default in order to be able to call
`get_var_group` easily externally.

### Does this PR introduce a breaking change?
no

### Other information:

2 of 4 new or added lines in 1 file covered. (50.0%)

158 of 1930 relevant lines covered (8.19%)

0.41 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

11.62
/src/figanos/matplotlib/utils.py
1
"""Utility functions for figanos figure-creation."""
2

3
from __future__ import annotations
5✔
4
import json
5✔
5
import math
5✔
6
import pathlib
5✔
7
import re
5✔
8
import warnings
5✔
9
from collections.abc import Callable
5✔
10
from copy import deepcopy
5✔
11
from pathlib import Path
5✔
12
from tempfile import NamedTemporaryFile
5✔
13
from typing import Any
5✔
14

15
import cairosvg
5✔
16
import cartopy.crs as ccrs
5✔
17
import cartopy.feature as cfeature
5✔
18
import geopandas as gpd
5✔
19
import matplotlib as mpl
5✔
20
import matplotlib.axes
5✔
21
import matplotlib.colors as mcolors
5✔
22
import matplotlib.pyplot as plt
5✔
23
import numpy as np
5✔
24
import pandas as pd
5✔
25
import seaborn
5✔
26
import xarray as xr
5✔
27
import yaml
5✔
28
from matplotlib.lines import Line2D
5✔
29
from skimage.transform import resize
5✔
30
from xclim.core.options import METADATA_LOCALES
5✔
31
from xclim.core.options import OPTIONS as XC_OPTIONS
5✔
32

33
from .._logo import Logos
5✔
34

35

36
# file to map variable key words to variable group for IPCC color scheme
37
VARJSON = Path(__file__).parents[1] / "data/ipcc_colors/variable_groups.json"
5✔
38

39
TERMS: dict = {}
5✔
40
"""
5✔
41
A translation directory for special terms to appear on the plots.
42

43
Keys are terms to translate and they map to "locale": "translation" dictionaries.
44
The "official" figanos terms are based on figanos/data/terms.yml.
45
"""
46

47

48
# Load terms translations
49
with (pathlib.Path(__file__).resolve().parents[1] / "data" / "terms.yml").open() as f:
5✔
50
    TERMS = yaml.safe_load(f)
5✔
51

52

53
def get_localized_term(term, locale=None):
5✔
54
    """
55
    Get `term` translated into `locale`.
56

57
    Terms are pulled from the :py:data:`TERMS` dictionary.
58

59
    Parameters
60
    ----------
61
    term : str
62
        A word or short phrase to translate.
63
    locale : str, optional
64
        A 2-letter locale name to translate to.
65
        Default is None, which will pull the locale from xclim's "metadata_locales" option (taking the first).
66

67
    Returns
68
    -------
69
    str
70
        Translated term.
71
    """
72
    locale = locale or (XC_OPTIONS[METADATA_LOCALES] or ["en"])[0]
×
73
    if locale == "en":
×
74
        return term
×
75

76
    if term not in TERMS:
×
77
        warnings.warn(f"No translation known for term '{term}'.", stacklevel=2)
×
78
        return term
×
79

80
    if locale not in TERMS[term]:
×
81
        warnings.warn(f"No {locale} translation known for term '{term}'.", stacklevel=2)
×
82
        return term
×
83

84
    return TERMS[term][locale]
×
85

86

87
def empty_dict(param) -> dict:
5✔
88
    """Return empty dict if input is None."""
89
    if param is None:
×
90
        param = dict()
×
91
    return deepcopy(param)  # avoid modifying original input dict when popping items
×
92

93

94
def check_timeindex(
5✔
95
    xr_objs: xr.DataArray | xr.Dataset | dict[str, Any],
96
) -> xr.DataArray | xr.Dataset | dict[str, Any]:
97
    """
98
    Check if the time index of Xarray objects in a dict is CFtime and convert to pd.DatetimeIndex if True.
99

100
    Parameters
101
    ----------
102
    xr_objs : xr.DataArray or xr.Dataset or dict
103
        Dictionary containing Xarray DataArrays or Datasets.
104

105
    Returns
106
    -------
107
    xr.DataArray or xr.Dataset or dict
108
        Dictionary of xarray objects with a pandas DatetimeIndex
109
    """
110
    if isinstance(xr_objs, dict):
×
111
        for name, obj in xr_objs.items():
×
112
            if "time" in obj.dims:
×
113
                if isinstance(obj.get_index("time"), xr.CFTimeIndex):
×
114
                    conv_obj = obj.convert_calendar(
×
115
                        "standard", use_cftime=None, align_on="year"
116
                    )
117
                    xr_objs[name] = conv_obj
×
118
                    warnings.warn(
×
119
                        "CFTimeIndex converted to pandas DatetimeIndex with a 'standard' calendar.", stacklevel=2
120
                    )
121

122
    else:
123
        if "time" in xr_objs.dims:
×
124
            if isinstance(xr_objs.get_index("time"), xr.CFTimeIndex):
×
125
                conv_obj = xr_objs.convert_calendar(
×
126
                    "standard", use_cftime=None, align_on="year"
127
                )
128
                xr_objs = conv_obj
×
129
                warnings.warn(
×
130
                    "CFTimeIndex converted to pandas DatetimeIndex with a 'standard' calendar.", stacklevel=2
131
                )
132

133
    return xr_objs
×
134

135

136
def get_array_categ(array: xr.DataArray | xr.Dataset) -> str:
5✔
137
    """
138
    Get an array category, which determines how to plot the array.
139

140
    Parameters
141
    ----------
142
    array : Dataset or DataArray
143
        The array being categorized.
144

145
    Returns
146
    -------
147
    str
148
        ENS_PCT_VAR_DS: ensemble percentiles stored as variables
149
        ENS_PCT_DIM_DA: ensemble percentiles stored as dimension coordinates, DataArray
150
        ENS_PCT_DIM_DS: ensemble percentiles stored as dimension coordinates, DataSet
151
        ENS_STATS_VAR_DS: ensemble statistics (min, mean, max) stored as variables
152
        ENS_REALS_DA: ensemble with 'realization' dim, as DataArray
153
        ENS_REALS_DS: ensemble with 'realization' dim, as Dataset
154
        DS: any Dataset that is not  recognized as an ensemble
155
        DA: DataArray
156
    """
157
    if isinstance(array, xr.Dataset):
×
158
        if (
×
159
            pd.notnull(
160
                [re.search("_p[0-9]{1,2}", var) for var in array.data_vars]
161
            ).sum()
162
            >= 2
163
        ):
164
            cat = "ENS_PCT_VAR_DS"
×
165
        elif (
×
166
            pd.notnull(
167
                [re.search("_[Mm]ax|_[Mm]in", var) for var in array.data_vars]
168
            ).sum()
169
            >= 2
170
        ):
171
            cat = "ENS_STATS_VAR_DS"
×
172
        elif "percentiles" in array.dims:
×
173
            cat = "ENS_PCT_DIM_DS"
×
174
        elif "realization" in array.dims:
×
175
            cat = "ENS_REALS_DS"
×
176
        else:
177
            cat = "DS"
×
178

179
    elif isinstance(array, xr.DataArray):
×
180
        if "percentiles" in array.dims:
×
181
            cat = "ENS_PCT_DIM_DA"
×
182
        elif "realization" in array.dims:
×
183
            cat = "ENS_REALS_DA"
×
184
        else:
185
            cat = "DA"
×
186
    else:
187
        raise TypeError("Array is not an Xarray Dataset or DataArray")
×
188

189
    return cat
×
190

191

192
def get_attributes(
5✔
193
    string: str, xr_obj: xr.DataArray | xr.Dataset, locale: str | None = None
194
) -> str:
195
    """
196
    Fetch attributes or dims corresponding to keys from Xarray objects.
197

198
    Searches DataArray attributes first, then the first variable (DataArray) of the Dataset, then Dataset attributes.
199
    If a locale is activated in xclim's options or a locale is passed, a localized version is given if available.
200

201
    Parameters
202
    ----------
203
    string : str
204
        String corresponding to an attribute name.
205
    xr_obj : DataArray or Dataset
206
        The Xarray object containing the attributes.
207
    locale : str, optional
208
        A 2-letter locale name to translate to.
209
        Default is None, which will pull the locale
210
        from xclim's "metadata_locales" option (taking the first).
211

212
    Returns
213
    -------
214
    str
215
        Xarray attribute value as string or empty string if not found
216
    """
217
    locale = locale or (XC_OPTIONS[METADATA_LOCALES] or ["en"])[0]
×
218
    if locale != "en":
×
219
        names = [f"{string}_{locale}", string]
×
220
    else:
221
        names = [string]
×
222

223
    for name in names:
×
224
        if isinstance(xr_obj, xr.DataArray) and name in xr_obj.attrs:
×
225
            return xr_obj.attrs[name]
×
226

227
        if (
×
228
            isinstance(xr_obj, xr.Dataset)
229
            and name in xr_obj[list(xr_obj.data_vars)[0]].attrs
230
        ):  # DataArray of first variable
231
            return xr_obj[list(xr_obj.data_vars)[0]].attrs[name]
×
232

233
        if isinstance(xr_obj, xr.Dataset) and name in xr_obj.attrs:
×
234
            return xr_obj.attrs[name]
×
235

236
    warnings.warn(f'Attribute "{string}" not found.', stacklevel=2)
×
237
    return ""
×
238

239

240
def set_plot_attrs(
5✔
241
    attr_dict: dict[str, Any],
242
    xr_obj: xr.DataArray | xr.Dataset,
243
    ax: matplotlib.axes.Axes | None = None,
244
    title_loc: str = "center",
245
    facetgrid: seaborn.axisgrid.FacetGrid | None = None,
246
    wrap_kw: dict[str, Any] | None = None,
247
) -> matplotlib.axes.Axes:
248
    """
249
    Set plot elements according to Dataset or DataArray attributes.
250

251
    Uses get_attributes() to check for and get the string.
252

253
    Parameters
254
    ----------
255
    attr_dict : dict
256
        Dictionary containing specified attribute keys.
257
    xr_obj : Dataset or DataArray
258
        The Xarray object containing the attributes.
259
    ax : matplotlib axis
260
        The matplotlib axis of the plot.
261
    title_loc : str
262
        Location of the title.
263
    wrap_kw : dict, optional
264
        Arguments to pass to the wrap_text function for the title.
265

266
    Returns
267
    -------
268
    matplotlib.axes.Axes
269
    """
270
    wrap_kw = empty_dict(wrap_kw)
×
271

272
    #  check
273
    for key in attr_dict:
×
274
        if key not in [
×
275
            "title",
276
            "ylabel",
277
            "yunits",
278
            "xlabel",
279
            "xunits",
280
            "cbar_label",
281
            "cbar_units",
282
            "suptitle",
283
        ]:
284
            warnings.warn(f'Use_attrs element "{key}" not supported', stacklevel=2)
×
285

286
    if "title" in attr_dict:
×
287
        title = get_attributes(attr_dict["title"], xr_obj)
×
288
        ax.set_title(wrap_text(title, **wrap_kw), loc=title_loc)
×
289

290
    if "ylabel" in attr_dict:
×
291
        if (
×
292
            "yunits" in attr_dict
293
            and len(get_attributes(attr_dict["yunits"], xr_obj)) >= 1
294
        ):  # second condition avoids '[]' as label
295
            ylabel = wrap_text(
×
296
                get_attributes(attr_dict["ylabel"], xr_obj)
297
                + " ("
298
                + get_attributes(attr_dict["yunits"], xr_obj)
299
                + ")"
300
            )
301
        else:
302
            ylabel = wrap_text(get_attributes(attr_dict["ylabel"], xr_obj))
×
303

304
        ax.set_ylabel(ylabel)
×
305

306
    if "xlabel" in attr_dict:
×
307
        if (
×
308
            "xunits" in attr_dict
309
            and len(get_attributes(attr_dict["xunits"], xr_obj)) >= 1
310
        ):  # second condition avoids '[]' as label
311
            xlabel = wrap_text(
×
312
                get_attributes(attr_dict["xlabel"], xr_obj)
313
                + " ("
314
                + get_attributes(attr_dict["xunits"], xr_obj)
315
                + ")"
316
            )
317
        else:
318
            xlabel = wrap_text(get_attributes(attr_dict["xlabel"], xr_obj))
×
319

320
        ax.set_xlabel(xlabel)
×
321

322
    # cbar label has to be assigned in main function, ignore.
323
    if "cbar_label" in attr_dict:
×
324
        pass
×
325

326
    if "cbar_units" in attr_dict:
×
327
        pass
×
328

329
    if facetgrid:
×
330
        if "suptitle" in attr_dict:
×
331
            suptitle = get_attributes(attr_dict["suptitle"], xr_obj)
×
332
            facetgrid.fig.suptitle(suptitle, y=1.05)
×
333
            facetgrid.set_titles(template="{value}")
×
334
        return facetgrid
×
335

336
    else:
337
        return ax
×
338

339

340
def get_suffix(string: str) -> str:
5✔
341
    """Get suffix of typical Xclim variable names."""
342
    if re.search("[0-9]{1,2}$|_[Mm]ax$|_[Mm]in$|_[Mm]ean$", string):
×
343
        suffix = re.search("[0-9]{1,2}$|[Mm]ax$|[Mm]in$|[Mm]ean$", string).group()
×
344
        return suffix
×
345
    else:
346
        raise ValueError(f"Mean, min or max not found in {string}")
×
347

348

349
def sort_lines(array_dict: dict[str, Any]) -> dict[str, str]:
5✔
350
    """
351
    Label arrays as 'middle', 'upper' and 'lower' for ensemble plotting.
352

353
    Parameters
354
    ----------
355
    array_dict : dict
356
        Dictionary of format {'name': array...}.
357

358
    Returns
359
    -------
360
    dict
361
        Dictionary of {'middle': 'name', 'upper': 'name', 'lower': 'name'}.
362
    """
363
    if len(array_dict) != 3:
×
364
        raise ValueError("Ensembles must contain exactly three arrays")
×
365

366
    sorted_lines = {}
×
367

368
    for name in array_dict.keys():
×
369
        suffix = get_suffix(name)
×
370

371
        if suffix.isalpha():
×
372
            if suffix in ["max", "Max"]:
×
373
                sorted_lines["upper"] = name
×
374
            elif suffix in ["min", "Min"]:
×
375
                sorted_lines["lower"] = name
×
376
            elif suffix in ["mean", "Mean"]:
×
377
                sorted_lines["middle"] = name
×
378
        elif suffix.isdigit():
×
379
            if int(suffix) >= 51:
×
380
                sorted_lines["upper"] = name
×
381
            elif int(suffix) <= 49:
×
382
                sorted_lines["lower"] = name
×
383
            elif int(suffix) == 50:
×
384
                sorted_lines["middle"] = name
×
385
        else:
386
            raise ValueError('Arrays names must end in format "_mean" or "_p50" ')
×
387
    return sorted_lines
×
388

389

390
def loc_mpl(
5✔
391
    loc: str | tuple[int | float, int | float] | int,
392
) -> tuple[tuple[float, float], tuple[int | float, int | float], str, str]:
393
    """
394
    Find coordinates and alignment associated to loc string.
395

396
    Parameters
397
    ----------
398
    loc : string, int, or tuple[float, float]
399
        Location of text, replicating https://matplotlib.org/stable/api/_as_gen/matplotlib.pyplot.legend.html.
400
        If a tuple, must be in axes coordinates.
401

402
    Returns
403
    -------
404
    tuple(float, float), tuple(float, float), str, str
405
    """
406
    ha = "left"
×
407
    va = "bottom"
×
408

409
    loc_strings = [
×
410
        "upper right",
411
        "upper left",
412
        "lower left",
413
        "lower right",
414
        "right",
415
        "center left",
416
        "center right",
417
        "lower center",
418
        "upper center",
419
        "center",
420
    ]
421

422
    if isinstance(loc, int):
×
423
        try:
×
424
            loc = loc_strings[loc - 1]
×
425
        except IndexError as err:
×
426
            raise ValueError("loc must be between 1 and 10, inclusively") from err
×
427

428
    if loc in loc_strings:
×
429
        # ha
430
        if "left" in loc:
×
431
            ha = "left"
×
432
        elif "right" in loc:
×
433
            ha = "right"
×
434
        else:
435
            ha = "center"
×
436

437
        # va
438
        if "lower" in loc:
×
439
            va = "bottom"
×
440
        elif "upper" in loc:
×
441
            va = "top"
×
442
        else:
443
            va = "center"
×
444

445
        # transAxes
446
        if loc == "upper right":
×
447
            loc = (0.97, 0.97)
×
448
            box_a = (1, 1)
×
449
        elif loc == "upper left":
×
450
            loc = (0.03, 0.97)
×
451
            box_a = (0, 1)
×
452
        elif loc == "lower left":
×
453
            loc = (0.03, 0.03)
×
454
            box_a = (0, 0)
×
455
        elif loc == "lower right":
×
456
            loc = (0.97, 0.03)
×
457
            box_a = (1, 0)
×
458
        elif loc == "right":
×
459
            loc = (0.97, 0.5)
×
460
            box_a = (1, 0.5)
×
461
        elif loc == "center left":
×
462
            loc = (0.03, 0.5)
×
463
            box_a = (0, 0.5)
×
464
        elif loc == "center right":
×
465
            loc = (0.97, 0.5)
×
466
            box_a = (0.97, 0.5)
×
467
        elif loc == "lower center":
×
468
            loc = (0.5, 0.03)
×
469
            box_a = (0.5, 0)
×
470
        elif loc == "upper center":
×
471
            loc = (0.5, 0.97)
×
472
            box_a = (0.5, 1)
×
473
        else:
474
            loc = (0.5, 0.5)
×
475
            box_a = (0.5, 0.5)
×
476

477
    elif isinstance(loc, tuple):
×
478
        box_a = []
×
479
        for i in loc:
×
480
            if i > 1 or i < 0:
×
481
                raise ValueError(
×
482
                    "Text location coordinates must be between 0 and 1, inclusively"
483
                )
484
            elif i > 0.5:
×
485
                box_a.append(1)
×
486
            else:
487
                box_a.append(0)
×
488
        box_a = tuple(box_a)
×
489
    else:
490
        raise ValueError(
×
491
            "loc must be a string, int or tuple. "
492
            "See https://matplotlib.org/stable/api/_as_gen/matplotlib.pyplot.legend.html"
493
        )
494

495
    return loc, box_a, ha, va
×
496

497

498
def plot_coords(
5✔
499
    ax: matplotlib.axes.Axes | None,
500
    xr_obj: xr.DataArray | xr.Dataset,
501
    loc: str | tuple[float, float] | int,
502
    param: str | None = None,
503
    backgroundalpha: float = 1,
504
) -> matplotlib.axes.Axes:
505
    """
506
    Place coordinates on plot area.
507

508
    Parameters
509
    ----------
510
    ax : matplotlib.axes.Axes or None
511
        Matplotlib axes object on which to place the text.
512
        If None, will use plt.figtext instead (should be used for facetgrids).
513
    xr_obj : xr.DataArray or xr.Dataset
514
        The xarray object from which to fetch the text content.
515
    param : {"location", "time"}, optional
516
        The parameter used.
517
    loc : string, int or tuple
518
        Location of text, replicating https://matplotlib.org/stable/api/_as_gen/matplotlib.pyplot.legend.html.
519
        If a tuple, must be in axes coordinates.
520
    backgroundalpha : float
521
        Transparency of the text background. 1 is opaque, 0 is transparent.
522

523
    Returns
524
    -------
525
    matplotlib.axes.Axes
526
    """
527
    text = None
×
528
    if param == "location":
×
529
        if "lat" in xr_obj.coords and "lon" in xr_obj.coords:
×
530
            text = "lat={:.2f}, lon={:.2f}".format(
×
531
                float(xr_obj["lat"]), float(xr_obj["lon"])
532
            )
533
        else:
534
            warnings.warn(
×
535
                'show_lat_lon set to True, but "lat" and/or "lon" not found in coords', stacklevel=2
536
            )
537
    if param == "time":
×
538
        if "time" in xr_obj.coords:
×
539
            text = str(xr_obj.time.dt.strftime("%Y-%m-%d").values)
×
540

541
        else:
542
            warnings.warn('show_time set to True, but "time" not found in coords', stacklevel=2)
×
543

544
    loc, box_a, ha, va = loc_mpl(loc)
×
545

546
    if text:
×
547
        if ax:
×
548
            t = mpl.offsetbox.TextArea(
×
549
                text, textprops=dict(transform=ax.transAxes, ha=ha, va=va)
550
            )
551

552
            tt = mpl.offsetbox.AnnotationBbox(
×
553
                t,
554
                loc,
555
                xycoords="axes fraction",
556
                box_alignment=box_a,
557
                pad=0.05,
558
                bboxprops=dict(
559
                    facecolor="white",
560
                    alpha=backgroundalpha,
561
                    edgecolor="w",
562
                    boxstyle="Square, pad=0.5",
563
                ),
564
            )
565
            ax.add_artist(tt)
×
566
            return ax
×
567
        elif not ax:
×
568
            """
×
569
            if loc == "top left":
570
                plt.figtext(0.8, 1.025, text, ha="center", fontsize=12)
571
            elif loc == "top right":
572
                plt.figtext(0.2, -0.075, text, ha="center", fontsize=12)
573
            elif loc == "bottom left":
574
                plt.figtext(0.2, -0.075, text, ha="center", fontsize=12)
575
            elif loc == "bottom right" or loc is True:
576
                plt.figtext(0.8, -0.075, text, ha="center", fontsize=12)
577
            elif isinstance(loc, tuple):
578
                        else:
579
                raise ValueError(
580
                    f"{loc} option does not work with facetgrids. Try 'top left', ''top right', 'bottom left', "
581
                    f"'bottom right' or a tuple of coordinates."
582
                )
583
            """
584
            plt.figtext(
×
585
                loc[0],
586
                loc[1],
587
                text,
588
                ha=ha,
589
                va=va,
590
                fontsize=12,
591
            )
592

593
            return None
×
594

595

596
def find_logo(logo: str | pathlib.Path) -> str:
5✔
597
    """Read a logo file."""
598
    logos = Logos()
×
599
    if logo:
×
600
        logo_path = logos[logo]
×
601
    else:
602
        logo_path = logos.default
×
603

604
    if logo_path is None:
×
605
        raise ValueError(
×
606
            "No logo found. Please install one with the figanos.Logos().set_logo() method."
607
        )
608
    return logo_path
×
609

610

611
def load_image(
5✔
612
    im: str | pathlib.Path,
613
    height: float | None,
614
    width: float | None,
615
    keep_ratio: bool = True,
616
) -> np.ndarray:
617
    """
618
    Scale an image to a specified height and width.
619

620
    Parameters
621
    ----------
622
    im : str or Path
623
        The image to be scaled. PNG and SVG formats are supported.
624
    height : float, optional
625
        The desired height of the image. If None, the original height is used.
626
    width : float, optional
627
        The desired width of the image. If None, the original width is used.
628
    keep_ratio : bool
629
        If True, the aspect ratio of the original image is maintained. Default is True.
630

631
    Returns
632
    -------
633
    np.ndarray
634
        The scaled image.
635
    """
636
    if pathlib.Path(im).suffix == ".png":
×
637
        image = mpl.pyplot.imread(im)
×
638
        original_height, original_width = image.shape[:2]
×
639

640
        if height is None and width is None:
×
641
            return image
×
642

643
        warnings.warn(
×
644
            "The scikit-image library is used to resize PNG images. This may affect logo image quality.", stacklevel=2
645
        )
646
        if not keep_ratio:
×
647
            height = original_height or height
×
648
            width = original_width or width
×
649
        else:
650
            if width is not None:
×
651
                if height is not None:
×
652
                    warnings.warn("Both height and width provided, using height.", stacklevel=2)
×
653
                # Only width is provided, derive zoom factor for height based on aspect ratio
654
                height = (width / original_width) * original_height
×
655
            elif height is not None:
×
656
                # Only height is provided, derive zoom factor for width based on aspect ratio
657
                width = (height / original_height) * original_width
×
658

659
        return resize(image, (height, width, image.shape[2]), anti_aliasing=True)
×
660

661
    elif pathlib.Path(im).suffix == ".svg":
×
662
        cairo_kwargs = dict(url=im)
×
663
        if not keep_ratio:
×
664
            if height is not None and width is not None:
×
665
                cairo_kwargs.update(output_height=height, output_width=width)
×
666
        elif width is not None:
×
667
            if height is not None:
×
668
                warnings.warn("Both height and width provided, using height.", stacklevel=2)
×
669
            cairo_kwargs.update(output_width=width)
×
670
        elif height is not None:
×
671
            cairo_kwargs.update(output_height=height)
×
672

673
        with NamedTemporaryFile(suffix=".png") as png_file:
×
674
            cairo_kwargs.update(write_to=png_file.name)
×
675
            cairosvg.svg2png(**cairo_kwargs)
×
676
            return mpl.pyplot.imread(png_file.name)
×
677

678

679
def plot_logo(
5✔
680
    ax: matplotlib.axes.Axes,
681
    loc: str | tuple[float, float] | int,
682
    logo: str | pathlib.Path | Logos | None = None,
683
    height: float | None = None,
684
    width: float | None = None,
685
    keep_ratio: bool = True,
686
    **offset_image_kwargs,
687
) -> matplotlib.axes.Axes:
688
    r"""
689
    Place logo of plot area.
690

691
    Parameters
692
    ----------
693
    ax : matplotlib.axes.Axes
694
        Matplotlib axes object on which to place the text.
695
    loc : string, int or tuple
696
        Location of text, replicating https://matplotlib.org/stable/api/_as_gen/matplotlib.pyplot.legend.html.
697
        If a tuple, must be in axes coordinates.
698
    logo : str, Path, figanos.Logos, optional
699
        A name (str) or Path to a logo file, or a name of an already-installed logo.
700
        If an existing is not found, the logo will be installed and accessible via the filename.
701
        The default logo is the Figanos logo. To install the Ouranos (or another) logo consult the Usage page.
702
        The logo must be in 'png' format.
703
    height : float, optional
704
        The desired height of the image. If None, the original height is used.
705
    width : float, optional
706
        The desired width of the image. If None, the original width is used.
707
    keep_ratio : bool, optional
708
        If True, the aspect ratio of the original image is maintained. Default is True.
709
    \*\*offset_image_kwargs
710
        Arguments to pass to matplotlib.offsetbox.OffsetImage().
711

712
    Returns
713
    -------
714
    matplotlib.axes.Axes
715
    """
716
    if offset_image_kwargs is None:
×
717
        offset_image_kwargs = {}
×
718

719
    if isinstance(logo, Logos):
×
720
        logo_path = logo.default
×
721
    else:
722
        logo_path = find_logo(logo)
×
723

724
    image = load_image(logo_path, height, width, keep_ratio)
×
725
    imagebox = mpl.offsetbox.OffsetImage(image, **offset_image_kwargs)
×
726

727
    loc, box_a, ha, va = loc_mpl(loc)
×
728
    ab = mpl.offsetbox.AnnotationBbox(
×
729
        imagebox,
730
        loc,
731
        frameon=False,
732
        xycoords="axes fraction",
733
        box_alignment=box_a,
734
        pad=0.05,
735
    )
736
    ax.add_artist(ab)
×
737
    return ax
×
738

739

740
def split_legend(
5✔
741
    ax: matplotlib.axes.Axes,
742
    in_plot: bool = False,
743
    axis_factor: float = 0.15,
744
    label_gap: float = 0.02,
745
) -> matplotlib.axes.Axes:
746
    #  TODO: check for and fix overlapping labels
747
    """
748
    Draw line labels at the end of each line, or outside the plot.
749

750
    Parameters
751
    ----------
752
    ax : matplotlib.axes.Axes
753
        The axis containing the legend.
754
    in_plot : bool
755
        If True, prolong plot area to fit labels. If False, print labels outside of plot area. Default: False.
756
    axis_factor : float
757
        If in_plot is True, fraction of the x-axis length to add at the far right of the plot. Default: 0.15.
758
    label_gap : float
759
        If in_plot is True, fraction of the x-axis length to add as a gap between line and label. Default: 0.02.
760

761
    Returns
762
    -------
763
    matplotlib.axes.Axes
764
    """
765
    # create extra space
766
    init_xbound = ax.get_xbound()
×
767

768
    ax_bump = (init_xbound[1] - init_xbound[0]) * axis_factor
×
769
    label_bump = (init_xbound[1] - init_xbound[0]) * label_gap
×
770

771
    if in_plot is True:
×
772
        ax.set_xbound(lower=init_xbound[0], upper=init_xbound[1] + ax_bump)
×
773

774
    # get legend and plot
775

776
    handles, labels = ax.get_legend_handles_labels()
×
777
    for handle, label in zip(handles, labels, strict=False):
×
778
        last_x = handle.get_xdata()[-1]
×
779
        last_y = handle.get_ydata()[-1]
×
780

781
        if isinstance(last_x, np.datetime64):
×
782
            last_x = mpl.dates.date2num(last_x)
×
783

784
        color = handle.get_color()
×
785
        # ls = handle.get_linestyle()
786

787
        if in_plot is True:
×
788
            ax.text(
×
789
                last_x + label_bump,
790
                last_y,
791
                label,
792
                ha="left",
793
                va="center",
794
                color=color,
795
            )
796
        else:
797
            trans = mpl.transforms.blended_transform_factory(ax.transAxes, ax.transData)
×
798
            ax.text(
×
799
                1.01,
800
                last_y,
801
                label,
802
                ha="left",
803
                va="center",
804
                color=color,
805
                transform=trans,
806
            )
807

808
    return ax
×
809

810

811
def fill_between_label(
5✔
812
    sorted_lines: dict[str, Any],
813
    name: str,
814
    array_categ: dict[str, Any],
815
    legend: str,
816
) -> str:
817
    """
818
    Create a label for the shading around a line in line plots.
819

820
    Parameters
821
    ----------
822
    sorted_lines : dict
823
        Dictionary created by the sort_lines() function.
824
    name : str
825
        Key associated with the object being plotted in the 'data' argument of the timeseries() function.
826
    array_categ : dict
827
        The categories of the array, as created by the get_array_categ function.
828
    legend : str
829
        Legend mode.
830

831
    Returns
832
    -------
833
    str
834
        Label to be applied to the legend element representing the shading.
835
    """
836
    if legend != "full":
×
837
        label = None
×
838
    elif array_categ[name] in [
×
839
        "ENS_PCT_VAR_DS",
840
        "ENS_PCT_DIM_DS",
841
        "ENS_PCT_DIM_DA",
842
    ]:
843
        label = get_localized_term("{}th-{}th percentiles").format(
×
844
            get_suffix(sorted_lines["lower"]), get_suffix(sorted_lines["upper"])
845
        )
846
    elif array_categ[name] == "ENS_STATS_VAR_DS":
×
847
        label = get_localized_term("min-max range")
×
848
    else:
849
        label = None
×
850

851
    return label
×
852

853

854
def get_var_group(
5✔
855
    da: xr.DataArray | None = None,
856
    unique_str: str | None = None,
857
    path_to_json: str | pathlib.Path | None = None,
858
) -> str:
859
    """
860
    Get IPCC variable group from DataArray or a string using a json file (figanos/data/ipcc_colors/variable_groups.json).
861

862
    If `da` is a Dataset, look in the DataArray of the first variable.
863
    """
NEW
864
    if path_to_json is None:
×
NEW
865
        path_to_json = VARJSON
×
866

867
    # create dict
868
    with pathlib.Path(path_to_json).open(encoding="utf-8") as _f:
×
869
        var_dict = json.load(_f)
×
870

871
    matches = []
×
872

873
    if unique_str:
×
874
        for v in var_dict:
×
875
            regex = rf"(?:^|[^a-zA-Z])({v})(?:[^a-zA-Z]|$)"  # matches when variable is not inside word
×
876
            if re.search(regex, unique_str):
×
877
                matches.append(var_dict[v])
×
878

879
    else:
880
        if isinstance(da, xr.Dataset):
×
881
            da = da[list(da.data_vars)[0]]
×
882
        # look in DataArray name
883
        if hasattr(da, "name") and isinstance(da.name, str):
×
884
            for v in var_dict:
×
885
                regex = rf"(?:^|[^a-zA-Z])({v})(?:[^a-zA-Z]|$)"
×
886
                if re.search(regex, da.name):
×
887
                    matches.append(var_dict[v])
×
888

889
        # look in history
890
        if hasattr(da, "history") and len(matches) == 0:
×
891
            for v in var_dict:
×
892
                regex = rf"(?:^|[^a-zA-Z])({v})(?:[^a-zA-Z]|$)"
×
893
                if re.search(regex, da.history):
×
894
                    matches.append(var_dict[v])
×
895

896
    matches = np.unique(matches)
×
897

898
    if len(matches) == 0:
×
899
        warnings.warn(
×
900
            "Colormap warning: Variable group not found. Use the cmap argument.", stacklevel=2
901
        )
902
        return "misc"
×
903
    elif len(matches) >= 2:
×
904
        warnings.warn(
×
905
            "Colormap warning: More than one variable group found. Use the cmap argument.", stacklevel=2
906
        )
907
        return "misc"
×
908
    else:
909
        return matches[0]
×
910

911

912
def create_cmap(
5✔
913
    var_group: str | None = None,
914
    divergent: bool | int = False,
915
    filename: str | None = None,
916
) -> matplotlib.colors.Colormap:
917
    """
918
    Create colormap according to variable group.
919

920
    Parameters
921
    ----------
922
    var_group : str, optional
923
        Variable group from IPCC scheme.
924
    divergent : bool or int
925
        Diverging colormap. If False, use sequential colormap.
926
    filename : str, optional
927
        Name of IPCC colormap file. If not None, 'var_group' and 'divergent' are not used.
928

929
    Returns
930
    -------
931
    matplotlib.colors.Colormap
932
    """
933
    reverse = False
×
934

935
    if filename:
×
936
        folder = "continuous_colormaps_rgb_0-255"
×
937
        filename = filename.replace(".txt", "")
×
938

939
        if filename.endswith("_r"):
×
940
            reverse = True
×
941
            filename = filename[:-2]
×
942

943
    else:
944
        # filename
945
        if divergent is not False:
×
946
            if var_group == "misc2":
×
947
                var_group = "misc"
×
948
            filename = var_group + "_div"
×
949
        else:
950
            if var_group == "misc":
×
951
                filename = var_group + "_seq_3"  # Batlow
×
952
            elif var_group == "misc2":
×
953
                filename = "misc_seq_2"  # freezing rain
×
954
            else:
955
                filename = var_group + "_seq"
×
956

957
        folder = "continuous_colormaps_rgb_0-255"
×
958

959
    # parent should be 'figanos/'
960
    path = (
×
961
        pathlib.Path(__file__).parents[1]
962
        / "data"
963
        / "ipcc_colors"
964
        / folder
965
        / (filename + ".txt")
966
    )
967

968
    rgb_data = np.loadtxt(path)
×
969

970
    # convert to 0-1 RGB
971
    rgb_data = rgb_data / 255
×
972

973
    cmap = mcolors.LinearSegmentedColormap.from_list("cmap", rgb_data, N=256)
×
974
    if reverse is True:
×
975
        cmap = cmap.reversed()
×
976

977
    return cmap
×
978

979

980
def get_rotpole(xr_obj: xr.DataArray | xr.Dataset) -> ccrs.RotatedPole | None:
5✔
981
    """
982
    Create a Cartopy crs rotated pole projection/transform from DataArray or Dataset attributes.
983

984
    Parameters
985
    ----------
986
    xr_obj : xr.DataArray or xr.Dataset
987
        The xarray object from which to look for the attributes.
988

989
    Returns
990
    -------
991
    ccrs.RotatedPole or None
992
    """
993
    try:
×
994

995
        if isinstance(xr_obj, xr.Dataset):
×
996
            gridmap = xr_obj.cf.grid_mapping_names.get("rotated_latitude_longitude", [])
×
997

998
            if len(gridmap) > 1:
×
999
                warnings.warn(
×
1000
                    f"There are conflicting grid_mapping attributes in the dataset. Assuming {gridmap[0]}.", stacklevel=2
1001
                )
1002

1003
            coord_name = gridmap[0] if gridmap else "rotated_pole"
×
1004
        else:
1005
            # If it can't find grid_mapping, assume it's rotated_pole
1006
            coord_name = xr_obj.attrs.get("grid_mapping", "rotated_pole")
×
1007

1008
        rotpole = ccrs.RotatedPole(
×
1009
            pole_longitude=xr_obj[coord_name].grid_north_pole_longitude,
1010
            pole_latitude=xr_obj[coord_name].grid_north_pole_latitude,
1011
            central_rotated_longitude=xr_obj[coord_name].north_pole_grid_longitude,
1012
        )
1013
        return rotpole
×
1014

1015
    except AttributeError:
×
1016
        warnings.warn("Rotated pole not found. Specify a transform if necessary.", stacklevel=2)
×
1017
        return None
×
1018

1019

1020
def wrap_text(text: str, min_line_len: int = 18, max_line_len: int = 30) -> str:
5✔
1021
    """
1022
    Wrap text.
1023

1024
    Parameters
1025
    ----------
1026
    text : str
1027
        The text to wrap.
1028
    min_line_len : int
1029
        Minimum length of each line.
1030
    max_line_len : int
1031
        Maximum length of each line.
1032

1033
    Returns
1034
    -------
1035
    str
1036
        Wrapped text
1037
    """
1038
    start = min_line_len
×
1039
    stop = max_line_len
×
1040
    sep = "\n"
×
1041
    remaining = len(text)
×
1042

1043
    if len(text) >= max_line_len:
×
1044
        while remaining > max_line_len:
×
1045
            if ". " in text[start:stop]:
×
1046
                pos = text.find(". ", start, stop) + 1
×
1047
            elif ": " in text[start:stop]:
×
1048
                pos = text.find(": ", start, stop) + 1
×
1049
            elif " " in text[start:stop]:
×
1050
                pos = text.rfind(" ", start, stop)
×
1051
            else:
1052
                warnings.warn("No spaces, points or colons to break line at.", stacklevel=2)
×
1053
                break
×
1054

1055
            text = sep.join([text[:pos], text[pos + 1 :]])
×
1056

1057
            remaining = len(text) - len(text[:pos])
×
1058
            start = pos + 1 + min_line_len
×
1059
            stop = pos + 1 + max_line_len
×
1060

1061
    return text
×
1062

1063

1064
def gpd_to_ccrs(df: gpd.GeoDataFrame, proj: ccrs.CRS) -> gpd.GeoDataFrame:
5✔
1065
    """
1066
    Open shapefile with geopandas and convert to cartopy projection.
1067

1068
    Parameters
1069
    ----------
1070
    df : gpd.GeoDataFrame
1071
        GeoDataFrame (geopandas) geometry to be added to axis.
1072
    proj : ccrs.CRS
1073
        Projection to use, taken from the cartopy.crs options.
1074

1075
    Returns
1076
    -------
1077
    gpd.GeoDataFrame
1078
        GeoDataFrame adjusted to given projection
1079
    """
1080
    prj4 = proj.proj4_init
×
1081
    return df.to_crs(prj4)
×
1082

1083

1084
def convert_scen_name(name: str) -> str:
5✔
1085
    """Convert strings containing SSP, RCP or CMIP to their proper format."""
1086
    matches = re.findall(r"(?:SSP|RCP|CMIP)[0-9]{1,3}", name, flags=re.I)
×
1087
    if matches:
×
1088
        for s in matches:
×
1089
            if sum(c.isdigit() for c in s) == 3:
×
1090
                new_s = s.replace(
×
1091
                    s[-3:], s[-3] + "-" + s[-2] + "." + s[-1]
1092
                ).upper()  # ssp245 to SSP2-4.5
1093
                new_name = name.replace(s, new_s)  # put back in name
×
1094
            elif sum(c.isdigit() for c in s) == 2:
×
1095
                new_s = s.replace(
×
1096
                    s[-2:], s[-2] + "." + s[-1]
1097
                ).upper()  # rcp45 to RCP4.5
1098
                new_name = name.replace(s, new_s)
×
1099
            else:
1100
                new_s = s.upper()  # cmip5 to CMIP5
×
1101
                new_name = name.replace(s, new_s)
×
1102

1103
        return new_name
×
1104
    else:
1105
        return name
×
1106

1107

1108
def get_scen_color(name: str, path_to_dict: str | pathlib.Path) -> str:
5✔
1109
    """Get color corresponding to SSP,RCP, model or CMIP substring from a dictionary."""
1110
    with pathlib.Path(path_to_dict).open(encoding="utf-8") as _f:
×
1111
        color_dict = json.load(_f)
×
1112

1113
    color = None
×
1114
    for entry in color_dict:
×
1115
        if entry in name:
×
1116
            color = color_dict[entry]
×
1117
            color = tuple([i / 255 for i in color])
×
1118
            break
×
1119

1120
    return color
×
1121

1122

1123
def process_keys(dct: dict[str, Any], func: Callable) -> dict[str, Any]:
5✔
1124
    """Apply function to dictionary keys."""
1125
    old_keys = [key for key in dct]
×
1126
    for old_key in old_keys:
×
1127
        new_key = func(old_key)
×
1128
        dct[new_key] = dct.pop(old_key)
×
1129
    return dct
×
1130

1131

1132
def categorical_colors() -> dict[str, str]:
5✔
1133
    """Get a list of the categorical colors associated with certain substrings (SSP,RCP,CMIP)."""
1134
    path = (
×
1135
        pathlib.Path(__file__).parents[1] / "data/ipcc_colors/categorical_colors.json"
1136
    )
1137
    with path.open(encoding="utf-8") as _f:
×
1138
        cat = json.load(_f)
×
1139

1140
        return cat
×
1141

1142

1143
def get_mpl_styles() -> dict[str, pathlib.Path]:
5✔
1144
    """Get the available matplotlib styles and their paths as a dictionary."""
1145
    files = sorted(pathlib.Path(__file__).parent.joinpath("style").glob("*.mplstyle"))
×
1146
    styles = {style.stem: style for style in files}
×
1147
    return styles
×
1148

1149

1150
def set_mpl_style(*args: str, reset: bool = False) -> None:
5✔
1151
    """
1152
    Set the matplotlib style using one or more stylesheets.
1153

1154
    Parameters
1155
    ----------
1156
    args : str
1157
        Name(s) of figanos matplotlib style ('ouranos', 'paper, 'poster') or path(s) to matplotlib stylesheet(s).
1158
    reset : bool
1159
        If True, reset style to matplotlib default before applying the stylesheets.
1160

1161
    Returns
1162
    -------
1163
    None
1164
    """
1165
    if reset is True:
×
1166
        mpl.style.use("default")
×
1167
    for s in args:
×
1168
        if s.endswith(".mplstyle") is True:
×
1169
            mpl.style.use(s)
×
1170
        elif s in get_mpl_styles():
×
1171
            mpl.style.use(get_mpl_styles()[s])
×
1172
        else:
1173
            warnings.warn(f"Style {s} not found.", stacklevel=2)
×
1174

1175

1176
def add_cartopy_features(
5✔
1177
    ax: matplotlib.axes.Axes, features: list[str] | dict[str, dict[str, Any]]
1178
) -> matplotlib.axes.Axes:
1179
    """
1180
    Add cartopy features to matplotlib axes.
1181

1182
    Parameters
1183
    ----------
1184
    ax : matplotlib.axes.Axes
1185
        The axes on which to add the features.
1186
    features : list or dict
1187
        List of features, or nested dictionary of format {'feature': {'kwarg':'value'}}
1188

1189
    Returns
1190
    -------
1191
    matplotlib.axes.Axes
1192
        The axis with added features.
1193
    """
1194
    if isinstance(features, list):
×
1195
        features = {f: {} for f in features}
×
1196

1197
    for feat in features:
×
1198
        if "scale" not in features[feat]:
×
1199
            ax.add_feature(getattr(cfeature, feat.upper()), **features[feat])
×
1200
        else:
1201
            scale = features[feat].pop("scale")
×
1202
            ax.add_feature(
×
1203
                getattr(cfeature, feat.upper()).with_scale(scale),
1204
                **features[feat],
1205
            )
1206
            features[feat]["scale"] = scale  # put back
×
1207
    return ax
×
1208

1209

1210
def custom_cmap_norm(
5✔
1211
    cmap,
1212
    vmin: int | float,
1213
    vmax: int | float,
1214
    levels: int | list[int | float] | None = None,
1215
    divergent: bool | int | float = False,
1216
    linspace_out: bool = False,
1217
) -> matplotlib.colors.Normalize | np.ndarray:
1218
    """
1219
    Get matplotlib normalization according to main function arguments.
1220

1221
    Parameters
1222
    ----------
1223
    cmap: matplotlib.colormap
1224
        Colormap to be used with the normalization.
1225
    vmin: int or float
1226
        Minimum of the data to be plotted with the colormap.
1227
    vmax: int or float
1228
        Maximum of the data to be plotted with the colormap.
1229
    levels : int or list, optional
1230
        Number of  levels or list of level boundaries (in data units) to use to divide the colormap.
1231
    divergent : bool or int or float
1232
        If int or float, becomes center of cmap. Default center is 0.
1233
    linspace_out: bool
1234
        If True, return array created by np.linspace() instead of normalization instance.
1235

1236
    Returns
1237
    -------
1238
    matplotlib.colors.Normalize
1239
    """
1240
    # get cmap if string
1241
    if isinstance(cmap, str):
×
1242
        if cmap in plt.colormaps():
×
1243
            cmap = matplotlib.colormaps[cmap]
×
1244
        else:
1245
            raise ValueError("Colormap not found")
×
1246

1247
    # make vmin and vmax prettier
1248
    if (vmax - vmin) >= 25:
×
1249
        rvmax = math.ceil(vmax / 10.0) * 10
×
1250
        rvmin = math.floor(vmin / 10.0) * 10
×
1251
    elif 1 <= (vmax - vmin) < 25:
×
1252
        rvmax = math.ceil(vmax / 1) * 1
×
1253
        rvmin = math.floor(vmin / 1) * 1
×
1254
    elif 0.1 <= (vmax - vmin) < 1:
×
1255
        rvmax = math.ceil(vmax / 0.1) * 0.1
×
1256
        rvmin = math.floor(vmin / 0.1) * 0.1
×
1257
    else:
1258
        rvmax = math.ceil(vmax / 0.01) * 0.01
×
1259
        rvmin = math.floor(vmin / 0.01) * 0.01
×
1260

1261
    # center
1262
    center = None
×
1263
    if divergent is not False:
×
1264
        if divergent is True:
×
1265
            center = 0
×
1266
        elif isinstance(divergent, int | float):
×
1267
            center = divergent
×
1268

1269
    # build norm with options
1270
    if center is not None and isinstance(levels, int):
×
1271
        if center <= rvmin or center >= rvmax:
×
1272
            raise ValueError("vmin, center and vmax must be in ascending order.")
×
1273
        if levels % 2 == 1:
×
1274
            half_levels = int((levels + 1) / 2) + 1
×
1275
        else:
1276
            half_levels = int(levels / 2) + 1
×
1277

1278
        lin = np.concatenate(
×
1279
            (
1280
                np.linspace(rvmin, center, num=half_levels),
1281
                np.linspace(center, rvmax, num=half_levels)[1:],
1282
            )
1283
        )
1284
        norm = matplotlib.colors.BoundaryNorm(boundaries=lin, ncolors=cmap.N)
×
1285

1286
        if linspace_out:
×
1287
            return lin
×
1288

1289
    elif levels is not None:
×
1290
        if isinstance(levels, list):
×
1291
            if center is not None:
×
1292
                warnings.warn(
×
1293
                    "Divergent argument ignored when levels is a list. Use levels as a number instead.", stacklevel=2
1294
                )
1295
            norm = matplotlib.colors.BoundaryNorm(boundaries=levels, ncolors=cmap.N)
×
1296
        else:
1297
            lin = np.linspace(rvmin, rvmax, num=levels + 1)
×
1298
            norm = matplotlib.colors.BoundaryNorm(boundaries=lin, ncolors=cmap.N)
×
1299

1300
            if linspace_out:
×
1301
                return lin
×
1302

1303
    elif center is not None:
×
1304
        norm = matplotlib.colors.TwoSlopeNorm(center, vmin=rvmin, vmax=rvmax)
×
1305
    else:
1306
        norm = matplotlib.colors.Normalize(rvmin, rvmax)
×
1307

1308
    return norm
×
1309

1310

1311
def norm2range(
5✔
1312
    data: np.ndarray, target_range: tuple, data_range: tuple | None = None
1313
) -> np.ndarray:
1314
    """Normalize data across a specific range."""
1315
    if data_range is None:
×
1316
        if len(data) > 1:
×
1317
            data_range = (np.nanmin(data), np.nanmax(data))
×
1318
        else:
1319
            raise ValueError(" if data is not an array, data_range must be specified")
×
1320

1321
    norm = (data - data_range[0]) / (data_range[1] - data_range[0])
×
1322

1323
    return target_range[0] + (norm * (target_range[1] - target_range[0]))
×
1324

1325

1326
def size_legend_elements(
5✔
1327
    data: np.ndarray, sizes: np.ndarray, marker: str, max_entries: int = 6
1328
) -> list[matplotlib.lines.Line2D]:
1329
    """
1330
    Create handles to use in a point-size legend.
1331

1332
    Parameters
1333
    ----------
1334
    data : np.ndarray
1335
        Data used to determine the point sizes.
1336
    sizes : np.ndarray
1337
        Array of point sizes.
1338
    max_entries : int
1339
        Maximum number of entries in the legend.
1340
    marker: str
1341
        Marker to use in legend.
1342

1343
    Returns
1344
    -------
1345
    list of matplotlib.lines.Line2D
1346
    """
1347
    # how many increments of 10 pts**2 are there in the sizes
1348
    n = int(np.round(max(sizes) - min(sizes), -1) / 10)
×
1349

1350
    # divide data in those increments
1351
    lgd_data = np.linspace(min(data), max(data), n)
×
1352

1353
    # round according to range
1354
    ratio = abs(max(data) - min(data) / n)
×
1355

1356
    if ratio >= 1000:
×
1357
        rounding = 1000
×
1358
    elif 100 <= ratio < 1000:
×
1359
        rounding = 100
×
1360
    elif 10 <= ratio < 100:
×
1361
        rounding = 10
×
1362
    elif 5 <= ratio < 10:
×
1363
        rounding = 5
×
1364
    elif 1 <= ratio < 5:
×
1365
        rounding = 1
×
1366
    elif 0.1 <= ratio < 1:
×
1367
        rounding = 0.1
×
1368
    elif 0.01 <= ratio < 0.1:
×
1369
        rounding = 0.01
×
1370
    else:
1371
        rounding = 0.001
×
1372

1373
    lgd_data = np.unique(rounding * np.round(lgd_data / rounding))
×
1374

1375
    # convert back to sizes
1376
    lgd_sizes = norm2range(
×
1377
        data=lgd_data,
1378
        data_range=(min(data), max(data)),
1379
        target_range=(min(sizes), max(sizes)),
1380
    )
1381

1382
    legend_elements = []
×
1383

1384
    for s, d in zip(lgd_sizes, lgd_data, strict=False):
×
1385
        if isinstance(d, float) and d.is_integer():
×
1386
            label = str(int(d))
×
1387
        else:
1388
            label = str(d)
×
1389

1390
        legend_elements.append(
×
1391
            Line2D(
1392
                [0],
1393
                [0],
1394
                marker=marker,
1395
                color="k",
1396
                lw=0,
1397
                markerfacecolor="w",
1398
                label=label,
1399
                markersize=np.sqrt(np.abs(s)),
1400
            )
1401
        )
1402

1403
    if len(legend_elements) > max_entries:
×
1404
        return [legend_elements[i] for i in np.arange(0, max_entries + 1, 2)]
×
1405
    else:
1406
        return legend_elements
×
1407

1408

1409
def add_features_map(
5✔
1410
    data,
1411
    ax,
1412
    use_attrs,
1413
    projection,
1414
    features,
1415
    geometries_kw,
1416
    frame,
1417
) -> matplotlib.axes.Axes:
1418
    """
1419
    Add features such as cartopy, time label, and geometries to a map on a given matplotlib axis.
1420

1421
    Parameters
1422
    ----------
1423
    data : dict, DataArray or Dataset
1424
        Input data do plot. If dictionary, must have only one entry.
1425
    ax : matplotlib axis
1426
        Matplotlib axis on which to plot, with the same projection as the one specified.
1427
    use_attrs : dict
1428
        Dict linking a plot element (key, e.g. 'title') to a DataArray attribute (value, e.g. 'Description').
1429
        Default value is {'title': 'description', 'cbar_label': 'long_name', 'cbar_units': 'units'}.
1430
        Only the keys found in the default dict can be used.
1431
    projection : ccrs.Projection
1432
        The projection to use, taken from the cartopy.crs options. Ignored if ax is not None.
1433
    features : list or dict
1434
        Features to use, as a list or a nested dict containing kwargs. Options are the predefined features from
1435
        cartopy.feature: ['coastline', 'borders', 'lakes', 'land', 'ocean', 'rivers', 'states'].
1436
    geometries_kw : dict
1437
        Arguments passed to cartopy ax.add_geometry() which adds given geometries (GeoDataFrame geometry) to axis.
1438
    frame : bool
1439
        Show or hide frame. Default False.
1440

1441
    Returns
1442
    -------
1443
    matplotlib.axes.Axes
1444
    """
1445
    # add features
1446
    if features:
×
1447
        add_cartopy_features(ax, features)
×
1448

1449
    set_plot_attrs(use_attrs, data, ax)
×
1450

1451
    if frame is False:
×
1452
        ax.spines["geo"].set_visible(False)
×
1453

1454
    # add geometries
1455
    if geometries_kw:
×
1456
        if "geoms" not in geometries_kw.keys():
×
1457
            warnings.warn(
×
1458
                'geoms missing from geometries_kw (ex: {"geoms": df["geometry"]})', stacklevel=2
1459
            )
1460
        if "crs" in geometries_kw.keys():
×
1461
            geometries_kw["geoms"] = gpd_to_ccrs(
×
1462
                geometries_kw["geoms"], geometries_kw["crs"]
1463
            )
1464
        else:
1465
            geometries_kw["geoms"] = gpd_to_ccrs(geometries_kw["geoms"], projection)
×
1466
        geometries_kw = {
×
1467
            "crs": projection,
1468
            "facecolor": "none",
1469
            "edgecolor": "black",
1470
        } | geometries_kw
1471

1472
        ax.add_geometries(**geometries_kw)
×
1473
    return ax
×
1474

1475

1476
def masknan_sizes_key(data, sizes) -> xr.Dataset:
5✔
1477
    """
1478
    Mask the np.Nan values between variables used to plot hue and markersize in xr.plot.scatter().
1479

1480
    Parameters
1481
    ----------
1482
    data: xr.Dataset
1483
        xr.Dataset used to plot
1484
    sizes: str
1485
        Variable used to plot markersize
1486

1487
    Returns
1488
    -------
1489
    xr.Dataset
1490
    """
1491
    # find variable name
1492
    kl = list(data.keys())
×
1493
    kl.remove(sizes)
×
1494
    key = kl[0]
×
1495

1496
    # Create a mask for missing 'sizes' data
1497
    size_mask = np.isnan(data[sizes])
×
1498

1499
    # Set 'key' values to NaN where 'sizes' is missing
1500
    data[key] = data[key].where(~size_mask)
×
1501

1502
    # Create a mask for missing 'key' data
1503
    key_mask = np.isnan(data[key])
×
1504

1505
    # Set 'sizes' values to NaN where 'key' is missing
1506
    data[sizes] = data[sizes].where(~key_mask)
×
1507
    return data
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc