Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions ci/codespell-ignore-words.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
aas
ABD
aother
axises
coo
curvelinear
Expand Down
99 changes: 99 additions & 0 deletions galleries/users_explain/data_containers.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
Data Containers Architecture
============================

Overview
--------

Data Containers are a system to allow Matplotlib artists to have a more
consistent data interface.
The key idea is that there is a new :class:`DataContainer` datatype that
Artists can use which provides a consistent interface and allows for such
things as caching, draw-time updating, key mapping, etc.

This project fundamentally comes in two parts, the Data Containers themselves,
and an execution graph which transforms input data through a pipeline of
operations, ultimately resulting in the representation of the data on a
display.

In the initial phase of rolling these out, the execution graph is considered
entirely internal to Matplotlib, and users are not expected to interact with it
directly.

Data Containers
---------------

The Data Containers themselves are actually a Protocol which describes just two
methods, :meth:`DataContainer.query` and :meth:`DataContainer.describe`.
Any Python object which provides these two methods (and appropriately returns
the expected data) can be used as a Data Container.

In practice, small wrappers for common data sources (such as numpy arrays,
dataframes, etc) are easy to write.
Matplotlib Artists have specialized containers associated with them that handle
backwards compatibility and other common operations for data related to that
artist.

Query
^^^^^

The primary method of Data Containers is :meth:`DataContainer.query`, which is what provides the data to a caller.
This method takes two parameters, a :class:`Graph` and a a coordinate of the parent (typically ``"axes"`` or ``"figure"``).
Many DataContainers, particularly those that represent static data, do not need either of these parameters.
The parameters are primarily there for :class:`DataContainer` objects that represent data that is dynamically computed in relation to the viewport of their axes, such as :class:`FuncContainer`.

The :meth:`DataContainer.query` returns both a dictionary mapping string keys to arbitrary (though most commonly numeric array) data, and a cache key.


Describe
^^^^^^^^

Since :meth:`DataContainer.query` can do relatively long computation (or otherwise high latency operations), a :class:`DataContainer` also has a method to :meth:`DataContainer.describe` the data.
This returns a dictionary mapping string keys to :class:`Desc` objects.

The purpose of this is to be able to quickly validate what operations are available, given a set of data.
This accounts for factors such as shape consistency and coordinate systems.

Desc objects
^^^^^^^^^^^^

:class:`Desc` objects are a dataclass that contains a ``shape`` and a ``coordinates``.

``shape`` is a tuple of integer or strings.
In its simplest form, this is just the shape of an array (or an empty tuple for scalar data).
However, `Desc` objects also allow variables to be used in shape descriptions.
This allows, for instance, data containers to report that a given field is an ``("M", "N")`` array.
Variables must be single characters.
It is expected that all variables from a given data container will resolve to a single number once queried.
That is, two fields that report ``("N",)`` will be the same length and ``("M", "M")`` will be a square array.
Additionally, additive offsets can be used, such as ``("N", "N+1")``, which represents an array that is one larger in the second dimension (e.g. a ``(4, 5)`` array).

Coordinates allow for differentiation of semantic meaning of a given variable.
The classic example is the difference between "data", "axes", "figure", and "display" coordinates.
However, this concept is extensible, including to non-spatial coordinates such as color spaces.


Execution Graph
---------------

The second portion of the Data Containers project is an execution graph.
In its initial implementation, it is intended for internal use, and thus not intended to be directly interacted with by end users.
That said, the core functionality of the execution graph is implemented and can be used by controlled, internal to Matplotlib, artists.


Edges
^^^^^

An :class:`Edge`, generically, takes a set of inputs, which are a dictionary of string keys to :class:`Desc` objects, and produces a set of outputs (a similar dictionary).
In theory, Edges are incredibly flexible, and can do many operations, including things such as resizing, upscaling/downscaling arrays, applying color transformations, etc.
In practice, for the initial implementation, the edges represent a relatively minimal set of the Matplotlib transform stack.
These are the transforms which allow for coordinate changes between "data", "axes", and "figure" space, for instance.


Graph
^^^^^

The graph consists of a set of :class:`Edge` objects.

The primary method of a graph, :meth:`Graph.evaluator`, which implements a version of Djikstra's algorithm to return a sequence of edges that take a given input (usually the :meth:`DataContainer.describe` of a given data container) and achieve the desired output, which is similarly a dictionary of :class:`Desc` objects with str keys.

Internally, the :class:`Graph` is able to have separate subgraphs for keys that are not interconnected (e.g. often many individual keys have a linear subgraph that does not depend on other data from the data container). This is done for efficiency, but does not actually affect the result.
Empty file.
43 changes: 43 additions & 0 deletions lib/matplotlib/_data_containers/_helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
from .description import Desc, desc_like
from .conversion_edge import Graph, TransformEdge


def _get_graph(ax):
"""Compute the Graph for a given axes.

Produces a minimal graph that provides enough for `FuncContainer`.
"""
if ax is None:
return Graph([])
desc: Desc = Desc(("N",), coordinates="data")

Check warning on line 12 in lib/matplotlib/_data_containers/_helpers.py

View workflow job for this annotation

GitHub Actions / mypy

[mypy] reported by reviewdog 🐶 By default the bodies of untyped functions are not checked, consider using --check-untyped-defs [annotation-unchecked] Raw Output: lib/matplotlib/_data_containers/_helpers.py:12: note: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs [annotation-unchecked]
xy: dict[str, Desc] = {"x": desc, "y": desc}

Check warning on line 13 in lib/matplotlib/_data_containers/_helpers.py

View workflow job for this annotation

GitHub Actions / mypy

[mypy] reported by reviewdog 🐶 By default the bodies of untyped functions are not checked, consider using --check-untyped-defs [annotation-unchecked] Raw Output: lib/matplotlib/_data_containers/_helpers.py:13: note: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs [annotation-unchecked]
implicit_graph = Graph(
[
TransformEdge(
"data",
xy,
desc_like(xy, coordinates="axes"),
transform=ax.transData - ax.transAxes,
),
TransformEdge(
"axes",
desc_like(xy, coordinates="axes"),
desc_like(xy, coordinates="display"),
transform=ax.transAxes,
),
TransformEdge(
"dpi",
desc_like(xy, coordinates="display_inches"),
desc_like(xy, coordinates="display"),
transform=ax.figure.dpi_scale_trans,
),
],
aliases=(("parent", "axes"),),
)
return implicit_graph


def check_container(artist, container_cls, operation="This operation"):
"""Validation helper for backwards compatibility checks"""
if not isinstance(artist._container, container_cls):
raise TypeError(f"{operation} is not available with a custom container class")
240 changes: 240 additions & 0 deletions lib/matplotlib/_data_containers/containers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
from __future__ import annotations

from typing import (
Protocol,
Optional,
Any,
Union,
)
from collections.abc import Callable, MutableMapping
import uuid

from cachetools import LFUCache # type: ignore[import-untyped]

import numpy as np

from .description import Desc, desc_like

from typing import TYPE_CHECKING

if TYPE_CHECKING:
from .conversion_edge import Graph


class _MatplotlibTransform(Protocol):
def transform(self, verts): ...

def __sub__(self, other) -> "_MatplotlibTransform": ...


class DataContainer(Protocol):
def query(
self,
graph: Graph,
parent_coordinates: str = "axes",
/,
) -> tuple[dict[str, Any], Union[str, int]]:
"""
Query the data container for data.

Parameters
----------
graph : matplotlib._data_containers.Graph
This is a graph that represents the available operations.
Most commonly, this is used to get information on the pan/zoom of
an Axes, which allows a Container to reactively produce data
(e.g. compute only the relevant region, dynamically downscale, etc)
parent_coordinates: str, Optional
This provides a small insight into where the data sits within the
Graph.

Returns
-------
data : dict[str, Any]
The values are really array-likes

cache_key : str
This is a key that clients can use to cache down-stream
computations on this data.
"""
...

def describe(self) -> dict[str, Desc]:
"""
Describe the data a query will return.

This provides the set of keys as well as the relative shapes and
coordinate systems of each key

Returns
-------
dict[str, Desc]
"""
...


class NoNewKeys(ValueError): ...


class ArrayContainer:
def __init__(self, coordinates: dict[str, str] | None = None, /, **data):
"""A container which represents numpy arrays.

Arrays in an ArrayContainer are only updated via an explicit call to
:meth:`ArrayContainer.update`.

Parameters
----------
coordinates: dict[str, str]
A mapping of string keys to string coordinate systems for data in
the container
**data
The initial arrays for the container

"""
coordinates = coordinates or {}
self._data = data
self._cache_key = str(uuid.uuid4())
self._desc = {
k: (
Desc(v.shape, coordinates.get(k, "auto"))
if hasattr(v, "shape")
else Desc((), coordinates.get(k, "auto"))
)
for k, v in data.items()
}

def query(
self,
graph: Graph,
parent_coordinates: str = "axes",
) -> tuple[dict[str, Any], Union[str, int]]:
return dict(self._data), self._cache_key

def describe(self) -> dict[str, Desc]:
return dict(self._desc)

def update(self, **data):
"""Update the data in the container.

Arrays must be of the same shape, and no new arrays may be added.
Only updated arrays need to be included.

Parameters
----------
**data:
The new arrays
"""
# TODO check that this is still consistent with desc!
if not all(k in self._data for k in data):
raise NoNewKeys(
f"The keys that currently exist are {set(self._data)}. You "
f"tried to add {set(data) - set(self._data)!r}."
)
self._data.update(data)
self._cache_key = str(uuid.uuid4())


class FuncContainer:
def __init__(
self,
# TODO: is this really the best spelling?!
xfuncs: Optional[
dict[str, tuple[tuple[Union[str, int], ...], Callable[[Any], Any]]]
] = None,
yfuncs: Optional[
dict[str, tuple[tuple[Union[str, int], ...], Callable[[Any], Any]]]
] = None,
xyfuncs: Optional[
dict[str, tuple[tuple[Union[str, int], ...], Callable[[Any, Any], Any]]]
] = None,
):
"""
A container that wraps several functions. They are split into 3 categories:

- functions that are offered x-like values as input
- functions that are offered y-like values as input
- functions that are offered both x and y like values as two inputs

In addition to the callable, the user needs to provide a spelling of
what the (relative) shapes will be in relation to each other. For now this
is a list of integers and strings, where the strings are "generic" values.

For example if two functions report shapes: ``{'bins':[N], 'edges': [N + 1]``}
then when called, *edges* will always have one more entry than bins.

Parameters
----------
xfuncs, yfuncs, xyfuncs : dict[str, tuple[shape, func]]

"""
self._desc: dict[str, Desc] = {}

def _split(input_dict):
out = {}
for k, (shape, func) in input_dict.items():
self._desc[k] = Desc(shape)
out[k] = func
return out

self._xfuncs = _split(xfuncs) if xfuncs is not None else {}
self._yfuncs = _split(yfuncs) if yfuncs is not None else {}
self._xyfuncs = _split(xyfuncs) if xyfuncs is not None else {}
self._cache: MutableMapping[Union[str, int], Any] = LFUCache(64)

def _query_hash(self, data_lim, size):
xlims, ylims = data_lim.evaluate({"x": [0, 1], "y": [0, 1]}).values()
data_bounds = (*(float(x) for x in xlims), *(float(y) for y in ylims))
hash_key = hash((data_bounds, size))
return hash_key

def query(
self,
graph: Graph,
parent_coordinates: str = "axes",
) -> tuple[dict[str, Any], Union[str, int]]:
desc = Desc(("N",))
xy = {"x": desc, "y": desc}
data_lim = graph.evaluator(
desc_like(xy, coordinates="data"),
desc_like(xy, coordinates=parent_coordinates),
).inverse

screen_size = graph.evaluator(
desc_like(xy, coordinates=parent_coordinates),
desc_like(xy, coordinates="display"),
)

screen_dims = screen_size.evaluate({"x": [0, 1], "y": [0, 1]})
xpix, ypix = np.ceil(np.abs(np.diff(screen_dims["x"]))), np.ceil(
np.abs(np.diff(screen_dims["y"]))
)
xpix = int(xpix)
ypix = int(ypix)

hash_key = self._query_hash(data_lim, (xpix, ypix))
if hash_key in self._cache:
return self._cache[hash_key], hash_key

x_data = data_lim.evaluate(
{
"x": np.linspace(0, 1, xpix * 2),
"y": np.zeros(xpix * 2),
}
)["x"]
y_data = data_lim.evaluate(
{
"x": np.zeros(ypix * 2),
"y": np.linspace(0, 1, ypix * 2),
}
)["y"]

ret = self._cache[hash_key] = dict(
**{k: f(x_data) for k, f in self._xfuncs.items()},
**{k: f(y_data) for k, f in self._yfuncs.items()},
**{k: f(x_data, y_data) for k, f in self._xyfuncs.items()},
)
return ret, hash_key

def describe(self) -> dict[str, Desc]:
return dict(self._desc)
Loading
Loading