Maps#

[2]:
import cartopy.crs as ccrs
import numpy as np
import xarray as xr
from matplotlib import pyplot as plt

import figanos.matplotlib as fg


fg.utils.set_mpl_style("ouranos")

# load dataset
url = "https://pavics.ouranos.ca/twitcher/ows/proxy/thredds/dodsC/birdhouse/disk2/cccs_portal/indices/Final/BCCAQv2_CMIP6/tx_max/YS/ssp585/ensemble_percentiles/tx_max_ann_BCCAQ2v2+ANUSPLIN300_historical+ssp585_1950-2100_30ymean_percentiles.nc"
opened = xr.open_dataset(url, decode_timedelta=False, engine="netcdf4")
ds_space = opened[["tx_max_p50"]].isel(time=0).sel(lat=slice(40, 65), lon=slice(-90, -55))

Gridded Data on Maps#

The gridmap function plots gridded data onto maps built using Cartopy along with xarray plotting functions.

Visit the timeseries notebook to learn the basic functions of figanos. The main arguments of the timeseries() functions are also found in gridmap(), but new ones are introduced to handle map projections and colormap/colorbar options.

By default, the Lambert Conformal conic projection is used for the basemaps. The projection can be changed using the projection argument. The available projections can be found here. The transform argument should be used to specify the data coordinate system. If a transform is not provided, figanos will look for dimensions named ‘lat’ and ‘lon’ or ‘rlat’ and ‘rlon’ and return the ccrs.PlateCaree() or ccrs.RotatedPole() transforms, respectively.

Features can also be added to the map by passing the names of the cartopy pre-defined features in a list via the features argument (case-insensitively). A nested dictionary can also be passed to features in order to apply modifiers to these features, for instance features = {'coastline': {'scale': '50m', 'color':'grey'}}.

The gridmap() function only accepts one object in its data argument, inside a dictionary or not. Datasets are accepted, but only their first variable will be plotted.

[3]:
fg.gridmap(
    ds_space,
    features=["coastline", "ocean"],
    frame=True,
    show_time="lower left",
)
[3]:
<GeoAxes: title={'center': '30 year mean Annual maximum\nof daily maximum temperature.\n50th percentile of ensemble.'}, xlabel='lon [degrees_east]', ylabel='lat [degrees_north]'>
/home/docs/checkouts/readthedocs.org/user_builds/figanos/conda/latest/lib/python3.14/site-packages/cartopy/io/__init__.py:242: DownloadWarning: Downloading: https://naturalearth.s3.amazonaws.com/50m_physical/ne_50m_ocean.zip
  warnings.warn(f'Downloading: {url}', DownloadWarning)
/home/docs/checkouts/readthedocs.org/user_builds/figanos/conda/latest/lib/python3.14/site-packages/cartopy/io/__init__.py:242: DownloadWarning: Downloading: https://naturalearth.s3.amazonaws.com/50m_physical/ne_50m_coastline.zip
  warnings.warn(f'Downloading: {url}', DownloadWarning)
../_images/notebooks_figanos_maps_4_2.png

Colormaps and colorbars#

The colormap used to display the plots with gridmap() is directly dependent on three arguments:

  • cmap accepts colormap objects or strings. Strings passed can either be names of built-in matplotlib colormaps or names of the IPCC-prescribed colormaps that were registered to matplotlib upon importing figanos (see cell below). The colormaps are built from RGB data found in the IPCC-WG1 GitHub repository. Any colormap specified as a string can be reversed by adding ‘_r’ to the end of the string.

  • divergent dictates whether the colormap will be sequential or divergent. If a number (integer or float) is provided, it becomes the center of the colormap. The default central value is 0.

  • levels=N will create a discrete colormap of N levels. Otherwise, the colormap will be continuous.

By default, if cmap=None, figanos will look for certain variable names in the attributes of the DataArray (da.name and da.history, in this order) and return a colormap corresponding to the ‘group’ of this variable, following the IPCC visual style guide’s scheme (see page 11). The groups are displayed in the table below.

Variable Group

Matching strings

Temperature (temp)

tas, tasmin, tasmax, tdps, tg, tn, tx

Precipitation (prec)

pr, prc, hurs, huss, rain,precip, precipitation, humidity, evapotranspiration

Wind (wind)

sfcWind, ua, uas, vas

Cryosphere (cryo)

snw, snd, prsn, siconc, ice

Note: The strings shown above will not be recognized as variables if they are part of a longer word, for example, ‘tas’ in ‘fantastic’.

When none of the variables names match a group, or when multiple matches are found, the function resorts to the ‘Batlow’ colormap.

[5]:
import json
from pathlib import Path

import matplotlib

from figanos import data


with data().joinpath("ipcc_colors").joinpath("variable_groups.json").open(encoding="utf-8") as f:
    var_dict = json.load(f)

for f in sorted(data().joinpath("ipcc_colors/continuous_colormaps_rgb_0-255").glob("*")):
    cmap_name = Path(f).name.replace(".txt", "")
    fig = plt.figure()
    ax = fig.add_axes([0.05, 0.80, 0.9, 0.1])
    cb = matplotlib.colorbar.ColorbarBase(ax, orientation="horizontal", cmap=cmap_name)
    cb.outline.set_visible(False)
    cb.ax.set_xticklabels([])
    split = cmap_name.split("_")
    var = split[0] + (split[2] if len(split) == 3 else "")
    kw = [k for k, v in var_dict.items() if v == var]
    # plt.title(f"name: {name} \n keywords: {kw}", wrap=True)
    plt.figtext(
        0.5,
        0.95 + (0.04 * int(len(kw) / 10)),
        f"name: {cmap_name}",
        fontsize=15,
        ha="center",
    )
    plt.figtext(0.5, 0.91, f"keywords: {kw}", fontsize=10, ha="center", wrap=True)
../_images/notebooks_figanos_maps_7_0.png
../_images/notebooks_figanos_maps_7_1.png
../_images/notebooks_figanos_maps_7_2.png
../_images/notebooks_figanos_maps_7_3.png
../_images/notebooks_figanos_maps_7_4.png
../_images/notebooks_figanos_maps_7_5.png
../_images/notebooks_figanos_maps_7_6.png
../_images/notebooks_figanos_maps_7_7.png
../_images/notebooks_figanos_maps_7_8.png
../_images/notebooks_figanos_maps_7_9.png
../_images/notebooks_figanos_maps_7_10.png
../_images/notebooks_figanos_maps_7_11.png
../_images/notebooks_figanos_maps_7_12.png
../_images/notebooks_figanos_maps_7_13.png
../_images/notebooks_figanos_maps_7_14.png
../_images/notebooks_figanos_maps_7_15.png
[6]:
# Change the name of our DataArray for one that includes 'pr' (precipitation) - this is still the same temperature data
da_pr = ds_space.tx_max_p50.copy()
da_pr.name = "pr_max_p50"

# Create a diverging colormap with 8 levels, centered at 300
ax = fg.gridmap(
    da_pr,
    divergent=300,
    levels=8,
    plot_kw={"cbar_kwargs": {"label": "precipitation"}},
)
ax.set_title("This is still temperature data,\nbut let's pretend.")
[6]:
Text(0.5, 1.0, "This is still temperature data,\nbut let's pretend.")
../_images/notebooks_figanos_maps_8_1.png

Note: Using the levels argument will result in a colormap that is split evenly across the span of the data, without consideration for how ‘nice’ the intervals are (i.e. the boundaries of the different colors will often fall on numbers with some decimals, that might be totally significant to an audience). To obtain ‘nice’ intervals, it is possible to use the levels argument in plot_kw. This might however, and often, result in the number of levels not being exactly the one that is specified. Using both arguments is not recommended.

[7]:
# Create the same map, with 'nice' levels.
ax = fg.gridmap(
    da_pr,
    divergent=300,
    plot_kw={"levels": 8, "cbar_kwargs": {"label": None}},
    show_time=(0.85, 0.8),
)
ax.set_title("This cmap has 6 levels instead of 8,\nbut aren't they nice?")
[7]:
Text(0.5, 1.0, "This cmap has 6 levels instead of 8,\nbut aren't they nice?")
../_images/notebooks_figanos_maps_10_1.png

It is also possible to specify your own levels by passing a list to `plot_kw[‘levels’].

[8]:
ax = fg.plot.gridmap(
    da_pr,
    plot_kw={"levels": [290, 294, 298, 302], "cbar_kwargs": {"label": None}},
)
ax.set_title("Custom levels")
fg.utils.plot_logo(ax, loc=(0, 0.85), **{"zoom": 0.08})
/home/docs/checkouts/readthedocs.org/user_builds/figanos/conda/latest/lib/python3.14/site-packages/figanos/_logo.py:127: UserWarning: No logo configuration file found. Creating one at /home/docs/.config/figanos/logos/logo_mapping.yaml.
  self._setup()
/home/docs/checkouts/readthedocs.org/user_builds/figanos/conda/latest/lib/python3.14/site-packages/figanos/matplotlib/utils.py:599: UserWarning: Setting default logo to /home/docs/checkouts/readthedocs.org/user_builds/figanos/conda/latest/lib/python3.14/site-packages/figanos/data/figanos_logo.png
  logos = Logos()
[8]:
<GeoAxes: title={'center': 'Custom levels'}, xlabel='lon [degrees_east]', ylabel='lat [degrees_north]'>
../_images/notebooks_figanos_maps_12_2.png
[9]:
# Create a custom colour map (refer to https://matplotlib.org/stable/tutorials/colors/colormap-manipulation.html#directly-creating-a-segmented-colormap-from-a-list)
from matplotlib.colors import LinearSegmentedColormap


custom_colors = ["darkorange", "gold", "lawngreen", "lightseagreen"]
custom_cmap = LinearSegmentedColormap.from_list("mycmap", custom_colors)
ax = fg.gridmap(
    da_pr,
    divergent=300,
    cmap=custom_cmap,
    plot_kw={"levels": 8, "cbar_kwargs": {"label": None}},
    show_time=(0.85, 0.8),
)
ax.set_title("Custom cmap")
[9]:
Text(0.5, 1.0, 'Custom cmap')
../_images/notebooks_figanos_maps_13_1.png

pcolormesh vs contourf#

By default, xarray plots two-dimensional DataArrays using the matplotlib pcolormesh function (see xarray.plot.pcolormesh). The contourf argument in gridmap allows the user to use xarray.plot.contourf function instead. This also implies the key-value pairs passed in plot_kw are passed to these functions.

At large scales, both of these functions create practically equivalent plots. However, their inner workings are inherently different, and these different ways of plotting data become apparent at small scales.

When using contourf, passing a value in levels is equivalent to passing it in plot_kw['levels'], meaning the number of levels on the plot might not be exactly the specified value.

[10]:
zoomed = ds_space["tx_max_p50"].sel(lat=slice(44, 46), lon=slice(-65, -60))

fig, axs = plt.subplots(1, 2, figsize=(10, 6), subplot_kw={"projection": ccrs.LambertConformal()})
fg.gridmap(
    ax=axs[0],
    data=zoomed,
    contourf=False,
    plot_kw={"levels": 10, "add_colorbar": False},
)
axs[0].set_title("pcolormesh")
fg.gridmap(
    ax=axs[1],
    data=zoomed,
    contourf=True,
    plot_kw={"levels": 10, "cbar_kwargs": {"shrink": 0.5, "label": None}},
)
axs[1].set_title("contourf")
[10]:
Text(0.5, 1.0, 'contourf')
../_images/notebooks_figanos_maps_15_1.png

Station Data on Maps#

Data that is georeferenced by coordinates (e.g. latitude and longitude) but is not on a grid can be plotted using the scattermap function. This function is practically identical to gridmap(), but introduces some new arguments (see examples below). The function essentially builds a basemap using cartopy and calls plt.scatter() to plot the data.

[11]:
# Create a fictional observational dataset from scratch
names = ["station_" + str(i) for i in np.arange(10)]
lat = 45 + np.random.rand(10) * 3
lon = np.linspace(-76, -70, 10)
tas = 20 + np.random.rand(10) * 7
tas[9] = np.nan
yrs = 10 + 30 * np.random.rand(10)
yrs[0] = np.nan

attrs = {
    "units": "degC",
    "standard_name": "air_temperature",
    "long_name": "Near-Surface Daily Maximum Air Temperature",
}

tas = xr.DataArray(
    data=tas,
    coords={
        "station": names,
        "lat": ("station", lat),
        "lon": ("station", lon),
        "years": ("station", yrs),
    },
    dims=["station"],
    attrs=attrs,
)
tas.name = "tas"
tas = tas.to_dataset()
tas.attrs["description"] = "Observations"

# Set nice features
features = {
    "land": {"color": "#f0f0f0"},
    "rivers": {"edgecolor": "#cfd3d4"},
    "lakes": {"facecolor": "#cfd3d4"},
    "coastline": {"edgecolor": "black"},
}

# Plot
ax = fg.scattermap(
    tas,
    sizes="years",
    size_range=(15, 100),
    divergent=23.5,
    features=features,
    plot_kw={
        "edgecolor": "black",
    },
    fig_kw={"figsize": (9, 6)},
    legend_kw={"loc": "lower left", "title": "Number of years of data"},
)
/tmp/ipykernel_1908/3473899604.py:40: UserWarning: 1 nan values were dropped when plotting the color values
  ax = fg.scattermap(
/tmp/ipykernel_1908/3473899604.py:40: UserWarning: 1 nan values were dropped when setting the point size
  ax = fg.scattermap(
/home/docs/checkouts/readthedocs.org/user_builds/figanos/conda/latest/lib/python3.14/site-packages/cartopy/io/__init__.py:242: DownloadWarning: Downloading: https://naturalearth.s3.amazonaws.com/10m_physical/ne_10m_land.zip
  warnings.warn(f'Downloading: {url}', DownloadWarning)
/home/docs/checkouts/readthedocs.org/user_builds/figanos/conda/latest/lib/python3.14/site-packages/cartopy/io/__init__.py:242: DownloadWarning: Downloading: https://naturalearth.s3.amazonaws.com/10m_physical/ne_10m_rivers_lake_centerlines.zip
  warnings.warn(f'Downloading: {url}', DownloadWarning)
/home/docs/checkouts/readthedocs.org/user_builds/figanos/conda/latest/lib/python3.14/site-packages/cartopy/io/__init__.py:242: DownloadWarning: Downloading: https://naturalearth.s3.amazonaws.com/10m_physical/ne_10m_lakes.zip
  warnings.warn(f'Downloading: {url}', DownloadWarning)
/home/docs/checkouts/readthedocs.org/user_builds/figanos/conda/latest/lib/python3.14/site-packages/cartopy/io/__init__.py:242: DownloadWarning: Downloading: https://naturalearth.s3.amazonaws.com/10m_physical/ne_10m_coastline.zip
  warnings.warn(f'Downloading: {url}', DownloadWarning)
../_images/notebooks_figanos_maps_17_1.png

It is possible to plot observations on top of gridded data by calling both gridmap() and scattermap() and fixing the colormap limits (vmin and vmax), like demonstrated below.

[13]:
# defining our limits
vmin = 20
vmax = 35

# plotting the gridded data
ax = fg.gridmap(
    ds_space - 273.15,
    plot_kw={"vmin": vmin, "vmax": vmax, "add_colorbar": False},
    features=["coastline", "ocean"],
    show_time="lower right",
)
ax.set_extent([-76.5, -69, 44.5, 52], crs=ccrs.PlateCarree())  # equivalent to set_xlim and set_ylim for projections

# plotting the observations
fg.scattermap(
    tas,
    ax=ax,
    transform=ccrs.PlateCarree(),
    plot_kw={"vmin": vmin, "vmax": vmax, "edgecolor": "grey"},
)
/tmp/ipykernel_1908/1721594135.py:15: UserWarning: 1 nan values were dropped when plotting the color values
  fg.scattermap(
[13]:
<GeoAxes: title={'center': 'Observations'}, xlabel='lon', ylabel='lat'>
/home/docs/checkouts/readthedocs.org/user_builds/figanos/conda/latest/lib/python3.14/site-packages/cartopy/io/__init__.py:242: DownloadWarning: Downloading: https://naturalearth.s3.amazonaws.com/10m_physical/ne_10m_ocean.zip
  warnings.warn(f'Downloading: {url}', DownloadWarning)
../_images/notebooks_figanos_maps_20_3.png

Hatching on Maps#

The hatchmap function plots hatches on top of a map. It is a thin wrap around the plt.contourf() function, with very similar functionality to gridmap() and similar data arguments to timeseries(). It can be overlaid on top of a map created with gridmap() as shown below. hatchmap can also be used with plt.contourf() levels in plot_kw.

[16]:
from figanos import pitou


# Helper function for loading testing data
p = pitou()

ens_stats = xr.open_dataset(p.fetch("hatchmap-ens_stats.nc")).prcptot_mean
sup_8 = xr.open_dataset(p.fetch("hatchmap-sup_8.nc")).changed
inf_5 = xr.open_dataset(p.fetch("hatchmap-inf_5.nc")).changed

ax = fg.gridmap(ens_stats, features=["coastline", "ocean"], frame=True)

fg.hatchmap(
    {"Over 0.8": sup_8, "Under 0.5": inf_5},
    ax=ax,
    plot_kw={"Over 0.8": {"hatches": "*"}},
    features=["coastline", "ocean"],
    frame=True,
    legend_kw={"title": "Ensemble change"},
)
ax.set_title("Ensemble plot - hatchmap and gridmap")
/home/docs/checkouts/readthedocs.org/user_builds/figanos/conda/latest/lib/python3.14/site-packages/tqdm/auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html
  from .autonotebook import tqdm as notebook_tqdm
Downloading file 'hatchmap-ens_stats.nc' from 'https://raw.githubusercontent.com/Ouranosinc/figanos/main/src/figanos/data/test_data/hatchmap-ens_stats.nc' to '/home/docs/.cache/figanos'.
Downloading file 'hatchmap-sup_8.nc' from 'https://raw.githubusercontent.com/Ouranosinc/figanos/main/src/figanos/data/test_data/hatchmap-sup_8.nc' to '/home/docs/.cache/figanos'.
Downloading file 'hatchmap-inf_5.nc' from 'https://raw.githubusercontent.com/Ouranosinc/figanos/main/src/figanos/data/test_data/hatchmap-inf_5.nc' to '/home/docs/.cache/figanos'.
/home/docs/checkouts/readthedocs.org/user_builds/figanos/conda/latest/lib/python3.14/site-packages/figanos/matplotlib/plot.py:692: UserWarning: Colormap warning: More than one variable group found. Use the cmap argument.
  get_var_group(da=plot_data),
/tmp/ipykernel_1908/1738902228.py:13: UserWarning: Hatches argument must be of type 'list'. Wrapping string argument as list.
  fg.hatchmap(
/home/docs/checkouts/readthedocs.org/user_builds/figanos/conda/latest/lib/python3.14/site-packages/cartopy/mpl/geoaxes.py:1631: UserWarning: The following kwargs were not used by contour: 'Over 0.8', 'Under 0.5'
  result = super().contourf(*args, **kwargs)
[16]:
Text(0.5, 1.0, 'Ensemble plot - hatchmap and gridmap')
../_images/notebooks_figanos_maps_24_2.png

GeoDataFrame on Maps#

The gdfmap function plots geometries contained in a GeoPandas GeoDataFrame on maps. It is a thin wrapper around the GeoDataFrame.plot() method, with very similar functionality to gridmap() and most of the same features.

To use this function, the data to be linked to the colormap has to be included in the GeoDataFrame. Its name (as a string) must be passed to the df_col argument. Like described above, if the cmap argument is None, the function will look for common variable names in the name of this column, and use an appropriate colormap if a match is found.

[18]:
import geopandas as gpd


qc_bound = gpd.read_file(
    "https://pavics.ouranos.ca/geoserver/public/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=public%3Aquebec_admin_boundaries&maxFeatures=50&outputFormat=application%2Fjson",
)
qc_bound["pr"] = qc_bound["RES_CO_REG"].astype(float)  # create fake precipitation data

ax = fg.gdfmap(
    qc_bound,
    "pr",
    levels=16,
    plot_kw={"legend_kwds": {"label": "Fake precipitation (fake units)"}},
)
ERROR 1: PROJ: proj_create_from_database: Open of /home/docs/checkouts/readthedocs.org/user_builds/figanos/conda/latest/share/proj failed
../_images/notebooks_figanos_maps_27_1.png

It is also possible to only plot de boundaries with no values.

[20]:
fg.gdfmap(
    qc_bound,
    "boundary",
    plot_kw={"color": "purple"},
)
[20]:
<GeoAxes: >
../_images/notebooks_figanos_maps_30_1.png

Projections can be used like in gridmap(), although some of the Cartopy projections might lead to unexpected results due to the interaction between Cartopy and GeoPandas, especially when the whole globe is plotted.

Also note that the colorbar parameters have to be accessed through the legend_kwds argument of GeoDataFrame.plot().

[22]:
# # trick to be able to load the data
import io

import requests


url = "https://www.donneesquebec.ca/recherche/dataset/11a317d0-97a2-4896-85b5-4cb26ccf5dc6/resource/4c6fe152-8c82-4d36-a8e0-9b584b9cde18/download/cours-eau-v3r.json"
# Fetch with browser-like headers
response = requests.get(url, timeout=10, headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"})

r = gpd.read_file(io.BytesIO(response.content))
ax = fg.gdfmap(
    r,
    "OBJECTID",
    cmap="cool",
    # projection=ccrs.Mercator(),
    features={"ocean": {"color": "#a2bdeb"}},
    plot_kw={"legend_kwds": {"orientation": "vertical"}},
    frame=True,
)
ax.set_title("Waterways of Trois-Rivières")
[22]:
Text(0.5, 1.0, 'Waterways of Trois-Rivières')
../_images/notebooks_figanos_maps_33_1.png