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:
Composing multiple layers — plotting several data fields on top of each other across a uniform grid of panels.
Full layout control — building a non-uniform grid of panels by hand and placing decorations (like the legend colour bar) on custom matplotlib axes.
Using a matplotlib GridSpec directly — bypassing the earthkit-plots
Figurewrapper when you need complete control over the axes grid.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>
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()
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
GridSpectoekp.Figure— rows and columns are inferred automaticallyUsing
slice()to span a subplot across multiple rows or columnsMixing 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()
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()
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.