import enum
import math
import numpy as np
import numpy.random as randomgen
try:
import matplotlib.pyplot as plt
except ModuleNotFoundError as e:
raise ModuleNotFoundError(
"Error importing matplotlib, you may need to install by executing\n\t"
"pip install matplotlib"
) from e
import matplotlib.colors
import matplotlib.patches
import matplotlib.transforms
import matplotlib.tri
from matplotlib.path import Path
from matplotlib.lines import Line2D
from matplotlib.collections import LineCollection, PathCollection, PolyCollection
import mpl_toolkits.mplot3d
from mpl_toolkits.mplot3d.art3d import (Line3DCollection, Poly3DCollection,
patch_collection_2d_to_3d)
from math import factorial
from firedrake import (interpolate, sqrt, inner, Function, SpatialCoordinate,
FunctionSpace, VectorFunctionSpace, PointNotInDomainError,
Constant, assemble, dx)
from firedrake.mesh import MeshGeometry
from firedrake.petsc import PETSc
from ufl.domain import extract_unique_domain
__all__ = [
"plot", "triplot", "tricontourf", "tricontour", "trisurf", "tripcolor",
"quiver", "streamplot", "FunctionPlotter"
]
def toreal(array, component):
if array.dtype.kind == "c":
assert component in {"real", "imag"}
return getattr(array, component)
else:
assert component == "real"
return array
def _autoscale_view(axes, coords):
axes.autoscale_view()
if coords is not None:
coords = toreal(coords, "real")
# Dirty hack; autoscale_view doesn't appear to work for 3D plots.
if isinstance(axes, mpl_toolkits.mplot3d.Axes3D):
setters = ["set_xlim", "set_ylim", "set_zlim"]
for setter, idx in zip(setters, range(coords.shape[1])):
try:
setter = getattr(axes, setter)
except AttributeError:
continue
amin = coords[:, idx].min()
amax = coords[:, idx].max()
extra = (amax - amin) / 20
if extra == 0.0:
# 1D interval
extra = 0.5
amin -= extra
amax += extra
setter(amin, amax)
def _point_collection(axes, vertices, colors=None, **kwargs):
r"""Draw a collection of round markers at the given points
The markers are sized in points like those of a scatter plot, so their
paths live in display space and only their offsets are in data
coordinates.
Parameters
----------
axes : matplotlib.axes.Axes
Axes whose data transform positions the markers.
vertices : numpy.ndarray
Array of shape ``(num_points, gdim)`` of point coordinates.
colors : optional
Colour or array of colours for the markers. The colours arrive under
the same keyword that the line collections bounding a 2D mesh take
them under.
**kwargs
Additional keyword arguments for
:class:`~matplotlib.collections.PathCollection`.
Returns
-------
matplotlib.collections.PathCollection
The marker collection, converted to 3D if ``gdim`` is 3.
"""
kwargs.setdefault("sizes", [plt.rcParams["lines.markersize"] ** 2])
collection = PathCollection(
[Path.unit_circle()],
offsets=vertices[:, :2],
offset_transform=axes.transData,
transform=matplotlib.transforms.IdentityTransform(),
facecolors=colors,
**kwargs
)
if vertices.shape[1] == 3:
# Shading the markers by depth would take them off the colour that
# names them in the legend.
patch_collection_2d_to_3d(collection, zs=vertices[:, 2], depthshade=False)
return collection
def _add_collection(axes, vertices, **kwargs):
r"""Draw the mesh entities whose vertices are given and add them to the axes
The shape of ``vertices`` decides how each entity is drawn: entities with a
single vertex become round markers, those with two vertices become line
segments, and those with more become polygons. The facets of a 1D mesh are
points and those of a 2D mesh are segments, so this covers every entity
that a mesh of any dimension is drawn from.
Parameters
----------
axes : matplotlib.axes.Axes
Axes to add the collection to.
vertices : numpy.ndarray
Array of shape ``(num_entities, num_vertices, gdim)``.
**kwargs
Additional keyword arguments for the collection type chosen.
Returns
-------
matplotlib.collections.Collection
The collection that was added to the axes.
"""
num_vertices, gdim = vertices.shape[1:]
if num_vertices == 1:
collection = _point_collection(axes, vertices.reshape(-1, gdim), **kwargs)
elif num_vertices == 2:
segments = LineCollection if gdim == 2 else Line3DCollection
collection = segments(vertices, **kwargs)
else:
polygons = PolyCollection if gdim == 2 else Poly3DCollection
collection = polygons(vertices, **kwargs)
axes.add_collection(collection)
return collection
def _entity_node_list(cell, dimension):
r"""Local node numbers of each entity of the given dimension of a cell
The vertices of each entity come out in a cycle around its perimeter. FInAT
numbers the vertices of a quadrilateral lexicographically, putting 1 and 2
diagonally opposite each other; exchanging the last two makes the polygon
simple. Entities with any other number of vertices are simplices, which are
already in order.
Parameters
----------
cell : FIAT.reference_element.Cell
Reference cell whose topology is queried.
dimension : int or tuple of int
Dimension of the entities to look up. Tensor product cells index their
entities by a pair of horizontal and vertical dimensions.
Returns
-------
numpy.ndarray
Array of shape ``(num_entities, num_vertices_per_entity)``.
"""
entities = cell.get_topology()[dimension]
nodes = np.array([entities[key] for key in sorted(entities)])
if nodes.shape[-1] == 4:
return nodes[:, [0, 1, 3, 2]]
return nodes
def _extrude(nodes, offset, num_layers):
r"""Replicate the entities of the bottom layer up the columns of an extruded mesh
Parameters
----------
nodes : numpy.ndarray
Array of shape ``(num_entities, num_nodes_per_entity)`` of node numbers
in the bottom layer.
offset : int or numpy.ndarray
Increment in node number between successive layers. This broadcasts
against ``nodes``.
num_layers : int
Number of layers of cells in the extruded mesh.
Returns
-------
numpy.ndarray
Array of shape ``(num_entities * num_layers, num_nodes_per_entity)``.
The entities of each column come out contiguous.
"""
nodes = np.asarray(nodes)
offset = np.broadcast_to(offset, nodes.shape)
layers = np.arange(num_layers).reshape(1, -1, 1)
return (nodes[:, None, :] + offset[:, None, :] * layers).reshape(-1, nodes.shape[-1])
[docs]
@PETSc.Log.EventDecorator()
def triplot(mesh, axes=None, interior_kw={}, boundary_kw={}):
r"""Plot a mesh colouring marked facet segments
Only the exterior facets are coloured. Markers on interior facets are
ignored, so an internal boundary is drawn like the rest of the interior.
The interior and boundary keyword arguments can be any keyword argument for
:class:`LineCollection <matplotlib.collections.LineCollection>` and
related types.
On an extruded mesh, the bottom and top of the mesh are coloured under
the markers ``"bottom"`` and ``"top"``. A periodic extrusion identifies
the bottom with the top, so neither is drawn.
:arg mesh: mesh to be plotted
:arg axes: matplotlib :class:`Axes <matplotlib.axes.Axes>` object on which to plot mesh
:arg interior_kw: keyword arguments to apply when plotting the mesh interior
:arg boundary_kw: keyword arguments to apply when plotting the mesh boundary
:return: list of matplotlib :class:`Collection <matplotlib.collections.Collection>` objects
"""
gdim = mesh.geometric_dimension
tdim = mesh.topological_dimension
if gdim not in {2, 3}:
raise ValueError("Geometric dimension must be either 2 or 3!")
if axes is None:
figure = plt.figure()
if gdim == 3:
axes = figure.add_subplot(111, projection='3d')
else:
axes = figure.add_subplot(111)
coordinates = mesh.coordinates
element = coordinates.function_space().ufl_element()
if coordinates.function_space().finat_element.space_dimension() != mesh.ufl_cell().num_vertices:
# Interpolate to piecewise linear.
V = VectorFunctionSpace(mesh, element.family(), 1)
coordinates = assemble(interpolate(coordinates, V))
cell = coordinates.function_space().finat_element.cell
# Tensor product cells index their entities by a pair of horizontal and
# vertical dimensions. The horizontal facets bound the bottom and top of
# each column of cells, so there are none unless the mesh is extruded.
if mesh.extruded:
cell_dim, facet_dim, horiz_facet_dim = (tdim - 1, 1), (tdim - 2, 1), (tdim - 1, 0)
else:
cell_dim, facet_dim, horiz_facet_dim = tdim, tdim - 1, None
num_layers = mesh.layers - 1 if mesh.extruded else 1
coords = toreal(coordinates.dat.data_ro_with_halos, "real")
cell_node_map = coordinates.cell_node_map()
cell_nodes = cell_node_map.values_with_halo
result = []
interior_kw = dict(interior_kw)
# If the domain isn't a 3D volume, draw the interior.
if tdim <= 2:
idx = _entity_node_list(cell, cell_dim)[0]
cells = cell_nodes[:, idx]
if mesh.extruded:
cells = _extrude(cells, cell_node_map.offset[idx], num_layers)
vertices = coords[cells]
interior_kw["edgecolors"] = interior_kw.get("edgecolors", "k")
interior_kw["linewidths"] = interior_kw.get("linewidths", 1.0)
if gdim == 2 and tdim == 2:
interior_kw["facecolors"] = interior_kw.get("facecolors", "none")
result.append(_add_collection(axes, vertices, **interior_kw))
# Add colored lines/polygons for the boundary facets. Each facet is drawn
# from the nodes of the cell it belongs to that lie on it.
facet_node_list = _entity_node_list(cell, facet_dim)
exterior_facets = mesh.exterior_facets
node_map = coordinates.exterior_facet_node_map()
selection = facet_node_list[exterior_facets.local_facet_dat.data_ro_with_halos]
exterior_faces = np.take_along_axis(node_map.values_with_halo, selection, axis=1)
if mesh.extruded:
exterior_faces = _extrude(exterior_faces, node_map.offset[selection], num_layers)
# The bottom and top of an extruded mesh are not facets of the base mesh,
# so they are drawn from the cells of the lowest and highest layer instead.
# A periodic extrusion identifies the two, leaving no boundary there.
horizontal_markers = ["bottom", "top"] if (mesh.extruded
and not mesh.extruded_periodic) else []
def marker_faces(marker):
if marker in horizontal_markers:
layer = 0 if marker == "bottom" else num_layers - 1
idx = _entity_node_list(cell, horiz_facet_dim)[horizontal_markers.index(marker)]
return cell_nodes[:, idx] + layer * cell_node_map.offset[idx]
indices = exterior_facets.subset(int(marker)).indices
if mesh.extruded:
# Every facet of the base mesh was extruded into `num_layers`
# facets lying consecutively in the array.
indices = (num_layers * indices[:, None]
+ np.arange(num_layers)).reshape(-1)
return exterior_faces[indices, :]
markers = list(exterior_facets.unique_markers) + horizontal_markers
color_key = "colors" if tdim <= 2 else "facecolors"
boundary_kw = dict(boundary_kw)
boundary_colors = boundary_kw.pop(color_key, None)
if boundary_colors is None:
cmap = matplotlib.colormaps["Dark2"]
num_markers = len(markers)
colors = cmap([k / num_markers for k in range(num_markers)])
else:
colors = matplotlib.colors.to_rgba_array(boundary_colors)
if tdim == 3:
boundary_kw["edgecolors"] = boundary_kw.get("edgecolors", "k")
boundary_kw["linewidths"] = boundary_kw.get("linewidths", 1.0)
for marker, color in zip(markers, colors):
vertices = coords[marker_faces(marker)]
_boundary_kw = dict(**{color_key: color, "label": marker}, **boundary_kw)
result.append(_add_collection(axes, vertices, **_boundary_kw))
# Dirty hack to enable legends for 3D volume plots. See the function
# `Poly3DCollection.set_3d_properties`.
for collection in result:
if isinstance(collection, Poly3DCollection):
collection._facecolors2d = PolyCollection.get_facecolor(collection)
collection._edgecolors2d = PolyCollection.get_edgecolor(collection)
_autoscale_view(axes, coords)
return result
def _plot_2d_field(method_name, function, *args, complex_component="real", **kwargs):
axes = kwargs.pop("axes", None)
if axes is None:
figure = plt.figure()
axes = figure.add_subplot(111)
Q = function.function_space()
mesh = Q.mesh()
if len(function.ufl_shape) == 1:
element = function.ufl_element().sub_elements[0]
Q = FunctionSpace(mesh, element)
function = assemble(interpolate(sqrt(inner(function, function)), Q))
num_sample_points = kwargs.pop("num_sample_points", 10)
function_plotter = FunctionPlotter(mesh, num_sample_points)
triangulation = function_plotter.triangulation
values = function_plotter(function)
method = getattr(axes, method_name)
return method(triangulation, toreal(values, complex_component), *args, **kwargs)
[docs]
@PETSc.Log.EventDecorator()
def tricontourf(function, *args, complex_component="real", **kwargs):
r"""Create a filled contour plot of a 2D Firedrake :class:`~.Function`
If the input function is a vector field, the magnitude will be plotted.
:arg function: the Firedrake :class:`~.Function` to plot
:arg args: same as for matplotlib :func:`tricontourf <matplotlib.pyplot.tricontourf>`
:kwarg complex_component: If plotting complex data, which
component? (``'real'`` or ``'imag'``). Default is ``'real'``.
:arg kwargs: same as for matplotlib
:return: matplotlib :class:`ContourSet <matplotlib.contour.ContourSet>` object
"""
return _plot_2d_field("tricontourf", function, *args, complex_component=complex_component, **kwargs)
[docs]
@PETSc.Log.EventDecorator()
def tricontour(function, *args, complex_component="real", **kwargs):
r"""Create a contour plot of a 2D Firedrake :class:`~.Function`
If the input function is a vector field, the magnitude will be plotted.
:arg function: the Firedrake :class:`~.Function` to plot
:arg args: same as for matplotlib :func:`tricontour <matplotlib.pyplot.tricontour>`
:kwarg complex_component: If plotting complex data, which
component? (``'real'`` or ``'imag'``). Default is ``'real'``.
:arg kwargs: same as for matplotlib
:return: matplotlib :class:`ContourSet <matplotlib.contour.ContourSet>` object
"""
return _plot_2d_field("tricontour", function, *args, complex_component=complex_component, **kwargs)
[docs]
@PETSc.Log.EventDecorator()
def tripcolor(function, *args, complex_component="real", **kwargs):
r"""Create a pseudo-color plot of a 2D Firedrake :class:`~.Function`
If the input function is a vector field, the magnitude will be plotted.
:arg function: the function to plot
:arg args: same as for matplotlib :func:`tripcolor <matplotlib.pyplot.tripcolor>`
:kwarg complex_component: If plotting complex data, which
component? (``'real'`` or ``'imag'``). Default is ``'real'``.
:arg kwargs: same as for matplotlib
:return: matplotlib :class:`PolyCollection <matplotlib.collections.PolyCollection>` object
"""
kwargs["shading"] = kwargs.get("shading", "gouraud")
return _plot_2d_field("tripcolor", function, *args, complex_component=complex_component, **kwargs)
def _trisurf_3d(axes, function, *args, complex_component="real", vmin=None, vmax=None, norm=None, **kwargs):
num_sample_points = kwargs.pop("num_sample_points", 10)
function_plotter = FunctionPlotter(function.function_space().mesh(), num_sample_points)
coordinates, triangles = function_plotter.coordinates, function_plotter.triangles
vertices = coordinates[triangles]
collection = Poly3DCollection(vertices, *args, **kwargs)
values = toreal(function_plotter(function), complex_component)
avg_vals = values[triangles].mean(axis=1)
collection.set_array(avg_vals)
if (vmin is not None) or (vmax is not None):
collection.set_clim(vmin, vmax)
if norm is not None:
collection.set_norm(norm)
axes.add_collection(collection)
_autoscale_view(axes, coordinates)
return collection
[docs]
@PETSc.Log.EventDecorator()
def trisurf(function, *args, complex_component="real", **kwargs):
r"""Create a 3D surface plot of a 2D Firedrake :class:`~.Function`
If the input function is a vector field, the magnitude will be plotted.
:arg function: the Firedrake :class:`~.Function` to plot
:arg args: same as for matplotlib :meth:`plot_trisurf <mpl_toolkits.mplot3d.axes3d.Axes3D.plot_trisurf>`
:kwarg complex_component: If plotting complex data, which
component? (``'real'`` or ``'imag'``). Default is ``'real'``.
:arg kwargs: same as for matplotlib
:return: matplotlib :class:`Poly3DCollection <mpl_toolkits.mplot3d.art3d.Poly3DCollection>` object
"""
axes = kwargs.pop("axes", None)
if axes is None:
figure = plt.figure()
axes = figure.add_subplot(111, projection='3d')
_kwargs = {"antialiased": False, "edgecolor": "none", "cmap": plt.rcParams["image.cmap"]}
_kwargs.update(kwargs)
Q = function.function_space()
mesh = Q.mesh()
if mesh.geometric_dimension == 3:
return _trisurf_3d(axes, function, *args, complex_component=complex_component, **_kwargs)
_kwargs.update({"shade": False})
if len(function.ufl_shape) == 1:
element = function.ufl_element().sub_elements[0]
Q = FunctionSpace(mesh, element)
function = assemble(interpolate(sqrt(inner(function, function)), Q))
num_sample_points = kwargs.pop("num_sample_points", 10)
function_plotter = FunctionPlotter(mesh, num_sample_points)
triangulation = function_plotter.triangulation
values = toreal(function_plotter(function), complex_component)
return axes.plot_trisurf(triangulation, values, *args, **_kwargs)
[docs]
@PETSc.Log.EventDecorator()
def quiver(function, *, complex_component="real", **kwargs):
r"""Make a quiver plot of a 2D vector Firedrake :class:`~.Function`
:arg function: the vector field to plot
:kwarg complex_component: If plotting complex data, which
component? (``'real'`` or ``'imag'``). Default is ``'real'``.
:arg kwargs: same as for matplotlib :func:`quiver <matplotlib.pyplot.quiver>`
:return: matplotlib :class:`Quiver <matplotlib.quiver.Quiver>` object
"""
if function.ufl_shape != (2,):
raise ValueError("Quiver plots only defined for 2D vector fields!")
axes = kwargs.pop("axes", None)
if axes is None:
figure = plt.figure()
axes = figure.add_subplot(111)
coords = toreal(extract_unique_domain(function).coordinates.dat.data_ro, "real")
V = extract_unique_domain(function).coordinates.function_space()
function_interp = assemble(interpolate(function, V))
vals = toreal(function_interp.dat.data_ro, complex_component)
C = np.linalg.norm(vals, axis=1)
return axes.quiver(*(coords.T), *(vals.T), C, **kwargs)
def _step_to_boundary(mesh, x, u, dt, loc_tolerance):
bracket = (0., dt)
while bracket[1] - bracket[0] > loc_tolerance * dt:
ds = (bracket[1] + bracket[0]) / 2
if mesh.locate_cell(x + ds * u, tolerance=loc_tolerance) is None:
bracket = (bracket[0], ds)
else:
bracket = (ds, bracket[1])
return bracket[0]
@PETSc.Log.EventDecorator()
def streamline(function, point, direction=+1, tolerance=3e-3, loc_tolerance=1e-10,
complex_component="real"):
r"""Generate a streamline of a vector field starting from a point
:arg function: the Firedrake :class:`~.Function` to plot
:arg point: the starting point of the streamline
:arg direction: either +1 or -1 to integrate forward or backward
:arg tolerance: dimensionless tolerance for the RK12 adaptive integration
:arg loc_tolerance: tolerance for point location
:kwarg complex_component: If plotting complex data, which
component? (``'real'`` or ``'imag'``). Default is ``'real'``.
:returns: a generator of the position, velocity, and timestep ``(x, v, dt)``
"""
mesh = extract_unique_domain(function)
cell_sizes = mesh.cell_sizes
x = np.array(point)
v1 = toreal(direction * function._at(x, tolerance=loc_tolerance), complex_component)
r = toreal(cell_sizes._at(x, tolerance=loc_tolerance), "real")
v1norm = np.sqrt(np.sum(v1**2))
if np.isclose(v1norm, 0.0):
# Bail early for zero fields.
return
dt = 0.5 * r / v1norm
while True:
try:
v2 = toreal(direction * function._at(x + dt * v1, tolerance=loc_tolerance),
complex_component)
except PointNotInDomainError:
ds = _step_to_boundary(mesh, x, v1, dt, loc_tolerance)
y = x + ds * v1
v1 = toreal(direction * function._at(y, tolerance=loc_tolerance),
complex_component)
yield y, v1, ds
break
dx1 = dt * v1
dx2 = dt * (v1 + v2) / 2
error = np.sqrt(np.sum((dx2 - dx1)**2)) / r
if error <= tolerance:
y = x + dx2
try:
vy = toreal(direction * function._at(y, tolerance=loc_tolerance),
complex_component)
r = toreal(cell_sizes._at(y, tolerance=loc_tolerance), "real")
except PointNotInDomainError:
v = (v1 + v2) / 2
ds = _step_to_boundary(mesh, x, v, dt, loc_tolerance)
y = x + ds * v
v1 = toreal(direction * function._at(y, tolerance=loc_tolerance),
complex_component)
yield y, v1, ds
break
x[:] = y
v1[:] = vy
yield y, v1, dt
# TODO: increase the step length if the error < fraction * tol
max_step_length = 0.5 * r / np.sqrt(np.sum(v1**2))
if error == 0.:
dt = max(1.5 * dt, max_step_length)
else:
proposed_dt = 0.85 * np.sqrt(tolerance / error) * dt
dt = min(max_step_length, proposed_dt)
class Reason(enum.IntEnum):
LENGTH = enum.auto()
TIME = enum.auto()
BOUNDARY = enum.auto()
class Streamplotter(object):
def __init__(self, function, resolution, min_length, max_time, tolerance,
loc_tolerance, *, complex_component="real"):
r"""Generates a dense set of streamlines of a vector field
This class is a utility for the :func:`~firedrake.plot.streamplot`
function.
"""
self.function = function
self.resolution = resolution
self.min_length = min_length
self.max_time = max_time
self.tolerance = tolerance
self.loc_tolerance = loc_tolerance
self.complex_component = complex_component
# Create a grid to track the distance to the nearest streamline
mesh = extract_unique_domain(self.function)
coords = toreal(mesh.coordinates.dat.data_ro, "real")
self._xmin = coords.min(axis=0)
xmax = coords.max(axis=0)
self._r = self.resolution / np.sqrt(mesh.geometric_dimension)
shape = tuple(((xmax - self._xmin) / self._r).astype(int) + 2)
self._grid = np.full(shape, 4 * self.resolution)
self.streamlines = []
def _grid_index(self, x):
r"""Return the indices in the grid where the given point lies"""
return tuple(((x - self._xmin) / self._r).astype(int))
def _grid_point(self, index):
r"""Return the position of the given grid index"""
return self._xmin + self._r * np.array(index)
def _approx_distance_to_streamlines(self, x):
r"""Return the approximate distance to the set of streamlines that have
been added, capped out to twice the resolution"""
index = self._grid_index(x)
g = self._grid[index[0]:index[0] + 2, index[1]:index[1] + 2]
lx, ly = (x - self._grid_point(index)) / self._r
return ((1 - ly) * ((1 - lx) * g[0, 0] + lx * g[1, 0])
+ ly * ((1 - lx) * g[0, 1] + lx * g[1, 1]))
def _compute_chunk(self, start_point, direction):
r"""Compute a short segment of a streamline starting at a given point"""
s = [start_point]
L = 0.
T = 0.
reason = Reason.BOUNDARY
for x, v, dt in streamline(self.function, start_point, direction,
self.tolerance, self.loc_tolerance,
complex_component=self.complex_component):
delta = x - s[-1]
s.append(x)
T += dt
L += np.sqrt(np.sum(delta**2))
if L >= self.min_length:
reason = Reason.LENGTH
break
if T >= self.max_time:
reason = Reason.TIME
break
return np.array(s), reason
def _enter_distance_to_chunk(self, chunk):
shape = self._grid.shape
# TODO: Make this distance to segments, not just distance to points --
# could be overestimating the distance in the case of very long segments
for x in chunk:
ix, iy = self._grid_index(x)
for i in range(max(ix - 2, 0), min(ix + 4, shape[0])):
for j in range(max(iy - 2, 0), min(iy + 4, shape[1])):
y = self._grid_point((i, j))
dist = min(np.sqrt(np.sum((x - y)**2)), 2 * self.resolution)
self._grid[i, j] = min(dist, self._grid[i, j])
def _index_of_first_bad_point(self, chunk):
r"""Return the index of the first point in the chunk that is close to
another streamline"""
for k, x in enumerate(chunk):
if self._approx_distance_to_streamlines(x) < self.resolution:
return k
return None
def _add_streamline_direction(self, chunk, index, reason, direction):
chunks = []
while (index is None) and (reason == Reason.LENGTH):
next_point = chunk[-1, :]
next_chunk, next_reason = self._compute_chunk(next_point, direction)
# Cut off the first point of the next chunk -- it's identical to
# the last point of the previous one
next_chunk = next_chunk[1:, :]
next_index = self._index_of_first_bad_point(next_chunk)
# Add the previous chunk
self._enter_distance_to_chunk(chunk[:index, :])
chunks.append(chunk[:index, :])
chunk, reason, index = next_chunk, next_reason, next_index
if index != 0:
self._enter_distance_to_chunk(chunk[:index])
chunks.append(chunk[:index])
return np.concatenate(chunks, axis=0)
def add_streamline(self, point):
# If the point isn't inside the domain, bail out
outside = extract_unique_domain(self.function).locate_cell(point) is None
too_close = self._approx_distance_to_streamlines(point) < self.resolution
if outside or too_close:
return
# Compute the first segments of the forward and backward chunks from
# the current point
fchunk, freason = self._compute_chunk(point, direction=+1)
findex = self._index_of_first_bad_point(fchunk)
bchunk, breason = self._compute_chunk(point, direction=-1)
bindex = self._index_of_first_bad_point(bchunk)
# If the initial segments aren't long enough, bail out
flength = np.sum(np.sqrt(np.sum(np.diff(fchunk[:findex], axis=0)**2, axis=1)))
blength = np.sum(np.sqrt(np.sum(np.diff(bchunk[:bindex], axis=0)**2, axis=1)))
if flength + blength < self.min_length:
return
forward = self._add_streamline_direction(fchunk, findex, freason, +1)
backward = self._add_streamline_direction(bchunk, bindex, breason, -1)
streamline = np.vstack((backward[::-1], forward[1:]))
self.streamlines.append(streamline)
[docs]
@PETSc.Log.EventDecorator()
def streamplot(function, resolution=None, min_length=None, max_time=None,
start_width=0.5, end_width=1.5, tolerance=3e-3, loc_tolerance=1e-10,
seed=None, complex_component="real", **kwargs):
r"""Create a streamline plot of a vector field
Similar to matplotlib :func:`streamplot <matplotlib.pyplot.streamplot>`
:arg function: the Firedrake :class:`~.Function` to plot
:arg resolution: minimum spacing between streamlines (defaults to domain size / 20)
:arg min_length: minimum length of a streamline (defaults to 4x resolution)
:arg max_time: maximum time to integrate a streamline
:arg start_width: line width at beginning of streamline
:arg end_width: line width at end of streamline, to convey direction
:arg tolerance: dimensionless tolerance for adaptive ODE integration
:arg loc_tolerance: point location tolerance for :meth:`~firedrake.function.Function.at`
:kwarg complex_component: If plotting complex data, which
component? (``'real'`` or ``'imag'``). Default is ``'real'``.
:kwarg kwargs: same as for matplotlib :class:`~matplotlib.collections.LineCollection`
"""
if function.ufl_shape != (2,):
raise ValueError("Streamplot only defined for 2D vector fields!")
axes = kwargs.pop("axes", None)
if axes is None:
figure = plt.figure()
axes = figure.add_subplot(111)
mesh = extract_unique_domain(function)
if resolution is None:
coords = toreal(mesh.coordinates.dat.data_ro, "real")
resolution = (coords.max(axis=0) - coords.min(axis=0)).max() / 20
if min_length is None:
min_length = 4 * resolution
if max_time is None:
area = assemble(Constant(1) * dx(mesh))
average_speed = np.sqrt(assemble(inner(function, function) * dx) / area)
if np.isclose(average_speed, 0.0):
max_time = 0.
else:
max_time = 50 * min_length / average_speed
streamplotter = Streamplotter(function, resolution, min_length, max_time,
tolerance, loc_tolerance,
complex_component=complex_component)
# TODO: better way of seeding start points
shape = streamplotter._grid.shape
xmin = streamplotter._grid_point((0, 0))
xmax = streamplotter._grid_point((shape[0] - 2, shape[1] - 2))
X, Y = np.meshgrid(np.linspace(xmin[0], xmax[0], shape[0] - 2),
np.linspace(xmin[1], xmax[1], shape[1] - 2))
start_points = np.vstack((X.ravel(), Y.ravel())).T
# Randomly shuffle the start points
generator = randomgen.Generator(randomgen.MT19937(seed))
for x in generator.permutation(np.array(start_points)):
streamplotter.add_streamline(x)
# Colors are determined by the speed, thicknesses by arc length
speeds = []
widths = []
for streamline in streamplotter.streamlines:
velocity = toreal(np.array(function._at(streamline, tolerance=loc_tolerance)),
complex_component)
speed = np.sqrt(np.sum(velocity**2, axis=1))
speeds.extend(speed[:-1])
delta = np.sqrt(np.sum(np.diff(streamline, axis=0)**2, axis=1))
arc_length = np.cumsum(delta)
length = arc_length[-1]
s = arc_length / length
linewidth = (1 - s) * start_width + s * end_width
widths.extend(linewidth)
points = []
for streamline in streamplotter.streamlines:
pts = streamline.reshape(-1, 1, 2)
points.extend(np.hstack((pts[:-1], pts[1:])))
speeds = np.array(speeds)
widths = np.array(widths)
points = np.asarray(points)
if speeds.size > 0:
vmin = kwargs.pop("vmin", speeds.min())
vmax = kwargs.pop("vmax", speeds.max())
norm = kwargs.pop("norm", matplotlib.colors.Normalize(vmin=vmin, vmax=vmax))
else:
norm = None
cmap = plt.get_cmap(kwargs.pop("cmap", None))
collection = LineCollection(points, cmap=cmap, norm=norm, linewidth=widths,
**kwargs)
collection.set_array(speeds)
axes.add_collection(collection)
_autoscale_view(axes, extract_unique_domain(function).coordinates.dat.data_ro)
return collection
class _FiredrakeFunctionPath(matplotlib.collections.PathCollection):
# A distinct class to distinguish MPL PathCollection from the same object
# used for plotting a Firedrake function (mainly for legend handling)
pass
class _HandlerFiredrakeFunctionPath(matplotlib.legend_handler.HandlerLine2D):
# Legend handler for _FiredrakeFunctionPath
def create_artists(
self, legend, orig_handle, xdescent, ydescent, width, height, fontsize, trans
):
xdata, xdata_marker = self.get_xdata(
legend, xdescent, ydescent, width, height, fontsize
)
ydata = np.full_like(xdata, (height - ydescent) / 2)
l = Line2D(xdata, ydata)
self.update_prop(l, orig_handle, legend)
l.set_transform(trans)
return [l]
def _default_update_prop(self, legend_handle, orig_handle):
# We need to override the default update property method as
# PathCollection and Line2D are incompatible
super(type(legend_handle), legend_handle).update_from(orig_handle)
legend_handle._linestyle = orig_handle._linestyles[0][1] or '-'
legend_handle._linewidth = orig_handle._linewidths[0]
legend_handle._color = orig_handle._original_edgecolor
matplotlib.legend.Legend.update_default_handler_map(
{_FiredrakeFunctionPath: _HandlerFiredrakeFunctionPath()}
)
[docs]
@PETSc.Log.EventDecorator()
def plot(function, *args, num_sample_points=10, complex_component="real", **kwargs):
r"""Plot a 1D Firedrake :class:`~.Function`
:arg function: The :class:`~.Function` to plot
:arg args: same as for matplotlib :func:`plot <matplotlib.pyplot.plot>`
:arg num_sample_points: number of sample points for high-degree functions
:kwarg complex_component: If plotting complex data, which
component? (``'real'`` or ``'imag'``). Default is ``'real'``.
:arg kwargs: same as for matplotlib :class:`PathPatch <matplotlib.patches.PathPatch>`
:return: list of matplotlib :class:`Line2D <matplotlib.lines.Line2D>`
"""
axes = kwargs.pop("axes", None)
if axes is None:
figure = plt.figure()
axes = figure.add_subplot(111)
label_list = kwargs.pop('label', [])
if isinstance(label_list, str):
label_list = [label_list]
result = []
for ii, line in enumerate([function, *args]):
if isinstance(line, MeshGeometry):
raise TypeError("Expected Function, not Mesh; see firedrake.triplot")
if extract_unique_domain(line).geometric_dimension > 1:
raise ValueError("Expected 1D Function; for plotting higher-dimensional fields, "
"see tricontourf, tripcolor, quiver, trisurf")
if line.ufl_shape != ():
raise NotImplementedError("Plotting vector-valued 1D functions is not supported")
try:
label = label_list[ii]
except IndexError:
label = line.name()
if line.ufl_element().degree() < 4:
result.append(_bezier_plot(line, axes, complex_component=complex_component, label=label, **kwargs))
else:
degree = line.ufl_element().degree()
sample_points = max(num_sample_points, 2 * degree)
function_plotter = FunctionPlotter(line.function_space().mesh(), sample_points)
x_vals = function_plotter(line.function_space().mesh().coordinates)
y_vals = function_plotter(line)
points = np.array([x_vals, y_vals])
num_cells = line.function_space().mesh().num_cells()
result.append(_interp_bezier(points, num_cells, axes, label=label, **kwargs))
_autoscale_view(axes, None)
return result
def _bezier_calculate_points(function):
"""Calculate points values for a function used for bezier plotting
:arg function: 1D Function with 1 < deg < 4
"""
Q = function.function_space()
deg = Q.ufl_element().degree()
M = np.empty([deg + 1, deg + 1], dtype=float)
# TODO: Revise this when FInAT gets dual evaluation
basis = Q.finat_element.fiat_equivalent.dual_basis()
for i in range(deg + 1):
coeff = factorial(deg) / (factorial(i) * factorial(deg - i))
for j in range(deg + 1):
x = list(basis[j].get_point_dict().keys())[0][0]
M[i, j] = coeff * (x ** i) * (1 - x) ** (deg - i)
M_inv = np.linalg.inv(M)
cell_node_list = Q.cell_node_list
return np.dot(function.dat.data_ro[cell_node_list], M_inv)
def _bezier_plot(function, axes, complex_component="real", **kwargs):
"""Plot a 1D function on a function space with order no more than 4 using
Bezier curves within each cell
:arg function: 1D :class:`~.Function` to plot
:arg axes: :class:`Axes <matplotlib.axes.Axes>` for plotting
:kwarg complex_component: If plotting complex data, which
component? (``'real'`` or ``'imag'``). Default is ``'real'``.
:arg kwargs: additional key work arguments to plot
:return: matplotlib :class:`PathPatch <matplotlib.patches.PathPatch>`
"""
deg = function.function_space().ufl_element().degree()
mesh = function.function_space().mesh()
if deg == 0:
V = FunctionSpace(mesh, "DG", 1)
interp = assemble(interpolate(function, V))
return _bezier_plot(interp, axes, complex_component=complex_component,
**kwargs)
y_vals = _bezier_calculate_points(function)
x = SpatialCoordinate(mesh)
coords = Function(FunctionSpace(mesh, 'DG', deg))
coords.interpolate(x[0])
x_vals = _bezier_calculate_points(coords)
vals = np.dstack((toreal(x_vals, "real"), toreal(y_vals, complex_component)))
codes = {1: [Path.MOVETO, Path.LINETO],
2: [Path.MOVETO, Path.CURVE3, Path.CURVE3],
3: [Path.MOVETO, Path.CURVE4, Path.CURVE4, Path.CURVE4]}
vertices = vals.reshape(-1, 2)
path = Path(vertices, np.tile(codes[deg],
function.function_space().cell_node_list.shape[0]))
# We never want to color the interior arc of a line
kwargs["facecolor"] = "none"
# _get_patches_for_fill is used for patches, but we really DO want _get_lines
# becasue we are pretending this _is_ a line
kwargs["edgecolor"] = kwargs.pop(
"edgecolor",
axes._get_lines.get_next_color()
)
kwargs["linewidth"] = kwargs.pop(
"linewidth",
plt.rcParams['lines.linewidth']
)
patch = _FiredrakeFunctionPath([path], **kwargs)
axes.add_collection(patch)
return patch
def _interp_bezier(pts, num_cells, axes, complex_component="real", **kwargs):
"""Interpolate points of a 1D function into piece-wise Bezier curves
:arg pts: Points of the 1D function evaluated by _calculate_one_dim_points
:arg num_cells: Number of cells containing the points
:arg axes: Axes to be plotted on
:kwarg complex_component: If plotting complex data, which
component? (``'real'`` or ``'imag'``). Default is ``'real'``.
:arg kwargs: Addition key word argument for plotting
"""
pts = pts.T.reshape(num_cells, -1, 2)
vertices = np.array([]).reshape(-1, 2)
rows = np.arange(4)
cols = (np.arange((pts.shape[1] - 1) // 3) * 3).reshape(-1, 1)
idx = rows + cols
# For transforming 1D points to Bezier curve
M = np.array([[1., 0., 0., 0.],
[-5/6, 3., -3/2, 1/3],
[1/3, -3/2, 3., -5/6],
[0., 0., 0., 1.]])
for i in range(num_cells):
xs = np.dot(M, pts[i, idx])
vertices = np.append(toreal(vertices, "real"),
toreal(xs.transpose([1, 0, 2]).reshape(-1, 2),
complex_component))
vertices = vertices.reshape(-1, 2)
codes = np.tile([Path.MOVETO, Path.CURVE4, Path.CURVE4, Path.CURVE4],
vertices.shape[0] // 4)
path = Path(vertices, codes)
# We never want to color the interior arc of a line
kwargs["facecolor"] = "none"
# _get_patches_for_fill is used for patches, but we really DO want _get_lines
# becasue we are pretending this _is_ a line
kwargs["edgecolor"] = kwargs.pop(
"edgecolor",
axes._get_lines.get_next_color()
)
kwargs["linewidth"] = kwargs.pop(
"linewidth",
plt.rcParams['lines.linewidth']
)
patch = _FiredrakeFunctionPath([path], **kwargs)
axes.add_collection(patch)
return patch
[docs]
class FunctionPlotter:
def __init__(self, mesh, num_sample_points):
# num_sample_points must be of the form 3k + 1 for cubic Bezier plotting
if num_sample_points % 3 != 1:
num_sample_points = (num_sample_points // 3) * 3 + 1
if mesh.topological_dimension == 1:
self._setup_1d(mesh, num_sample_points)
else:
self._setup_nd(mesh, num_sample_points)
def _setup_1d(self, mesh, num_sample_points):
self._reference_points = np.linspace(0.0, 1.0, num_sample_points).reshape(-1, 1)
def _setup_nd(self, mesh, num_sample_points):
cell_name = mesh.ufl_cell().cellname
if cell_name == "triangle":
x = np.array([0, 0, 1])
y = np.array([0, 1, 0])
elif cell_name in ["quadrilateral", "interval * interval"]:
x = np.array([0, 0, 1, 1])
y = np.array([0, 1, 0, 1])
else:
raise ValueError(f"Unsupported cell type {cell_name}")
# First, create the *reference points* -- a triangulation and points in
# a single reference cell of the mesh, which will be coarser or denser
# depending on how many sample points were specified.
base_tri = matplotlib.tri.Triangulation(x, y)
refiner = matplotlib.tri.UniformTriRefiner(base_tri)
sub_triangles = int(math.log(num_sample_points, 4))
tri = refiner.refine_triangulation(False, sub_triangles)
triangles = tri.get_masked_triangles()
self._reference_points = np.column_stack((tri.x, tri.y))
# Now create a matching triangulation of the whole domain.
num_vertices = self._reference_points.shape[0]
# TODO: What do we do with variable layers?
num_layers = 1 if mesh.layers is None else mesh.layers - 1
num_cells = mesh.coordinates.function_space().cell_node_list.shape[0] * num_layers
add_idx = np.arange(num_cells).reshape(-1, 1, 1) * num_vertices
all_triangles = (triangles + add_idx).reshape(-1, 3)
coordinate_values = self(mesh.coordinates)
X = coordinate_values.reshape(-1, mesh.geometric_dimension)
coords = toreal(X, "real")
if mesh.geometric_dimension == 2:
x, y = coords[:, 0], coords[:, 1]
self.triangulation = matplotlib.tri.Triangulation(x, y, triangles=all_triangles)
elif mesh.geometric_dimension == 3:
self.coordinates = coords
self.triangles = all_triangles
[docs]
def __call__(self, function):
# TODO: Make this more efficient on repeated calls -- for example reuse `elem`
# if the function space is the same as the last one
Q = function.function_space()
mesh = Q.mesh()
dimension = mesh.topological_dimension
keys = {1: (0,), 2: (0, 0)}
fiat_element = Q.finat_element.fiat_equivalent
elem = fiat_element.tabulate(0, self._reference_points)[keys[dimension]]
cell_node_list = Q.cell_node_list
if mesh.extruded:
cell_node_list = _extrude(cell_node_list, Q.cell_node_map().offset,
mesh.layers - 1)
data = function.dat.data_ro_with_halos[cell_node_list]
if function.ufl_shape == ():
vec_length = 1
else:
vec_length = function.ufl_shape[0]
if vec_length == 1:
data = np.reshape(data, data.shape + (1,))
return np.einsum("ijk, jl->ilk", data, elem).reshape(-1)