Layouts and Figures

earthkit-plots supports multi-panel layouts via the Figure class. A Figure manages a matplotlib GridSpec and a collection of subplots (Map or Subplot instances). You add panels to it using figure.add_map() or figure.add_subplot(), and then call plotting and decoration methods on the figure to apply them across all panels at once.

This tutorial demonstrates several layout patterns:

  1. Composing multiple layers — plotting several data fields on top of each other across a uniform grid of panels.

  2. Full layout control — building a non-uniform grid of panels by hand and placing decorations (like the legend colour bar) on custom matplotlib axes.

  3. Using a matplotlib GridSpec directly — bypassing the earthkit-plots Figure wrapper when you need complete control over the axes grid.

  4. Mixed panel types with spanning cells — combining individual and wide panels in the same figure.

[1]:
import earthkit.data as ekd

import earthkit.plots as ekp

# ERA5 monthly averaged 2-metre temperature for December 1993
era5_2t = ekd.from_source("sample", "era5-monthly-mean-2t-199312.grib")

# An ECMWF HRES forecast containing mean sea-level pressure and wind gusts
joachim = ekd.from_source(
    "url",
    "https://get.ecmwf.int/repository/test-data/metview/gallery/fc_msl_wg_joachim.grib",
).to_fieldlist()

# Storm Ophelia 850 hPa wind U and V components
wind = ekd.from_source("sample", "storm_ophelia_wind_850.grib").to_fieldlist()
wind.ls()
[1]:
parameter.variable time.valid_datetime time.base_datetime time.step vertical.level vertical.level_type ensemble.member geography.grid_type
0 u 2017-10-16 2017-10-16 0 days 850 pressure 0 regular_ll
1 v 2017-10-16 2017-10-16 0 days 850 pressure 0 regular_ll

Composing multiple layers

One of the most important advantages of the OO API is the ability to compose multiple data layers on the same subplot. In the example below, wind gust filled contours and mean sea-level pressure contour lines are plotted together across a grid of forecast time steps from Storm Joachim.

Here figure.contourf(...) and figure.contour(...) each add a layer to every subplot in the grid - earthkit-plots iterates over the panels and matches each field to the correct one. The subplot_titles call uses a format string with {time} and {lead_time} placeholders, which are resolved from each panel’s data metadata automatically.

First let’s take a look at the fields available in the Joachim dataset:

[2]:
joachim.ls()
[2]:
parameter.variable time.valid_datetime time.base_datetime time.step vertical.level vertical.level_type ensemble.member geography.grid_type
0 msl 2011-12-15 00:00:00 2011-12-15 0 days 00:00:00 0 surface 0 regular_ll
1 10fg6 2011-12-15 00:00:00 2011-12-15 0 days 00:00:00 0 surface 0 regular_ll
2 msl 2011-12-15 06:00:00 2011-12-15 0 days 06:00:00 0 surface 0 regular_ll
3 10fg6 2011-12-15 06:00:00 2011-12-15 0 days 06:00:00 0 surface 0 regular_ll
4 msl 2011-12-15 12:00:00 2011-12-15 0 days 12:00:00 0 surface 0 regular_ll
5 10fg6 2011-12-15 12:00:00 2011-12-15 0 days 12:00:00 0 surface 0 regular_ll
6 msl 2011-12-15 18:00:00 2011-12-15 0 days 18:00:00 0 surface 0 regular_ll
7 10fg6 2011-12-15 18:00:00 2011-12-15 0 days 18:00:00 0 surface 0 regular_ll
8 msl 2011-12-16 00:00:00 2011-12-15 1 days 00:00:00 0 surface 0 regular_ll
9 10fg6 2011-12-16 00:00:00 2011-12-15 1 days 00:00:00 0 surface 0 regular_ll
10 msl 2011-12-16 06:00:00 2011-12-15 1 days 06:00:00 0 surface 0 regular_ll
11 10fg6 2011-12-16 06:00:00 2011-12-15 1 days 06:00:00 0 surface 0 regular_ll
12 msl 2011-12-16 12:00:00 2011-12-15 1 days 12:00:00 0 surface 0 regular_ll
13 10fg6 2011-12-16 12:00:00 2011-12-15 1 days 12:00:00 0 surface 0 regular_ll
14 msl 2011-12-16 18:00:00 2011-12-15 1 days 18:00:00 0 surface 0 regular_ll
15 10fg6 2011-12-16 18:00:00 2011-12-15 1 days 18:00:00 0 surface 0 regular_ll
16 msl 2011-12-17 00:00:00 2011-12-15 2 days 00:00:00 0 surface 0 regular_ll
17 10fg6 2011-12-17 00:00:00 2011-12-15 2 days 00:00:00 0 surface 0 regular_ll
[3]:
figure = ekp.Figure(domain=[-5, 23, 40, 58], figsize=(7, 7), rows=3, columns=3)

figure.contourf(joachim.sel({"parameter.variable": "10fg6"}), style="auto")
figure.contour(joachim.sel({"parameter.variable": "msl"}), units="hPa", style="auto")

figure.land()
figure.coastlines()
figure.borders()

figure.legend()
figure.subplot_titles("{time:%Y-%m-%d %H} UTC (+{lead_time}h)")
figure.title(
    "ECMWF HRES Run: {base_time:%Y-%m-%d %H} UTC\n{variable_name}",
    y=1.1,
)

figure.show()
<Figure size 700x700 with 0 Axes>
../../../_images/examples_examples_introduction_04-layouts_5_1.png

Full layout control

The OO API also lets you define exactly which subplots exist, where they sit in the grid, and what each one shows. Panels are added by calling figure.add_map(row, column) at specific grid positions - you can leave cells empty to create space for other elements, or pass a slice to make an axes fill the space of multiple cells.

In the example below, a custom legend axis is created directly with matplotlib and a non-uniform panel layout is built by hand. A 3×4 grid is used with the top-left three cells left empty to make room for a colour bar, and eight panels are arranged across the remaining cells:

[4]:
import matplotlib.pyplot as plt

figure = ekp.Figure(domain=[-5, 23, 40, 58], figsize=(9, 7), rows=3, columns=4)

gust_style = ekp.styles.Style(
    colors=["#85AAEE", "#208EFC", "#6CA632", "#FFB000", "#FF0000", "#7A11B1"],
    levels=[12, 15, 20, 25, 30, 35, 50],
    units="m s-1",
)

# Start at the top-right cell, leaving a gap for the colour bar
figure.add_map(0, 3)
for i in range(8):
    figure.add_map(1 + i // 4, i % 4)

figure.contourf(joachim.sel({"parameter.variable": "10fg6"}), style=gust_style)
figure.contour(joachim.sel({"parameter.variable": "msl"}), units="hPa", style="auto")

figure.land()
figure.coastlines()
figure.borders()

# Place the colourbar on a custom matplotlib axes
ax = plt.axes((0.05, 0.8, 0.65, 0.025))
figure.legend(ax=ax)

figure.subplot_titles("{time.valid_datetime:%Y-%m-%d %H} UTC")
figure.title(
    "ECMWF HRES Run: {base_time:%Y-%m-%d %H} UTC\n{variable_name}",
    fontsize=13,
    horizontalalignment="left",
    x=0,
    y=0.96,
)

figure.show()
../../../_images/examples_examples_introduction_04-layouts_7_0.png

Using a matplotlib GridSpec directly

For cases where the earthkit-plots grid layout is not flexible enough, you can create a matplotlib GridSpec by hand and pass it directly into ekp.Figure via the gridspec= argument. This gives you complete control over spacing, aspect ratios, and panel sizes while still using all of earthkit-plots’ plotting and decoration methods.

The example below combines a large Europe map with a small globe inset in the top-left corner. The globe uses a NearsidePerspective projection and shows a red box indicating the Europe domain extent. Both the domain bounds and CRS for the box are read directly from earthkit-plots’ built-in domain definitions via Domain.from_string("Europe"), so no manual coordinate constants are needed.

Key techniques shown:

  • Passing a GridSpec to ekp.Figure — rows and columns are inferred automatically

  • Using slice() to span a subplot across multiple rows or columns

  • Mixing projections across panels in the same figure

  • Reusing built-in domain metadata (Domain.from_string) to draw the inset bounding box

Matplotlib has extensive documentation on GridSpecs.

[5]:
import cartopy.crs as ccrs
import matplotlib.gridspec as gridspec
import matplotlib.patches as mpatches

from earthkit.plots.geography.domains import Domain

gs = gridspec.GridSpec(6, 6, wspace=0.05, hspace=0.05)
figure = ekp.Figure(gridspec=gs)

europe = Domain.from_string("Europe")
west, east, south, north = europe.bbox

europe_map = figure.add_map(
    row=slice(1, 6),
    column=slice(0, 6),
    domain=europe,
)
europe_map.contourf(era5_2t, style="auto", units="celsius")
europe_map.coastlines()
europe_map.borders()
europe_map.land()
europe_map.title("{variable_name} - {time:%B %Y}")

globe_map = figure.add_map(
    0,
    1,
    crs=ccrs.NearsidePerspective(central_longitude=15, central_latitude=50),
)
globe_map.contourf(era5_2t, style="auto", units="celsius")
globe_map.coastlines(linewidth=0.2)
globe_map.borders(linewidth=0.2)

globe_map.ax.add_patch(
    mpatches.Rectangle(
        xy=(west, south),
        width=east - west,
        height=north - south,
        facecolor="none",
        edgecolor="red",
        linewidth=1.5,
        transform=europe.bbox.crs,
    )
)

figure.legend(location="bottom", label="{variable_name} ({units})")
figure.show()
../../../_images/examples_examples_introduction_04-layouts_9_0.png

Mixed panels with spanning cells

You can pass Python slice objects as the row or column argument to figure.add_map() to make a panel span multiple grid cells — exactly like indexing a matplotlib GridSpec. The example below creates a 2×2 figure with Storm Ophelia wind data:

  • Top-left: U-component of wind

  • Top-right: V-component of wind

  • Bottom row (spanning both columns): wind speed as a quiver vector plot

The U and V components are selected from the fieldlist by short name, then the vector plot takes both components together.

[6]:
u = wind.sel({"parameter.variable": "u"})
v = wind.sel({"parameter.variable": "v"})

uv_style = ekp.styles.Style(
    levels=range(-40, 41, 5),
    units="m s-1",
    colors=["blue", "white", "red"],
    extend="both",
)

speed_style = ekp.styles.Style(
    levels=range(0, 41, 5),
    units="m s-1",
    colors="turbo",
    #     extend="max"
)

figure = ekp.Figure(rows=2, columns=2)

# Two individual panels in the top row
panel_u = figure.add_map(0, 0)
panel_u.contourf(u, style=uv_style)

panel_v = figure.add_map(0, 1)
panel_v.contourf(v, style=uv_style)

# One wide panel spanning both columns in the bottom row
panel_wind = figure.add_map(1, slice(0, 2))
panel_wind.quiver(u, v, style=speed_style)

figure.coastlines()
figure[2].land()  # Add land polygons to the third subplot
figure.legend(label="wind speed ({units})")

figure.subplot_titles("{variable_name}")

figure.title("U and V components of wind with wind speed and direction at {time}")

figure.show()
../../../_images/examples_examples_introduction_04-layouts_11_0.png

Because earthkit-plots is built on matplotlib, you can drop into raw matplotlib at any point using the .ax and .fig escape hatches on any subplot or figure object. This means there is no ceiling on what you can produce — if earthkit-plots does not expose a setting directly, matplotlib almost certainly does.

For an introduction to the OO and functional APIs more broadly, see the OO and functional APIs notebook.