"""Generic, DAG-based plot functions for the
:py:class:`~dantro.plot.creators.pyplot.PyPlotCreator` and derived plot
creators.
"""
import copy
import logging
import math
import numbers
import warnings
from functools import partial as _partial
from typing import Callable, Dict, List, Tuple, Union
import matplotlib.colors as mcolors
from ..._import_tools import LazyLoader
from ...exceptions import PlottingError
from ...tools import recursive_update
from ..plot_helper import PlotHelper
from ..utils import figure_leak_prevention, is_plot_func
from ..utils.color_mngr import ColorManager, parse_cmap_and_norm_kwargs
from ._utils import plot_errorbar as _plot_errorbar
# Local constants and lazy module imports
log = logging.getLogger(__name__)
xr = LazyLoader("xarray")
# .............................................................................
# fmt: off
_XR_PLOT_KINDS = { # --- start literalinclude
"scatter": ("hue", "col", "row"),
"line": ("x", "hue", "col", "row"),
"step": ("x", "col", "row"),
"contourf": ("x", "y", "col", "row"),
"contour": ("x", "y", "col", "row"),
"imshow": ("x", "y", "col", "row"),
"pcolormesh": ("x", "y", "col", "row"),
"hist": (),
} # --- end literalinclude
"""The available plot kinds for the *xarray* plotting interface, together with
the supported layout specifier keywords."""
_FACET_GRID_KINDS = {
# based on xarray plotting functions
"scatter": ("hue", "col", "row", "frames"),
"line": ("x", "hue", "col", "row", "frames"),
"step": ("x", "col", "row", "frames"),
"contourf": ("x", "y", "col", "row", "frames"),
"contour": ("x", "y", "col", "row", "frames"),
"imshow": ("x", "y", "col", "row", "frames"),
"pcolormesh": ("x", "y", "col", "row", "frames"),
"hist": ("frames",),
# based on dantro plotting functions
# NOTE These are dynamically added but generally look similar to the above:
# "errorbars": ("x", "hue", "col", "row", "frames"),
# "scatter3d": ("hue", "col", "row", "frames"),
}
"""The available plot kinds for the *dantro* plotting interface, together with
the supported layout specifiers, which include the ``frames`` option."""
_AUTO_PLOT_KINDS = { # --- start literalinclude
1: "line",
2: "pcolormesh",
3: "pcolormesh",
4: "pcolormesh",
5: "pcolormesh",
"with_hue": "line", # used when `hue` is explicitly set
"with_x_and_y": "pcolormesh", # used when _both_ `x` and `y` were set
"dataset": "scatter", # used for xr.Dataset-like data
"fallback": "hist", # used when none of the above matches
} # --- end literalinclude
"""A mapping from data dimensionality to preferred plot kind, used in automatic
plot kind selection. This assumes the specifiers of ``_FACET_GRID_KINDS``"""
# fmt: on
_FACET_GRID_FUNCS: Dict[str, Callable] = {}
"""A dict mapping additional facet grid kinds to callables.
This is populated by the ``make_facet_grid_plot`` decorator."""
# -----------------------------------------------------------------------------
# -- Helper functions ---------------------------------------------------------
# -----------------------------------------------------------------------------
[docs]def determine_plot_kind(
d: Union["xarray.DataArray", "xarray.Dataset"],
*,
kind: Union[str, dict],
default_kind_map: dict = _AUTO_PLOT_KINDS,
**plot_kwargs,
) -> str:
"""Determines the plot kind to use for the given data. If ``kind: auto``,
this will determine the plot kind depending on the dimensionality of the
data and other (potentially fixed) encoding specifiers. Otherwise, it will
simply return ``kind``.
**What if layout encodings were partly fixed?** There are two special cases
where this is of relevance, and both these cases are covered explicitly:
- If *both* ``x`` and ``y`` are given, ``line``- or ``hist``-like plot
kinds are no longer possible; hence, a ``pcolormesh``-like kind has
to be chosen.
- In turn, if ``hue`` was given, ``pcolormesh``-like plot kinds are no
longer applicable, thus a ``line``-like argument needs to be chosen.
These two special cases are specified via the extra keys ``with_x_and_y``
and ``with_hue`` in the kind mapping.
A kind mapping may look like this:
.. literalinclude:: ../../dantro/plot/funcs/generic.py
:language: python
:start-after: _AUTO_PLOT_KINDS = { # --- start literalinclude
:end-before: } # --- end literalinclude
:dedent: 4
Args:
d (Union[xarray.DataArray, xarray.Dataset]): The data for which to
determine the plot kind.
kind (Union[str, dict]): The given kind argument. If it is ``auto``,
the ``kind_map`` is used to determine the ``kind`` from the
dimensionality of ``d``.
If it is a dict, ``auto`` is implied and the dict is assumed to be
a (ndim -> kind) mapping, *updating* the ``default_kind_map``.
default_kind_map (dict, optional): The default mapping to use for
``kind: auto``, with keys being ``d``'s dimensionality and values
being the plot kind to use.
There are two special keys, ``fallback`` and ``dataset``. The
value belonging to ``dataset`` is used for data that is dataset-
like, i.e. does not have an ``ndim`` attribute. The value of
``fallback`` specifies the plot kind for data dimensionalities
that match no other key.
**plot_kwargs: All remaining plot function arguments, including any
layout encoding arguments that aim to *fix* a dimension; these are
used to determine the ``with_hue`` and ``with_x_and_y`` special
cases. Everything else is ignored.
Returns:
str: The selected plot kind. This is equal to the *given* ``kind`` if
it was None or a string unequal to ``auto``.
"""
# Was the plot kind already specified?
if kind is None or (isinstance(kind, str) and kind != "auto"):
# Yes. Just return that value.
return kind
# else: Need to determine it by inspecting the data and the kind mapping.
# First, determine the mapping.
kind_map = copy.deepcopy(default_kind_map)
if isinstance(kind, dict):
kind_map.update(kind)
# Handle special cases ...
# ... for datasets: always fall back to the specified default kind
if not hasattr(d, "ndim"):
return kind_map["dataset"]
# ... for given x *and* y layout specifiers
elif plot_kwargs.get("x") and plot_kwargs.get("y"):
return kind_map["with_x_and_y"]
# ... for given hue layout specifier
elif plot_kwargs.get("hue"):
return kind_map["with_hue"]
# Select the kind from the dimensionality. If this fails, use the default
# value instead.
try:
kind = kind_map[d.ndim]
except KeyError:
kind = kind_map["fallback"]
log.remark("Using plot kind '%s' for %d-dimensional data.", kind, d.ndim)
return kind
[docs]def determine_encoding(
dims: Union[List[str], Dict[str, int]],
*,
kind: str,
auto_encoding: Union[bool, dict],
default_encodings: dict,
allow_y_for_x: List[str] = ("line",),
plot_kwargs: dict,
) -> dict:
"""Determines the layout encoding for the given plot kind and the available
data dimensions (as specified by the ``dims`` argument).
If ``auto_encoding`` does not evaluate to true or ``kind is None``, this
function does nothing and simply returns all given plotting arguments.
Otherwise, it uses the chosen plot ``kind`` to associate layout specifiers
with dimension names of ``d``.
The available layout encoding specifiers (``x``, ``y``, ``col`` etc.) can
be specified in two ways:
- By default, ``default_encodings`` is used as a map from plot kind to
a sequence of available layout specifiers.
- If ``auto_encoding`` is a dictionary, the default map will be
*updated* with that dictionary.
The association is done in the following way:
1. Inspecting ``plot_kwargs``, all layout encoding specifiers are
extracted, dropping those that evaluate to False.
2. The encodings mapping is determined (see above).
3. The available dimension names are determined from ``dims``.
4. Depending on ``kind`` and the already fixed specifiers, the *free*
encoding specifiers and dimension names are extracted.
5. These free specifiers are associated with free dimension names,
in order of descending dimension size.
**Example:** Assume, the available specifiers are ``('x', 'y', 'col')`` and
the data has dimensions ``dim0``, ``dim1`` and ``dim2``. Let's further say
that ``y`` was already fixed to ``dim2``, leaving ``x`` and ``col`` as free
specifiers and ``dim0`` and ``dim1`` as free dimensions.
With ``x`` being specified before ``col`` in the list of available
specifiers, ``x`` would be associated to the remaining dimension with the
*larger* size and ``col`` to the remaining one.
An encodings mapping may look like this:
.. literalinclude:: ../../dantro/plot/funcs/generic.py
:language: python
:start-after: _XR_PLOT_KINDS = { # --- start literalinclude
:end-before: } # --- end literalinclude
:dedent: 4
This function also implements **automatic column wrapping**, aiming to
produce a more square-like figure with column wrapping. The prerequisites
are the following:
* The ``dims`` argument is a dict, containing size information
* The ``col_wrap`` argument is given and set to ``"auto"``
* The ``col`` specifier is in use
* The ``row`` specifier is *not* used, i.e. wrapping is possible
* There are more than three columns
In such a case, ``col_wrap`` will be set to ``ceil(sqrt(num_cols))``.
Otherwise, the entry will be removed from the plot arguments.
Args:
dims (Union[List[str], Dict[str, int]]): The dimension names (and, if
given as dict: their sizes) that are to be encoded. If no sizes are
provided, the assignment order will be the same as in the given
sequence of dimension names. If sizes are given, these will be used
to sort the dimension names in descending order of their sizes.
kind (str): The chosen plot kind. If this was None, will directly
return, because auto-encoding information is missing.
auto_encoding (Union[bool, dict]): Whether to perform auto-encoding.
If a dict, will regard it as a mapping of available encodings and
update ``default_encodings``.
default_encodings (dict): A map from plot kinds to available layout
specifiers, e.g. ``{"line": ("x", "hue", "col", "row")}``.
allow_y_for_x (List[str], optional): A list of plot kinds for which the
following replacement will be allowed: if a ``y`` specifier is
given but *no* ``x`` specifier, the ``"x"`` in the list of
available encodings will be replaced by a ``"y"``. This is to
support plots that allow *either* an ``x`` or a ``y`` specifier,
like the ``line`` kind.
plot_kwargs (dict): The actual plot function arguments, including any
layout encoding arguments that aim to *fix* a dimension. Everything
else is ignored.
"""
if not auto_encoding or kind is None:
log.debug("Layout auto-encoding was disabled (kind: %s).", kind)
return plot_kwargs
log.note(
"Automatically determining layout encoding for kind '%s' ...", kind
)
# Evaluate supported encodings, then get the available encoding specifiers
encs = copy.deepcopy(default_encodings)
if isinstance(auto_encoding, dict):
encs.update(auto_encoding)
encoding_specs = encs[kind]
# Special case for line-like kinds
if allow_y_for_x and kind in allow_y_for_x:
if plot_kwargs.get("y") and not plot_kwargs.get("x"):
encoding_specs = tuple(
s if s != "x" else "y" for s in encoding_specs
)
# Split plotting kwargs into a dict of layout specifiers and one that only
# includes the remaining plotting kwargs
plot_kwargs = copy.deepcopy(plot_kwargs)
specs = {k: v for k, v in plot_kwargs.items() if k in encoding_specs}
plot_kwargs = {k: v for k, v in plot_kwargs.items() if k not in specs}
# -- Determine specifiers, depending on kind and dimensionality
# Get all available dimension names. If size-information is available,
# sort them by size (descending), otherwise just use them as they are.
if isinstance(dims, dict):
dim_names = [
name
for name, _ in sorted(
dims.items(), key=lambda kv: kv[1], reverse=True
)
]
else:
dim_names = list(dims)
# Some dimensions and specifiers might already have been associated;
# determine those that have *not* yet been associated:
free_specs = [s for s in encoding_specs if not specs.get(s)]
free_dim_names = [name for name in dim_names if name not in specs.values()]
log.debug(" given specifiers: %s", specs)
log.debug(" free specifiers: %s", ", ".join(free_specs))
log.debug(" free dimensions: %s", ", ".join(free_dim_names))
# From these two lists, update the specifier dictionary
specs.update(
{s: dim_name for s, dim_name in zip(free_specs, free_dim_names)}
)
# Drop those specifiers that are effectively unset.
specs = {s: dim_name for s, dim_name in specs.items() if dim_name}
# Provide information about the chosen encoding
log.remark(
" encoding: %s",
", ".join([f"{s}: {d}" for s, d in specs.items()]),
)
log.remark(
" free: %s",
", ".join([k for k in encoding_specs if k not in specs]),
)
# -- Automatic column wrapping
if plot_kwargs.get("col_wrap") == "auto":
if (
not specs.get("row")
and specs.get("col")
and hasattr(dims, "items") # i.e.: dict-like
and dims[specs["col"]] > 3
):
num_cols = dims[specs["col"]]
plot_kwargs["col_wrap"] = math.ceil(math.sqrt(num_cols))
log.remark(
" col_wrap: %d (length of col dimension: %d)",
plot_kwargs["col_wrap"],
num_cols,
)
else:
# Remove it to avoid a plot warning or "unexpected argument"
del plot_kwargs["col_wrap"]
# Finally, return the merged layout specifiers and plot kwargs
return dict(**plot_kwargs, **specs)
[docs]class make_facet_grid_plot:
"""This is a decorator class that transforms a plot function that works on
a single axis into one that supports faceting via
:py:class:`xarray.plot.FacetGrid`.
Additionally, it allows to register the plotting function with the generic
:py:func:`~dantro.plot.funcs.generic.facet_grid` plot by adding the
callable to ``_FACET_GRID_FUNCS``.
"""
MAP_FUNCS = {
"dataset": lambda fg, f, **kws: fg.map_dataset(f, **kws),
"dataarray": lambda fg, f, **kws: fg.map_dataarray(f, **kws),
"dataarray_line": lambda fg, f, **kws: fg.map_dataarray_line(f, **kws),
}
"""The available mapping functions in :py:class:`xarray.plot.FacetGrid`"""
DEFAULT_ENCODINGS = ("col", "row", "frames")
"""The default encodings the facet grid supplies; these are those supported
by the generic facet grid function"""
DEFAULT_DROP_KWARGS = ("_fg", "meta_data", "hue_style", "add_guide")
"""The default kwargs that are to be dropped rather than passed on to the
wrapped plotting function.
Can be customized via ``drop_kwargs`` argument."""
[docs] def __init__(
self,
*,
map_as: str,
encodings: Tuple[str],
supported_hue_styles: Tuple[str] = None,
register_as_kind: Union[bool, str] = True,
overwrite_existing: bool = False,
drop_kwargs: Tuple[str] = DEFAULT_DROP_KWARGS,
parse_cmap_and_norm_kwargs: bool = True,
**default_kwargs,
):
"""Initialize the decorator, making the decorated function capable of
performing a facet grid plot.
Args:
map_as (str): Which mapping to use. Available: ``dataset``,
``dataarray`` and ``dataarray_line``.
encodings (Tuple[str]): The encodings supported by the wrapped
plot function, e.g. ``("x", "hue")``.
Note that these *need to be dimensionality-reducing encodings*
that have a qualitatively similar effect as ``col`` & ``row``
in that they consume a data *dimension*. This is in contrast to
plots that may represent multiple data *variables*, e.g. if the
data comes from a :py:class:`xarray.Dataset`; those should not
be specified here.
supported_hue_styles (Tuple[str]): Which hue styles are
supported by the wrapped plot function. It is suggested to set
this value if mapping via ``dataset`` or ``dataarray_line`` in
order to disallow configurations that will not work with the
wrapped plot function. If set to None, no check will be done.
register_as_kind (Union[bool, str], optional): If boolean, controls
*whether* to register the wrapped function with the generic
facet grid plot, using its own name. If a string, uses that
name for registration.
overwrite_existing (bool, optional): Whether to overwrite an
existing registration in ``_FACET_GRID_FUNCS``. If False, an
existing entry of the same ``register_as_kind`` value will
lead to an error.
drop_kwargs (Tuple[str], optional): Which keyword arguments to
drop before invocation of the wrapped function; this can be
useful to trim down the signature of the wrapped function.
parse_cmap_and_norm_kwargs (bool, optional): Whether to parse
colormap-related plot function arguments using the
:py:func:`~dantro.plot.utils.color_mngr.parse_cmap_and_norm_kwargs`
function. Should be set to false if the decorated plot function
takes care of these arguments itself.
**default_kwargs: Additional arguments that are passed to the
single-axis plotting function. These are used both when calling
it via the selected mapping function and when invoking it
without a facet grid.
These are recursively updated with those given upon plot
function invocation.
"""
try:
self.map_func = self.MAP_FUNCS[map_as]
except KeyError as exc:
raise ValueError(
f"Unsupported value for `map_as` argument: '{map_as}'! Needs "
f"to be one of: {', '.join(self.MAP_FUNCS)}"
)
self.encodings = encodings
self.supported_hue_styles = supported_hue_styles
self.register_as_kind = register_as_kind
self.overwrite_existing = overwrite_existing
self.drop_kwargs = drop_kwargs if drop_kwargs else ()
self.default_kwargs = default_kwargs
self.parse_cmap_and_norm_kwargs = parse_cmap_and_norm_kwargs
[docs] def parse_wpf_kwargs(self, data, **kwargs) -> dict:
"""Parses the keyword arguments in preparation for invoking the wrapped
plot function. This can happen both in context of a facet grid mapping
and a single invocation.
"""
# Update from defaults
kwargs = recursive_update(copy.deepcopy(self.default_kwargs), kwargs)
# Some checks
if (
self.supported_hue_styles is not None
and "hue_style" in kwargs
and kwargs["hue_style"] not in self.supported_hue_styles
):
raise ValueError(
f"The selected `hue_style` '{kwargs['hue_style']}' is not "
"supported for this plotting function! May only be: "
f"{', '.join(self.supported_hue_styles)}"
)
# Parse colormap-related arguments
if self.parse_cmap_and_norm_kwargs:
kwargs = parse_cmap_and_norm_kwargs(**copy.deepcopy(kwargs))
# Can do more pre-processing here
# ...
return kwargs
[docs] def __call__(self, plot_single_axis: Callable) -> Callable:
"""Generates a standalone DAG-based plotting function that supports
faceting. Additionally, integrates it as ``kind`` for the
general facet grid plotting function by adding it to the global
``_FACET_GRID_FUNCS`` dictionary.
"""
# First, wrap the single-axis plot function to achieve helper support
def wrapped_plot_func(
*args,
hlpr: PlotHelper,
_is_facetgrid: bool,
ax=None,
_fg: "xr.plot.FacetGrid" = None,
**kwargs,
):
"""Wraps the single-axis plotting function and performs the
following additional operations before invoking it:
1. Sync the plot helper to the given axis (if faceting)
2. Evaluates ``drop_kwargs`` to reduce the passed arguments
"""
# If this is called as part of a facet grid plot, we need to sync
# the helper to the given axis, otherwise the helper cannot be used
if _is_facetgrid:
hlpr.select_axis(ax=ax)
# Prepare kwargs, optionally dropping some keys that bloat the
# function signature ...
kwargs["_fg"] = _fg
kwargs["_is_facetgrid"] = _is_facetgrid
kwargs = {
k: v for k, v in kwargs.items() if k not in self.drop_kwargs
}
# Now invoke the single-axis plotting function
return plot_single_axis(*args, hlpr=hlpr, **kwargs)
# Get the mapping function
map_to_facet_grid = self.map_func
# Now, generate the facet-grid supporting function
def fgplot(
data,
*,
hlpr=None,
col: str = None,
row: str = None,
col_wrap: int = None,
sharex: bool = True,
sharey: bool = True,
figsize: tuple = None,
aspect: float = 1.0,
size: float = 3.0,
subplot_kws: dict = None,
**kwargs,
):
"""A facet-grid capable version of the given plot function.
Explicitly named arguments here are passed to the setup of the
:py:class:`xarray.plot.FacetGrid`; all ``kwargs`` are passed on to
the selected mapping function and subsequently: the wrapped
single-axis plot function.
"""
# Without columns or rows, cannot use facet grid. Make a primitive
# plot instead, directly using the wrapped plot function.
if not col and not row:
log.debug("No `col` or `row` set. Not using a facet grid.")
kwargs = self.parse_wpf_kwargs(data, **kwargs)
log.debug(
"Invoking single-axis plot function with kwargs: %s",
kwargs,
)
hlpr.setup_figure() # TODO Find out why this is necessary ...
return wrapped_plot_func(
data, hlpr=hlpr, _is_facetgrid=False, **kwargs
)
# Prepare facet grid and helper
log.debug(
"Setting up a facet grid (col: %s, row: %s) ...", col, row
)
fg = xr.plot.FacetGrid(
data,
col=col,
row=row,
col_wrap=col_wrap,
sharex=sharex,
sharey=sharey,
figsize=figsize,
aspect=aspect,
size=size,
subplot_kws=subplot_kws if subplot_kws else {},
)
hlpr.attach_figure_and_axes(fig=fg.fig, axes=fg.axs)
# Make the FacetGrid object available to the helper
hlpr._attrs["facet_grid"] = fg
# Parse arguments expected by wrapped plot function
kwargs = self.parse_wpf_kwargs(data, **kwargs)
# Prepare mapping keyword arguments and apply the mapping
log.debug("Invoking mapping function with kwargs %s ...", kwargs)
try:
map_to_facet_grid(
fg, wrapped_plot_func, hlpr=hlpr, _fg=fg, **kwargs
)
except Exception as exc:
raise PlottingError(
f"Failed mapping {type(data)} data to facet grid! Check "
"the given arguments, dimensionality, dimension names, "
"and whether the dimensions have coordinates associated.\n"
f"Got a {type(exc).__name__}: {exc}"
) from exc
# Return the FacetGrid object for further handling
return fg
# facet grid plot function constructed now.
# ... register it as a single-axis facet grid plot kind.
if self.register_as_kind:
if isinstance(self.register_as_kind, str):
regname = self.register_as_kind
else:
regname = plot_single_axis.__name__
if regname in _FACET_GRID_FUNCS or regname in _XR_PLOT_KINDS:
if not self.overwrite_existing:
_in_use = ", ".join(
list(_FACET_GRID_FUNCS) + list(_XR_PLOT_KINDS)
)
raise ValueError(
f"The plot function name '{regname}' is already used! "
"Either set `register_as_kind` to a different value, "
"or set `overwrite_existing`. Registered functions: "
f"{_in_use}"
)
# Register the callable for the non-standalone case
_FACET_GRID_FUNCS[regname] = fgplot
log.debug("Registered '%s' as special facet grid kind.", regname)
_FACET_GRID_KINDS[regname] = (
self.encodings + self.DEFAULT_ENCODINGS
)
log.debug(
"Registered '%s' encodings: %s",
regname,
", ".join(_FACET_GRID_KINDS[regname]),
)
# Build the standalone plot function, which takes the place of the
# decorated plot function
@is_plot_func(use_dag=True, required_dag_tags=("data",))
def standalone(*, data: dict, hlpr: PlotHelper, **kwargs):
try:
return fgplot(data["data"], hlpr=hlpr, **kwargs)
except Exception as exc:
raise PlottingError(
"Standalone facet grid plotting for plot function "
f"'{plot_single_axis.__name__}' failed!\n"
f"Got {type(exc).__name__}: {exc}\n\n"
f"Given arguments:\n {kwargs}\n\n"
f"Selected data:\n {str(data['data'])}\n"
) from exc
return standalone
# -----------------------------------------------------------------------------
# -- Facet Grid ---------------------------------------------------------------
# -----------------------------------------------------------------------------
[docs]@is_plot_func(
use_dag=True, required_dag_tags=("data",), supports_animation=True
)
def facet_grid(
*,
data: dict,
hlpr: PlotHelper,
kind: Union[str, dict] = None,
frames: str = None,
auto_encoding: Union[bool, dict] = False,
suptitle_kwargs: dict = None,
squeeze: bool = True,
**plot_kwargs,
):
"""A generic facet grid plot function for high dimensional data.
This function calls the ``data['data'].plot`` function if no plot ``kind``
is given, otherwise ``data['data'].plot.<kind>``.
It is designed for `plotting with xarray objects <http://xarray.pydata.org/en/stable/plotting.html>`_,
i.e. :py:class:`xarray.DataArray` and :py:class:`xarray.Dataset`.
Specifying the kind of plot requires the data to be of one of those types
and have a dimensionality that can be represented in these plots. See
`the correponding API documentation <https://xarray.pydata.org/en/stable/api.html#plotting>`_ for more information.
In most cases, this function creates a so-called
:py:class:`xarray.plot.FacetGrid` object that automatically layouts and
chooses a visual representation that fits the dimensionality of the data.
To specify which data dimension should be represented in which way, it
supports a declarative syntax: via the optional keyword arguments ``x``,
``y``, ``row``, ``col``, and/or ``hue`` (available options are listed in
the corresponding `plot function documentation <https://xarray.pydata.org/en/stable/api.html#plotting>`_),
the representation of the data dimensions can be selected. This is
referred to as "layout encoding".
dantro not only wraps this interface, but adds the following functionality:
* the ``frames`` layout encoding argument, which behaves in the same
way as the other encodings, but leads to an *animation* being
generated, thus opening up one further dimension of representation,
* the ``auto_encoding`` feature, which allows to select layout-
encodings automatically,
* and the ``kind: 'auto'`` option, which can be used in conjunction
with ``auto_encoding`` to choose the plot kind automatically as well.
* allows ``col_wrap: 'auto'``, which selects the value such that the
figure becomes more square-like (requires ``auto_encoding: true``)
* allows to register additional plot ``kind`` values that create plots
with a custom single-axis plotting function, using the
:py:class:`~dantro.plot.funcs.generic.make_facet_grid_plot`
decorator.
For details about auto-encoding and how the plot ``kind`` is chosen, see
:py:func:`~dantro.plot.funcs.generic.determine_encoding`
and :py:func:`~dantro.plot.funcs.generic.determine_plot_kind`.
.. note::
When specifying ``frames``, the ``animation`` arguments need to be
specified. See :ref:`here <pcr_pyplot_animations>` for more information
on the expected animation parameters.
The value of the ``animation.enabled`` key is not relevant for this
function; it will automatically enter or exit animation mode,
depending on whether the ``frames`` argument is given or not. This uses
the :ref:`animation mode switching <pcr_pyplot_animation_mode_switching>`
feature.
.. note::
Internally, this function calls ``.squeeze`` on the selected data, thus
being more tolerant with data that has size-1 dimension coordinates.
To suppress this behaviour, set the ``squeeze`` argument accordingly.
.. warning::
Depending on ``kind`` and the dimensionality of the data, some plot
functions might create their own figure, disregarding any previously
set up figure. This includes the figure from the plot helper.
To control figure aesthetics, you can either specify matplotlib RC
:ref:`style parameters <pcr_pyplot_style>` (via the ``style`` argument),
or you can use the ``plot_kwargs`` to pass arguments to the respective
plot functions. For the latter, refer to the respective documentation
to find out about available arguments.
Args:
data (dict): The data selected by the data transformation framework,
expecting the ``data`` key.
hlpr (PlotHelper): The plot helper
kind (str, optional): The kind of plot to use. Options are:
``contourf``, ``contour``, ``imshow``, ``line``, ``pcolormesh``,
``step``, ``hist``, ``scatter``, ``errorbars`` and any plot kinds
that were additionally registered via the
:py:class:`~dantro.plot.funcs.generic.make_facet_grid_plot`
decorator.
With ``auto``, dantro chooses an appropriate kind by itself; this
setting is useful when also using the ``auto_encoding`` feature;
see :ref:`dag_generic_facet_grid_auto_kind` for more information.
If None is given, xarray automatically determines it using the
dimensionality of the data, frequently falling back to ``hist``
for higher-dimensional data or lacking specifiers.
frames (str, optional): Data dimension from which to create animation
frames. If given, this results in the creation of an animation. If
not given, a single plot is generated. Note that this requires
``animation`` options as part of the plot configuration.
auto_encoding (Union[bool, dict], optional): Whether to choose the
layout encoding options automatically. For further options, can
pass a dict. See :ref:`dag_generic_auto_encoding` for more info.
suptitle_kwargs (dict, optional): Key passed on to the PlotHelper's
``set_suptitle`` helper function. Only used if animations are
enabled. The ``title`` entry can be a format string with the
following keys, which are updated for each frame of the animation:
``dim``, ``value``. Default: ``{dim:} = {value:.3g}``.
squeeze (bool, optional): whether to squeeze the data before plotting,
such that size-1 dimensions do not take up encoding dimensions.
**plot_kwargs: Passed on to ``<data>.plot`` or ``<data>.plot.<kind>``
These should include the layout encoding specifiers (``x``, ``y``,
``hue``, ``col``, and/or ``row``).
Raises:
AttributeError: Upon unsupported ``kind`` value
ValueError: Upon *any* upstream error in invocation of the xarray
plotting capabilities. This wraps the given error message and
provides additional information that helps to track down why the
plotting failed.
"""
import matplotlib.pyplot as plt
# Make sure to have the latest module-level variables available here; this
# is important to ensure that those `kind`s registered by the
# make_facet_grid_plot decorator are available here.
from .generic import _FACET_GRID_FUNCS, _FACET_GRID_KINDS
# .........................................................................
def plot_frame(_d, *, kind: str, plot_kwargs: dict):
"""Plot a FacetGrid frame"""
# Squeeze size-1 dimension coordinates to non-dimension coordinates
if squeeze:
_d = _d.squeeze()
# Retrieve the generic or specialized plot function, depending on kind
if kind is None:
plot_func = _d.plot
elif kind in _FACET_GRID_FUNCS:
_plot_func = _FACET_GRID_FUNCS[kind]
# Bind the data and helper to the function
plot_func = _partial(_plot_func, _d, hlpr=hlpr)
else:
try:
plot_func = getattr(_d.plot, kind)
except AttributeError as err:
_available_xr = ", ".join(_XR_PLOT_KINDS)
_available_dtr = ", ".join(_FACET_GRID_FUNCS)
raise AttributeError(
f"The plot kind '{kind}' seems not to be available for "
f"data of type {type(_d)}! Please check the documentation "
"regarding the expected data types. For xarray data "
f"structures, valid choices are: {_available_xr}.\n"
"Additionally, the following facet grid kinds were "
f"registered from within dantro: {_available_dtr}"
) from err
# Make sure to work on a fully cleared figure. This is important for
# *some* specialized plot functions and for certain dimensionality of
# the data: in these specific cases, an existing figure can be
# re-used, in some cases leading to plotting artifacts.
# In other cases, a new figure is opened by the plot function. The
# currently attached helper figure is then discarded below.
hlpr.fig.clear()
# Invoke the specialized plot function, taking care that no figures
# that are additionally created survive beyond that point, which would
# lead to figure leakage, gobbling up memory.
with figure_leak_prevention(close_current_fig_on_raise=True):
try:
rv = plot_func(**plot_kwargs)
except Exception as exc:
raise PlottingError(
"facet_grid plotting failed, most probably because the "
"dimensionality of the data, the chosen plot kind "
f"({kind}) and the specified layout encoding were not "
"compatible or because the selected data was missing "
"coordinates for one or more dimensions.\n"
"For debugging, inspect the chained traceback and the "
"information below.\n\n"
f"The upstream error was a {type(exc).__name__}: {exc}\n\n"
f"xr.plot.FacetGrid arguments:\n {plot_kwargs}\n\n"
f"Data:\n {str(_d)}\n"
) from exc
# NOTE rv usually is a xarray.FaceGrid object but not always: `hist`
# returns what matplotlib.pyplot.hist returns.
# This leads to the question why `hist`s do not seem to be
# possible in `xarray.FacetGrid`s, although they would be useful?
# Gaining a deeper understanding of this issue and corresponding
# xarray functionality is something to investigate in the future.
# Determine which figure and axes to attach to the PlotHelper.
# This is necessary because a figure might have been created in the
# invoked plot function and we need to make sure that we attach it
# correctly, otherwise there will be no plot output.
if isinstance(rv, xr.plot.FacetGrid):
fig = rv.fig
axes = rv.axs
else:
# Use the currently set figure and its axes.
fig = plt.gcf()
axes = plt.gca()
# When now attaching the new figure and axes, the previously existing
# figure (the one .clear()-ed above) is closed and discarded.
# If the figure extracted here is identical to the already-associated
# figure, nothing happens.
hlpr.attach_figure_and_axes(fig=fig, axes=axes, skip_if_identical=True)
# Store the FacetGrid instance for potential later manipulation
hlpr._attrs["facet_grid"] = rv
# Done with this frame now.
# Actual plotting routine starts here .....................................
# Get the Dataset, DataArray, or other compatible data
d = data["data"]
# Determine kind and encoding, updating the plot kwargs accordingly.
# NOTE Need to pop all explicitly given specifiers in order to not have
# them appear as part of plot_kwargs further downstream.
kind = determine_plot_kind(
d, kind=kind, default_kind_map=_AUTO_PLOT_KINDS, **plot_kwargs
)
plot_kwargs = determine_encoding(
d.sizes,
kind=kind,
auto_encoding=auto_encoding,
default_encodings=_FACET_GRID_KINDS,
plot_kwargs=dict(
frames=frames,
**plot_kwargs,
),
)
frames = plot_kwargs.pop("frames", None)
# Parse colorbar-related arguments
plot_kwargs = parse_cmap_and_norm_kwargs(**plot_kwargs)
# Done parsing arguments
log.note("Facet grid plot of kind '%s' now commencing ...", kind)
# If no animation is desired, the plotting routine is really simple
if not frames:
# Exit animation mode, if it was enabled. Then plot the figure. Done.
hlpr.disable_animation()
plot_frame(d, kind=kind, plot_kwargs=plot_kwargs)
return
# else: Animation is desired. Might have to enable it.
# If not already in animation mode, the plot function will be exited here
# and be invoked anew in animation mode. It will end up in this branch
# again, and will then be able to proceed past this point...
hlpr.enable_animation()
# Prepare some parameters for the update routine
suptitle_kwargs = suptitle_kwargs if suptitle_kwargs else {}
if "title" not in suptitle_kwargs:
suptitle_kwargs["title"] = "{dim:} = {value:.3g}"
# Define an animation update function. All frames are plotted therein.
# There is no need to plot the first frame _outside_ the update function,
# because it would be discarded anyway.
def update():
"""The animation update function: a python generator"""
# Go over all available frame data dimension
for f_value, f_data in d.groupby(frames):
# Plot a frame. It attaches the new figure and axes to the hlpr
plot_frame(f_data, kind=kind, plot_kwargs=plot_kwargs)
# Apply the suptitle format string, then invoke the helper
st_kwargs = copy.deepcopy(suptitle_kwargs)
st_kwargs["title"] = st_kwargs["title"].format(
dim=frames, value=f_value
)
hlpr.invoke_helper("set_suptitle", **st_kwargs)
# Done with this frame. Let the writer grab it.
yield
# Register the animation update with the helper
hlpr.register_animation_update(update, invoke_helpers_before_grab=True)
# -- Additional facet-grid supporting plots -----------------------------------
# TODO Should support errors along x as well!
[docs]@make_facet_grid_plot(
map_as="dataset",
encodings=("x", "hue"),
supported_hue_styles=("discrete",),
#
# defaults
hue_style="discrete",
add_guide=False,
)
def errorbars(
ds: "xarray.Dataset",
*,
_is_facetgrid: bool,
hlpr: PlotHelper,
y: str,
yerr: str,
x: str = None,
hue: str = None,
hue_fstr: str = "{value:}",
use_bands: bool = False,
add_legend: bool = True,
**kwargs,
):
"""An errorbar plot supporting facet grid.
This function makes use of a decorator to implement faceting support:
:py:class:`~dantro.plot.funcs.generic.make_facet_grid_plot`.
It additionally registers this plot as an available plot ``kind`` in
:py:func:`~dantro.plot.funcs.generic.facet_grid`.
.. note::
This plot function is heavily wrapped by the decorator, which is why
not all functionality is exposed here. Instead, the arguments seen here
are those that apply to a *single* subplot of a facet grid.
Uses :py:func:`~dantro.plot.funcs._utils.plot_errorbar` for
plotting individual lines.
Args:
ds (xarray.Dataset): The dataset containing the errorbar data
_is_facetgrid (bool): Indicates whether this plot is called as part of
a facet grid or whether no faceting takes place (i.e. when neither
columns nor rows are available for faceting). In such a case, this
plot supplies metadata to the plot helper to draw axis labels etc.
(For internal use only, no need to pass this parameter.)
hlpr (PlotHelper): The plot helper, exposing the currently selected
axis via ``hlpr.ax``.
y (str): Which data variable to use for the y-axis values
yerr (str): Which data variable to use for the errorbars or bands
x (str, optional): Which data dimension to plot on the x-axis
hue (str, optional): Which data dimension to represent via hues
hue_fstr (str, optional): A format string that is used to build the
label of discrete hue encoding.
use_bands (bool, optional): Whether to use errorbands instead of bars.
add_legend (bool, optional): Whether to add a legend to the individual
plot or to the figure
**kwargs: Passed on to ``hlpr.ax.errorbar`` via
:py:func:`~dantro.plot.funcs._utils.plot_errorbar`.
"""
# Prepare data
_y = ds[y]
_yerr = ds[yerr]
# Try to infer x, if not given
x = x if x else [dim for dim in _y.dims if dim not in (hue,)][0]
_x = ds.coords[x]
# If this is not a facet grid, still show some labels
if not _is_facetgrid:
# FIXME Should do this via helper, but not working (see #82)
# hlpr.provide_defaults("set_labels", x=x, y=f"{y} & {yerr}")
# Workaround:
hlpr.ax.set_xlabel(x)
hlpr.ax.set_ylabel(f"{y}, {yerr}")
# Case: No hue dimension -> plot single errorbar line
if hue is None:
_plot_errorbar(
ax=hlpr.ax,
x=_x,
y=_y,
yerr=_yerr,
fill_between=use_bands,
**kwargs,
)
return
# else: will plot multiple lines
# Keep track of legend handles and labels
_handles, _labels = [], []
# Group by the hue dimension and perform plots. To be a bit more permissive
# regarding data shape, squeeze out any additional dimensions that might
# have been left over.
hue_iter = zip(_y.groupby(hue), _yerr.groupby(hue))
for (_y_coord, _y_vals), (_yerr_coord, _yerr_vals) in hue_iter:
_y_vals = _y_vals.squeeze(drop=True)
_yerr_vals = _yerr_vals.squeeze(drop=True)
label = hue_fstr.format(dim=hue, value=_y_coord)
handle = _plot_errorbar(
ax=hlpr.ax,
x=_x,
y=_y_vals,
yerr=_yerr_vals,
label=label,
fill_between=use_bands,
**kwargs,
)
_handles.append(handle)
_labels.append(label)
# Either do a single-axis legend or prepare for figure-level legend
if not _is_facetgrid:
if add_legend:
hlpr.ax.legend(_handles, _labels, title=hue)
else:
hlpr.track_handles_labels(_handles, _labels)
if add_legend:
hlpr.provide_defaults("set_figlegend", title=hue)
# .............................................................................
[docs]@make_facet_grid_plot(
map_as="dataset",
register_as_kind="scatter3d",
encodings=("hue", "markersize"), # TODO correct?!
supported_hue_styles=("continuous",),
parse_cmap_and_norm_kwargs=False,
# defaults
# hue_style="discrete", # FIXME setting to 'discrete' fails, but shouldn't
)
def scatter3d(
ds: "xarray.Dataset",
*,
_is_facetgrid: bool,
hlpr: PlotHelper,
x: str,
y: str,
z: str,
hue: str = None,
markersize: Union[float, str] = None,
size_mapping: dict = None,
cmap: Union[str, dict, mcolors.Colormap] = None,
norm: Union[str, dict, mcolors.Normalize] = None,
vmin: float = None,
vmax: float = None,
add_colorbar: bool = True,
cbar_kwargs: dict = None,
**kwargs,
):
"""A 3-dimensional scatter plot supporting facet grid.
This function makes use of a decorator to implement faceting support:
:py:class:`~dantro.plot.funcs.generic.make_facet_grid_plot`.
It additionally registers this plot as an available plot ``kind`` in
:py:func:`~dantro.plot.funcs.generic.facet_grid`.
.. note::
This plot relies on the figure projection having been set to 3D,
which can be achieved via:
.. code-block:: yaml
my_3d_plot:
# ...
# for faceting:
subplot_kws: &projection
projection: 3d
# for single plot:
helpers:
set_figure:
subplot_kw: # sic
<<: *projection
There *may* also be a base plot configuration that does this.
.. warning::
Support of :ref:`auto-encoding <dag_generic_auto_encoding>` and of the
``hue`` and ``markersize`` encodings is not as general as it could be.
If you get dimensionality- or size-related errors, that's probably due
to an incompatible combination of encodings.
.. note::
This plot function is heavily wrapped by the decorator, which is why
not all functionality is exposed here. Instead, the arguments seen here
are those that apply to a *single* subplot of a facet grid.
Args:
ds (xarray.Dataset): The dataset containing the data
_is_facetgrid (bool): Indicates whether this plot is called as part of
a facet grid or whether no faceting takes place (i.e. when neither
columns nor rows are available for faceting). In such a case, this
plot supplies metadata to the plot helper to draw axis labels etc.
(For internal use only, no need to pass this parameter.)
hlpr (PlotHelper): The plot helper, exposing the currently selected
axis via ``hlpr.ax``.
x (str): Which data variable to plot on the x-axis
y (str): Which data variable to plot on the y-axis
z (str): Which data variable to plot on the z-axis
hue (str, optional): Which dimension or variable to represent via hues
markersize: (str, optional): Which data *dimension* to plot using the
markersize. Note that if ``hue`` is given this needs to match the
size of that dimension.
Whether using data *variables* here depends on the dimensionality
of the data; don't be surprised by a cryptic error message from
deep within xarray.
size_mapping: (dict, optional): A dictionary containing the facet grid
``size_mapping``. Is overwritten by ``markersize``, if passed.
cmap (Union[str, dict, matplotlib.colors.Colormap], optional): The
colormap, passed to the
:py:class:`~dantro.plot.utils.color_mngr.ColorManager`.
norm (Union[str, dict, matplotlib.colors.Normalize], optional):
The norm that is applied for the color-mapping.
vmin (float, optional): The lower bound of the color-mapping,
passed to the
:py:class:`~dantro.plot.utils.color_mngr.ColorManager`.
Ignored if norm evaluates to ``BoundaryNorm``.
vmax (float, optional): The upper bound of the color-mapping,
passed to the
:py:class:`~dantro.plot.utils.color_mngr.ColorManager`.
Ignored if norm evaluates to ``BoundaryNorm``.
add_colorbar (bool, optional): Whether to add a colorbar
cbar_kwargs (dict, optional): Arguments for colorbar creation.
**kwargs: Passed on to :py:func:`matplotlib.axes.Axes.scatter` or, if
``z`` is given, the equivalent 3D axes.
Raises:
AttributeError: If the active axes does not have a ``zaxis``.
In that case, you probably forgot to set the figure's projection,
see above.
"""
def get_var(v: str) -> xr.DataArray:
"""Retrieves a data variable from the dataset, making some checks"""
d = ds[v]
if d.ndim != 1:
raise ValueError(
f"Unexpected data dimensionality for variable '{v}'! "
"On the subplot-level, data variables should be 1D, but "
f"ds['{v}'] was {d.ndim}-dimensional: {dict(d.sizes)}"
)
return d
if not hasattr(hlpr.ax, "zaxis"):
raise AttributeError(
"Missing z-axis! Did you set the "
"projection (via `subplot_kws` or `setup_figure` helper)?"
)
cm = ColorManager(
cmap=cmap,
norm=norm,
vmin=vmin,
vmax=vmax,
)
shared_kwargs = dict(
c=get_var(hue) if hue is not None else None,
cmap=cm.cmap if cmap is not None else None,
norm=cm.norm if norm is not None else None,
vmin=vmin if norm is None else None,
vmax=vmax if norm is None else None,
)
# Add the 's' key to the kwargs. If both size_mapping and markersize are
# passed, 'markersize' will take precedent.
if size_mapping is not None:
shared_kwargs["s"] = size_mapping.values
if not _is_facetgrid and markersize is not None:
shared_kwargs["s"] = get_var(markersize).values
im = hlpr.ax.scatter(
get_var(x),
get_var(y),
get_var(z),
**shared_kwargs,
**kwargs,
)
# Postprocess
if not _is_facetgrid and hue is not None and add_colorbar:
# TODO This should read information from the FacetGrid's cbar_kwargs,
# which are also parsed there...
cm.create_cbar(
im,
fig=hlpr.fig,
ax=hlpr.ax,
**(cbar_kwargs if cbar_kwargs else {}),
)
# FIXME Should do this via helper, but not working (see #82)
# hlpr.provide_defaults("set_labels", x=x, y=y, z=z)
hlpr.ax.set_xlabel(x)
hlpr.ax.set_ylabel(y)
hlpr.ax.set_zlabel(z)
return im