Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

gdfmap #54

Merged
merged 3 commits into from
Apr 4, 2023
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
208 changes: 208 additions & 0 deletions docs/notebooks/tests/gdfmap_test.ipynb

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion spirograph/matplotlib/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
from .plot import gridmap, timeseries
from .plot import gdfmap, gridmap, timeseries
from .utils import categorical_colors, set_mpl_style
150 changes: 140 additions & 10 deletions spirograph/matplotlib/plot.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,26 @@
from __future__ import annotations

import math
import warnings
from pathlib import Path
from typing import Any, Optional
from typing import Any

import cartopy.feature as cfeature # noqa
import cartopy.mpl.geoaxes
import geopandas as gpd
import matplotlib.axes
import matplotlib.cm
import matplotlib.colors
import matplotlib.pyplot as plt
import numpy as np
import xarray as xr
from cartopy import crs as ccrs

from spirograph.matplotlib.utils import (
add_cartopy_features,
cbar_ticks,
check_timeindex,
clean_cmap_bounds,
convert_scen_name,
create_cmap,
empty_dict,
Expand Down Expand Up @@ -334,7 +341,7 @@ def gridmap(
plot_kw: dict[str, Any] | None = None,
projection: ccrs.Projection = ccrs.LambertConformal(),
transform: ccrs.Projection | None = None,
features: list | dict[str, Any] | None = None,
features: list[str] | dict[str, dict[str, Any]] | None = None,
geometries_kw: dict[str, Any] | None = None,
contourf: bool = False,
cmap: str | matplotlib.colors.Colormap | None = None,
Expand Down Expand Up @@ -367,12 +374,12 @@ def gridmap(
ccrs.PlateCarree() or ccrs.RotatedPole().
features : list or dict, optional
Features to use, as a list or a nested dict containing kwargs. Options are the predefined features from
cartopy.feature: ['coastline', 'borders', 'lakes', 'land', 'ocean', 'rivers'].
cartopy.feature: ['coastline', 'borders', 'lakes', 'land', 'ocean', 'rivers', 'states'].
geometries_kw : dict, optional
Arguments passed to cartopy ax.add_geometry() which adds given geometries (GeoDataFrame geometry) to axis.
contourf : bool
By default False, use plt.pcolormesh(). If True, use plt.contourf().
cmap : colormap or str, optional
cmap : matplotlib.colors.Colormap or str, optional
Colormap to use. If str, can be a matplotlib or name of the file of an IPCC colormap (see data/ipcc_colors).
If None, look for common variables (from data/ipcc_colors/varaibles_groups.json) in the name of the DataArray
or its 'history' attribute and use corresponding colormap, aligned with the IPCC visual style guide 2022
Expand Down Expand Up @@ -492,12 +499,8 @@ def gridmap(
pl = plot_data.plot.contourf(ax=ax, transform=transform, cmap=cmap, **plot_kw)

# add features
if isinstance(features, list):
for f in features:
ax.add_feature(getattr(cfeature, f.upper()))
if isinstance(features, dict):
for f in features:
ax.add_feature(getattr(cfeature, f.upper()), **features[f])
if features:
add_cartopy_features(ax, features)

if show_time is True:
plot_coords(ax, plot_data, param="time", backgroundalpha=0)
Expand Down Expand Up @@ -532,3 +535,130 @@ def gridmap(
ax.add_geometries(**geometries_kw)

return ax


def gdfmap(
df: gpd.GeoDataFrame,
df_col: str,
ax: cartopy.mpl.geoaxes.GeoAxes | cartopy.mpl.geoaxes.GeoAxesSubplot | None = None,
fig_kw: dict[str, Any] | None = None,
plot_kw: dict[str, Any] | None = None,
projection: ccrs.Projection = ccrs.PlateCarree(),
features: list[str] | dict[str, dict[str, Any]] | None = None,
cmap: str | matplotlib.colors.Colormap | None = "slev_seq",
levels: int | list[int | float] | None = None,
cbar: bool = True,
frame: bool = False,
) -> matplotlib.axes.Axes:
"""
Create a map plot from geometries.

Parameters
----------
df: geopandas.GeoDataFrame
Dataframe containing the geometries and the data to plot. Must have a column named 'geometry'.
df_col: str
Name of the column of 'df' containing the data to plot using the colorscale.
ax: cartopy.mpl.geoaxes.GeoAxes or cartopy.mpl.geoaxes.GeoaxesSubplot, optional
Matplotlib axis built with a projection, on which to plot.
fig_kw : dict, optional
Arguments to pass to `plt.figure()`.
plot_kw: dict, optional
Arguments to pass to the GeoDataFrame.plot() method.
projection : ccrs.Projection
The projection to use, taken from the cartopy.crs options.
features : list or dict, optional
Features to use, as a list or a nested dict containing kwargs. Options are the predefined features from
cartopy.feature: ['coastline', 'borders', 'lakes', 'land', 'ocean', 'rivers', 'states'].
cmap : matplotlib.colors.Colormap or str
Colormap to use. If str, can be a matplotlib or name of the file of an IPCC colormap (see data/ipcc_colors).
If None, look for common variables (from data/ipcc_colors/varaibles_groups.json) in the name of df_col
and use corresponding colormap, aligned with the IPCC visual style guide 2022
(https://www.ipcc.ch/site/assets/uploads/2022/09/IPCC_AR6_WGI_VisualStyleGuide_2022.pdf).
levels : int or list, optional
Number of levels or list of level boundaries (in data units) to use to divide the colormap.
cbar : bool
Beauprel marked this conversation as resolved.
Show resolved Hide resolved
Show colorbar. Default 'True'.
frame : bool
Show or hide frame. Default False.

Returns
-------
matplotlib.axes.Axes
"""
# create empty dicts if None
fig_kw = empty_dict(fig_kw)
plot_kw = empty_dict(plot_kw)
features = empty_dict(features)

# checks
if not isinstance(df, gpd.GeoDataFrame):
raise TypeError("df myst be an instance of class geopandas.GeoDataFrame")

if "geometry" not in df.columns:
raise ValueError("column 'geometry' not found in GeoDataFrame")

# convert to projection
df = gpd_to_ccrs(df=df, proj=projection)

# setup fig, ax
if not ax:
fig, ax = plt.subplots(subplot_kw={"projection": projection}, **fig_kw)
ax.set_aspect("equal") # recommended by geopandas

# add features and defaults
default_features = {
"land": {"color": "#f0f0f0"},
"rivers": {"edgecolor": "#cfd3d4"},
"lakes": {"facecolor": "#cfd3d4"},
"coastline": {"edgecolor": "black"},
}
features = default_features | features
add_cartopy_features(ax, features)

# colormap
if isinstance(cmap, str):
if cmap in plt.colormaps():
cmap = matplotlib.cm.get_cmap(cmap)
else:
try:
cmap = create_cmap(filename=cmap)
except FileNotFoundError:
warnings.warn("invalid cmap, using default")
cmap = create_cmap(filename="slev_seq")

elif cmap is None:
cdata = Path(__file__).parents[1] / "data/ipcc_colors/variable_groups.json"
cmap = create_cmap(get_var_group(unique_str=df_col, path_to_json=cdata))

# create normalization for colormap
if levels:
if isinstance(levels, int):
lin_levels = clean_cmap_bounds(
df[df_col].min(), df[df_col].max(), levels=levels
)
norm = matplotlib.colors.BoundaryNorm(boundaries=lin_levels, ncolors=cmap.N)
elif isinstance(levels, list):
norm = matplotlib.colors.BoundaryNorm(boundaries=levels, ncolors=cmap.N)
else:
raise TypeError("levels must be int or list")
plot_kw.setdefault("norm", norm)

# colorbar
if cbar:
plot_kw.setdefault("legend", True)
plot_kw["legend_kwds"].setdefault("label", df_col)
plot_kw["legend_kwds"].setdefault("orientation", "horizontal")
plot_kw["legend_kwds"].setdefault("pad", 0.02)

# plot
plot = df.plot(column=df_col, ax=ax, cmap=cmap, **plot_kw)

if frame is False:
# cbar
plot.figure.axes[1].spines["outline"].set_visible(False)
plot.figure.axes[1].tick_params(size=0)
# main axes
ax.spines["geo"].set_visible(False)

return ax
87 changes: 74 additions & 13 deletions spirograph/matplotlib/utils.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
from __future__ import annotations

import json
import math
import pathlib
import re
import warnings
from pathlib import Path
from typing import Any, Callable, Optional
from typing import Any, Callable

import cartopy.crs as ccrs
import cartopy.feature as cfeature # noqa
import geopandas
import geopandas as gpd
import matplotlib as mpl
Expand Down Expand Up @@ -401,29 +403,40 @@ def fill_between_label(
return label


def get_var_group(da: xr.DataArray, path_to_json: str | pathlib.Path) -> str:
"""Get IPCC variable group from DataArray using a json file (spirograph/data/ipcc_colors/variable_groups.json)."""
def get_var_group(
path_to_json: str | pathlib.Path,
da: xr.DataArray | None = None,
unique_str: str = None,
) -> str:
"""Get IPCC variable group from DataArray or a string using a json file (spirograph/data/ipcc_colors/variable_groups.json)."""

# create dict
with open(path_to_json) as f:
var_dict = json.load(f)

matches = []

# look in DataArray name
if hasattr(da, "name"):
for v in var_dict:
regex = rf"(?:^|[^a-zA-Z])({v})(?:[^a-zA-Z]|$)"
if re.search(regex, da.name):
matches.append(var_dict[v])

# look in history
if hasattr(da, "history") and len(matches) == 0:
if unique_str:
for v in var_dict:
regex = rf"(?:^|[^a-zA-Z])({v})(?:[^a-zA-Z]|$)" # matches when variable is not inside word
if re.search(regex, da.history):
if re.search(regex, unique_str):
matches.append(var_dict[v])

elif da:
# look in DataArray name
if hasattr(da, "name"):
for v in var_dict:
regex = rf"(?:^|[^a-zA-Z])({v})(?:[^a-zA-Z]|$)"
if re.search(regex, da.name):
matches.append(var_dict[v])

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

matches = np.unique(matches)

if len(matches) == 0:
Expand Down Expand Up @@ -711,3 +724,51 @@ def set_mpl_style(*args: str, reset: bool = False) -> None:
mpl.style.use(get_mpl_styles()[s])
else:
warnings.warn(f"Style {s} not found.")


def add_cartopy_features(
ax: matplotlib.axes.Axes, features: list[str] | dict[str, dict[str, Any]]
) -> matplotlib.axes.Axes:
"""
Add cartopy features to matplotlib axes.

Parameters
----------
ax : matplotlib.axes.Axes
The axes on which to add the features.

features : list or dict
List of features, or nested dictionary of format {'feature': {'kwarg':'value'}}

Returns
-------
matplotlib.axes.Axes
The axis with added features.
"""

if isinstance(features, list):
features = {f: {} for f in features}

for f in features:
ax.add_feature(getattr(cfeature, f.upper()), **features[f])

return ax


def clean_cmap_bounds(min: int | float, max: int | float, levels: int) -> np.ndarray:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

je ne suis pas sur de comprendre à quoi ça sert..

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Essentiellement juste à ce que le minimum et maximum de la colormap soient arrondis, et donc plus clean, avec le bénéfice supplémentaire que les subdivisions de la colormap seront aussi plus beaux, parce qu'on utilise linspace.

"""Get nicer cmap boundaries to use in a BoundaryNorm."""

if (max - min) >= 10:
rmax = np.round(math.ceil(max), -1)
rmin = np.round(math.floor(min), -1)
elif 1 <= (max - min) < 10:
rmax = np.round(math.ceil(max), 0)
rmin = np.round(math.floor(min), 0)
elif 0.1 <= (max - min) < 1:
rmax = np.round(math.ceil(max), 1)
rmin = np.round(math.floor(min), 1)
else:
rmax = np.round(math.ceil(max), 2)
rmin = np.round(math.floor(min), 2)

return np.linspace(rmin, rmax, num=levels + 1)