Why faceted?

At first glance it might seem we are re-inventing the wheel here. If you just google for “matplotlib subplots with shared colorbar” you’ll find a StackOverflow question with numerous answers with varying levels of complexity (some in fact are quite elegant). It might be tempting to go with one of these solutions, e.g.

In [1]: import xarray as xr

In [2]: import matplotlib.pyplot as plt

In [3]: ds = xr.tutorial.load_dataset('air_temperature').isel(time=slice(0, 3))

In [4]: fig, axes = plt.subplots(1, 3, figsize=(8, 4))

In [5]: for i, ax in enumerate(axes):
   ...:       c = ds.air.isel(time=i).plot.pcolormesh(
   ...:           ax=ax, add_colorbar=False, vmin=230, vmax=305)
   ...: 

In [6]: plt.tight_layout()

In [7]: fig.colorbar(c, ax=axes.ravel().tolist(), orientation='horizontal',
   ...:     label='Air temperature');
   ...: 
_images/example_tair.png

This looks ok, but things become a bit more challenging when we’d like to have a more control over the spacing and size of elements in the figure. matplotlib is super-flexible in that it is indeed possible to do this, but if your starting point for creating paneled figures is matplotlib.pyplot.subplots(), which it so often is for many of us, your options for exerting this type of control are somewhat tricky to use.

Let’s take the example above and start to impose some contraints:

  • Tight layout does a decent job of finding the right between-panel padding based on the axes labels, but I’d rather have direct control over this. Let’s impose a horizontal padding of half an inch between panels.

  • The colorbar is rather thick. Let’s set it to a fixed width of an eighth of an inch. This thickness should not depend on the overall dimensions of the figure.

  • The data we are plotting is geographic in nature; we really should be using cartopy, which will require that the panels have a strict aspect ratio, related to the extent of the domain in latitude-longitude space. Currently the aspect ratio is set dynamically based on the total figure size and matplotlib defaults for between-plot spacing and outer padding.

One by one we’ll go through these illustrating how much complexity this adds to our code just to produce a simple figure.

Fixing the between-plot spacing

As soon as we try to assign a certain amount of physical space to a plot element, we need to do some algebra. This is because to change the panel spacing after a call to matplotlib.pyplot.subplots(), we need to use matplotlib.pyplot.subplots_adjust(), which takes parameters representing an amount of relative space, meaning expressed as a fraction of a plot element, be it the whole figure or a single panel.

To help set up the problem, let’s define some variables. First, let’s say that we have \(m\) rows of \(n\) panels each; in our example \(m = 1\) and \(n = 3\). Then let’s say that we would like to introduce an internal pad, \(p_{internal}\), representing the spacing between the axes in inches. In order to use matplotlib.pyplot.subplots_adjust(), we need to determine the amount of relative space \(p_{internal}\) represents. In the context of the wspace parameter, the parameter that controls the spacing between panels, we need to determine the ratio of the width of the internal padding and the width of a single panel \(w_{panel}\). For a figure of width \(w\), with outer left and right paddings of \(p_{left}\) and \(p_{right}\) the width of a single panel is given by:

\[w_{panel} = \frac{w - p_{left} - p_{right} - (n - 1) p_{internal}}{n}.\]

Therefore the value we pass to wspace in matplotlib.pyplot.subplots_adjust() is:

\[\texttt{wspace} = \frac{p_{internal}}{w_{panel}}.\]

Finally, since in this process we needed to fix the left and right pads of the figure, we need to specify those in matplotlib.pyplot.subplots_adjust() too; note these are defined relative to the full figure width rather than the width of single panel:

\[\texttt{left} = \frac{p_{left}}{w}\]
\[\texttt{right} = \frac{w - p_{right}}{w}.\]

Writing this all out in code gives:

In [8]: w = 8.0

In [9]: p_left = 0.5

In [10]: p_right = 0.5

In [11]: m, n = (1, 3)

In [12]: p_internal = 0.5

In [13]: w_panel = (w - p_left - p_right - (n - 1) * p_internal) / n

In [14]: wspace = p_internal / w_panel

In [15]: left = p_left / w

In [16]: right = (w - p_right) / w

If we use these values when plotting we get:

In [17]: fig, axes = plt.subplots(1, 3, figsize=(w, 4), sharey=True)

In [18]: for i, ax in enumerate(axes):
   ....:       c = ds.air.isel(time=i).plot.pcolormesh(
   ....:           ax=ax, add_colorbar=False, vmin=230, vmax=305)
   ....: 

In [19]: fig.subplots_adjust(left=left, right=right, wspace=wspace)

In [20]: fig.colorbar(c, ax=axes.ravel().tolist(), orientation='horizontal',
   ....:     label='Air temperature');
   ....: 
_images/example_tair_adjusted.png

Fixing the colorbar thickness

Keeping the colorbar thickness constant introduces some additional challenges. Since fig.colorbar locates it on the bottom of the plot, we’ll need to set top and bottom pads for the figure, \(p_{top}\) and \(p_{bottom}\), a pad between the colorbar and the panels, \(p_{cbar}\), a thickness for the colorbar, \(w_{cbar}\) and a height for the overall figure \(h\):

In [21]: p_top = 0.5

In [22]: p_bottom = 0.5

In [23]: p_cbar = 0.5

In [24]: w_cbar = 0.125

In [25]: h = 4.

The top and bottom pads need to be passed to matplotlib.pyplot.subplots_adjust() and they follow similar conventions to the left and right pads, i.e. they are defined in terms of length relative to the overall height of the figure. The size of the colorbar is controlled differently; we control its size when we construct it using matplotlib.pyplot.colorbar(), using the fraction, pad, and aspect arguments. fraction dictates the fraction of the height of the colorbar would take with respect to the height of a single panel in the original figure; pad dictates the fraction of a single panel in the original figure the padding between the colorbar and panels would take; and aspect sets the ratio of the width of the long part of the colorbar to its thickness. Note that since we call matplotlib.pyplot.subplots_adjust() before calling matplotlib.pyplot.colorbar(), the panel height in the original figure is determined in part by our imposed \(p_{top}\) and \(p_{bottom}\). In this case since we are only using a single row of panels, we do not need to worry about the between panel spacing in this dimension, but we’ll include the \(p_{internal}\) term to keep things general:

\[h_{panel-original} = \frac{h - p_{top} - p_{bottom}}{m + p_{internal} (m - 1)}\]
\[\texttt{fraction} = \frac{w_{cbar}}{h_{panel-original}}\]
\[\texttt{pad} = \frac{p_{cbar}}{h_{panel-original}}\]
\[\texttt{aspect} = \frac{w - p_{left} - p_{right}}{w_{cbar}}.\]
In [26]: h_panel_original = h - p_top - p_bottom

In [27]: fraction = w_cbar / h_panel_original

In [28]: pad = p_cbar / h_panel_original

In [29]: cbar_aspect = (w - p_left - p_right) / w_cbar

In [30]: top = (h - p_top) / h

In [31]: bottom = p_bottom / h
In [32]: fig, axes = plt.subplots(1, 3, figsize=(w, h), sharey=True)

In [33]: for i, ax in enumerate(axes):
   ....:       c = ds.air.isel(time=i).plot.pcolormesh(
   ....:           ax=ax, add_colorbar=False, vmin=230, vmax=305)
   ....: 

In [34]: fig.subplots_adjust(left=left, right=right, wspace=wspace, top=top, bottom=bottom)

In [35]: fig.colorbar(c, ax=axes.ravel().tolist(), orientation='horizontal',
   ....:     label='Air temperature', fraction=fraction, pad=pad, aspect=cbar_aspect);
   ....: 
_images/example_tair_adjusted_cbar.png

Holding panels at a fixed aspect ratio

Things are starting to look much better, but there’s still more work to do. Let’s introduce cartopy to the mix. Adding a cartopy projection turns out to fix the aspect ratio of the panels in the figure, regardless of the figure size. We’ll want to address this additional constraint by adjusting our value for the total height of the figure, because the panel height will now by completely determined by the panel width. In a cartopy.crs.PlateCarree projection, the aspect ratio will be determined by the ratio of the latitudinal extent of the map divided by the longitudinal extent. In this case it will be \(\texttt{aspect} = \frac{75}{360}\). \(h_{panel}\) will now be determined completely based on this aspect ratio and the panel width, \(w_{panel}\) we determined earlier:

\[h_{panel} = a w_{panel}.\]

The total height, \(h\) is now just the sum of the height of the plot elements:

\[h = m h_{panel} + (m - 1) p_{internal} + p_{bottom} + p_{top} + p_{cbar} + w_{cbar}.\]

As a result of the height values changing, we’ll need to update the bottom and top parameters for matplotlib.pyplot.subplots_adjust() as well as the colorbar size parameters:

In [36]: a = 60. / 130.

In [37]: p_cbar = 0.25

In [38]: h_panel = a * w_panel

In [39]: h = p_bottom + p_top + h_panel + p_cbar + w_cbar

In [40]: h_panel_original = h - p_top - p_bottom

In [41]: fraction = w_cbar / h_panel_original

In [42]: pad = p_cbar / h_panel_original

In [43]: cbar_aspect = (w - p_left - p_right) / w_cbar

In [44]: top = (h - p_top) / h

In [45]: bottom = p_bottom / h
In [46]: import cartopy.crs as ccrs

In [47]: fig, axes = plt.subplots(1, 3, figsize=(w, h),
   ....:     subplot_kw={'projection': ccrs.PlateCarree()})
   ....: 

In [48]: for i, ax in enumerate(axes):
   ....:       c = ds.air.isel(time=i).plot.pcolormesh(
   ....:           ax=ax, add_colorbar=False, vmin=230, vmax=305,
   ....:           transform=ccrs.PlateCarree())
   ....:       ax.coastlines()
   ....:       ax.set_extent([-160, -30, 15, 75], crs=ccrs.PlateCarree())
   ....: 

In [49]: fig.subplots_adjust(left=left, right=right, wspace=wspace, top=top, bottom=bottom)

In [50]: fig.colorbar(c, ax=axes.ravel().tolist(), orientation='horizontal',
   ....:     label='Air temperature', fraction=fraction, pad=pad, aspect=cbar_aspect);
   ....: 
_images/example_tair_adjusted_cartopy.png

As examples go, this one was actually fairly simple; we only had one row of panels, rather than multiple, and we only had one colorbar. Taking the matplotlib.pyplot.subplots() approach was remarkably complicated.

What about AxesGrid?

In theory, it would be more straightforward to use the mpl_toolkits.axes_grid1.AxesGrid framework to do this. Having said that, it would still require a bit of math to determine the appropriate figure height. In addition there are some other problems with that approach, e.g.

  • Using mpl_toolkits.axes_grid1.AxesGrid with cartopy is not ideal due to axes sharing issues (SciTools/cartopy#939).

  • Colorbars drawn using mpl_toolkits.axes_grid1.AxesGrid are drawn using an outdated colorbar class in matplotlib, which is different than the one used by default (matplotlib/matplotlib#9778).

In faceted we use mpl_toolkits.axes_grid1.AxesGrid to aid in the placing the axes and colorbars, but we do not use the axes generated by it. Instead we create our own, which are modern and have working axes-sharing capabilities. In so doing we create a matplotlib.pyplot.subplots()-like interface, which is slightly more intuitive to use than mpl_toolkits.axes_grid1.AxesGrid with or without cartopy.

How would you do this in faceted?

In faceted this becomes much simpler; there is no need to do any algebra or post-hoc adjustment of the axes placement; everything gets handled in the top-level function.

In [51]: from faceted import faceted

In [52]: fig, axes, cax = faceted(1, 3, width=w, aspect=a,
   ....:                          left_pad=p_left, right_pad=p_right,
   ....:                          bottom_pad=p_bottom, top_pad=p_top,
   ....:                          internal_pad=p_internal,
   ....:                          cbar_mode='single', cbar_location='bottom',
   ....:                          cbar_size=w_cbar, cbar_pad=p_cbar, cbar_short_side_pad=0.,
   ....:                          axes_kwargs={'projection': ccrs.PlateCarree()})
   ....: 

In [53]: for i, ax in enumerate(axes):
   ....:       c = ds.air.isel(time=i).plot.pcolormesh(
   ....:           ax=ax, add_colorbar=False, vmin=230, vmax=305,
   ....:           transform=ccrs.PlateCarree())
   ....:       ax.coastlines()
   ....:       ax.set_extent([-160, -30, 15, 75], crs=ccrs.PlateCarree())
   ....: 

In [54]: plt.colorbar(c, cax=cax, orientation='horizontal',
   ....:               label='Air temperature');
   ....: 
_images/example_tair_faceted.png

What can’t you do in faceted?

The main thing that faceted cannot do is create a constrained set of axes that have varying size, or varying properties. For more complex figure construction tasks we recommend using a more fundamental matplotlib approach, either using mpl_toolkits.axes_grid1.AxesGrid, matplotlib.GridSpec, or Constrained Layout. The main reason for creating faceted was that these other tools were too flexible at the expense of simplicity. For a large percentage of the use cases, they are not required, but for the remaining percentage they are indeed quite useful.