plotastro — tutorial

Publication-quality matplotlib figures for astronomy journals. This notebook walks through every feature:

Install with pip install plotastro (or pip install -e . from a clone). The CMasher part of section 3 also needs the optional cmasher package (pip install cmasher). See README.md for the full reference documentation.

import sys
from pathlib import Path

import numpy as np
import matplotlib.pyplot as plt

if Path("../src/plotastro").is_dir():    # running from a clone: use its code,
    sys.path.insert(0, "../src")          # even if another release is installed
import plotastro as pa

print("plotastro", pa.__version__)
rng = np.random.default_rng(1)
plotastro 1.0.1

1. Quick start

pa.set_style(journal) activates one of the journal styles, and pa.subplots() creates a figure that is exactly one column wide for that journal, with a golden-ratio height. Because the figure already has the right physical size, you include it in LaTeX without any scaling — so the fonts on the page come out exactly as designed (~8–9 pt, matching the journal’s own text).

Available journals: mnras, rasti, aanda (A&A), apj/apjl, oja, prd/prl, jcap, natastro (Nature Astronomy), euclid (Euclid Consortium papers, see section 8), plus thesis and beamer width presets.

pa.set_style("mnras")

x = np.linspace(0, 4 * np.pi, 300)

fig, ax = pa.subplots()
for i in range(3):
    ax.plot(x, np.sin(x - i * np.pi / 4) * np.exp(-x / 12), label=f"$\\phi = {i}\\pi/4$")
ax.set_xlabel("$t$ [s]")
ax.set_ylabel(r"$A\,\sin(t-\phi)\,e^{-t/\tau}$")
ax.legend()
plt.show()
_images/1c9156c67e34c906d8ee2feac66bddb326edd45da8bbddce419ee6da19a5e7d9.png

In LaTeX you would then include it with:

\begin{figure}
    \includegraphics{myplot.pdf}   % no [width=...] needed!
    \caption{...}
\end{figure}

Prefer plain matplotlib? Nothing new to learn

Importing plotastro registers the styles with matplotlib itself, so you can ignore every helper in this package and keep writing ordinary matplotlib — one import and one plt.style.use line is the entire integration. The default figure size baked into each style is already the journal’s column width:

import plotastro                    # just to register the styles

plt.style.use("aanda")              # plain matplotlib from here on
fig, ax = plt.subplots()            # already A&A column-sized
ax.plot(x, np.sin(x) * np.exp(-x / 12))
ax.set_xlabel("$t$ [s]")
ax.set_ylabel("$y$")
plt.show()

pa.set_style("mnras")               # back to the helpers for this tutorial
_images/77710bf4d4064285bacbda4f40fe18a60167af7686db299e22f24d397f59c388.png

2. Figure sizes that match your journal

Why bother? If you make a 6-inch-wide figure and LaTeX squeezes it into an 84 mm column, everything shrinks by ~50% — your 10 pt labels become unreadable 5 pt labels. The fix is to build the figure at its final printed size.

pa.figsize() knows the column and full text widths of each journal:

argument

meaning

width="column"

one column wide (the default)

width="full"

full text width (both columns)

width=252.0

any width in LaTeX points (get yours with \the\columnwidth)

fraction=0.5

a fraction of that width

aspect=1

height/width of one panel (default: golden ratio ≈ 0.618)

nrows, ncols

height scales with the subplot grid

print("one column      :", pa.figsize("column"))
print("full width      :", pa.figsize("full"))
print("half a column   :", pa.figsize("column", fraction=0.5))
print("square panel    :", pa.figsize("column", aspect=1))
print("A&A column      :", pa.figsize("column", journal="aanda"))
one column      : (3.3208800332088004, 2.0524167330839185)
full width      : (6.973848069738481, 4.310075139476229)
half a column   : (1.6604400166044002, 1.0262083665419592)
square panel    : (3.3208800332088004, 3.3208800332088004)
A&A column      : (3.464508094645081, 2.1411837567897978)

pa.subplots() accepts the same arguments plus everything plt.subplots takes (sharex, gridspec_kw, …). A full-width two-panel figure:

k = np.logspace(-3, 1, 300)

fig, axes = pa.subplots(1, 2, width="full", aspect=0.75, sharex=True)
for z in [0, 0.5, 1, 2]:
    pk = 1e4 * k / (1 + (k / 0.02) ** 2.2) / (1 + z) ** 1.5
    axes[0].loglog(k, pk, label=f"$z = {z}$")
    axes[1].semilogx(k, pk / (1e4 * k / (1 + (k / 0.02) ** 2.2)))
axes[0].set_ylabel(r"$P(k)\ [h^{-3}\,\mathrm{Mpc}^3]$")
axes[1].set_ylabel(r"$P(k)\,/\,P(k, z=0)$")
for ax in axes:
    ax.set_xlabel(r"$k\ [h\,\mathrm{Mpc}^{-1}]$")
axes[0].legend()
plt.show()
_images/9698b5fd352ed560977977d913b7cfd3f22640a8513bba0ff138c6a004aaa850.png

3. The colour palettes

The default colour cycle is designed so that any two neighbouring colours remain distinguishable with the most common forms of colour-vision deficiency (deuteranopia and protanopia, ~5% of male readers — MNRAS explicitly asks for colour-blind-friendly figures).

  • C0–C8 are a colour-blind-safe re-ordering of the ColorBrewer Set1 palette: red and green are pushed far apart in the cycle (C7 and C2), so consecutive lines never form the classic problem pair, and adjacent colours differ in lightness as well as hue.

  • C9–C11 are light companions (from Tableau’s Color Blind 10): perfect for uncertainty bands, reference lines, or de-emphasised data under a saturated line of the same hue.

Every colour is available by name via pa.COLORS:

fig = pa.show_colors()
plt.show()
_images/844dcf398b00991c737a518c0c27edaad8f835197d5c02730535451367db94fc.png
x = np.linspace(0.5, 10, 200)
y = 2 * x ** -0.7

fig, ax = pa.subplots()
ax.plot(x, y, color=pa.COLORS["blue"], label="model")
ax.fill_between(x, 0.85 * y, 1.15 * y,
                color=pa.lighten(pa.COLORS["blue"], 0.7), label=r"$1\sigma$")
ax.plot(x, 1.6 * x ** -0.55, color=pa.COLORS["red"], ls="--", label="alternative")
ax.set_xlabel(r"$r$ [Mpc]")
ax.set_ylabel(r"$\xi(r)$")
ax.loglog()
ax.legend()
plt.show()
_images/aa4dc880db1b9ac495914d9374a49c3c6a068a57cf7a1422827d00f31c7035a2.png

pa.lighten(colour, amount) and pa.darken(colour, amount) create matched shades — much nicer than alpha, because they stay opaque (no colour shifts where elements overlap, and they print correctly in EPS).

More palettes ship with the package:

  • pa.OKABE_ITO — Okabe & Ito (2008), the classic CVD-safe recommendation;

  • pa.PETROFF10 — Petroff (2021), the 10-colour CVD-optimised cycle used across particle physics (matplotlib’s petroff10);

  • pa.PETROFF8 — Petroff’s 8-colour sibling (matplotlib’s petroff8), the default cycle of the Euclid style (section 8);

  • pa.TOL_VIBRANT — Paul Tol’s vibrant scheme, 7 CVD-safe colours;

  • pa.PAIRED — light/dark pairs for data/model or before/after comparisons: pa.PAIRED["blue"] → (light, dark).

Any of them, or your own list of colours, can become the active cycle when you activate a style: pa.set_style("mnras", palette="okabe_ito").

fig = pa.show_colors(pa.PETROFF10, title="Petroff (2021)")
plt.show()
_images/0abd3b9f9c8fcb615b14481a1df0753c6ff02df6dce98355ef607c9d9db41f26.png
x = np.linspace(0, 10, 40)
fig, ax = pa.subplots()
for i, (name, (light, dark)) in enumerate(list(pa.PAIRED.items())[:3]):
    y = np.sin(x + i) + 3 * i
    ax.plot(x, y + rng.normal(0, 0.18, x.size), "o", ms=2.5, color=light)
    ax.plot(x, y, color=dark, label=f"model {name}")
ax.set_xlabel("$x$")
ax.set_ylabel("$y$")
ax.legend()
plt.show()
_images/6ec7cf891a8c9df0110e52e80a5f4e7273af791fbadc5bba64ecfb87e3fb58a1.png

Colours and colormaps from CMasher (optional)

CMasher (van der Velden 2020, JOSS 5, 2004) is a collection of perceptually uniform scientific colormaps (sequential, diverging and cyclic), most of them colour-vision-deficiency friendly. plotastro can use it for both discrete colours and colormaps, but it is not a dependency. Install it only if you want it:

pip install cmasher            # or: pip install "plotastro[cmasher]"

plotastro imports it only when you ask for a CMasher colour. Without it, those calls raise an ImportError that says how to install it, and the rest of this notebook works the same.

There are five ways to use it:

you want

write

a colour cycle for every figure

pa.set_style("mnras", palette="cmr.rainforest")

a default colormap for every figure

pa.set_style("mnras", cmap="cmr.ocean")

n colours, e.g. one per line

pa.cmasher_colors("torch", n=5)

a colormap, optionally cut or split into levels

pa.cmasher_cmap("iceburn", cmap_range=(0.1, 0.9), n=6)

a map by name in any matplotlib call

cmap="cmr.iceburn" (once CMasher is imported)

CMasher names start with cmr.. pa.cmasher_colors and pa.cmasher_cmap accept them with or without the prefix, but set_style and matplotlib need it. Add _r to a name for the reversed map.

Choosing a map. Pick the type of map from the kind of data:

data

type of map

try

lines that must be easy to tell apart; images

sequential, many hues

rainforest, torch, chroma, neon, apple

steps of one quantity (redshift, mass, time)

sequential, one hue

ocean, flamingo, freeze, jungle, gothic

signed data around zero (residuals, velocities)

diverging

fusion, waterlily, viola (white centre); iceburn, redshift, wildfire (black centre)

angles and phases

cyclic

infinity, seasons, emergency

The next cell draws some of them. Under each map is its greyscale version, from pa.simulate_cvd, which is what a black-and-white printout shows:

groups = {
    "sequential, many hues": ["rainforest", "torch", "chroma", "neon"],
    "sequential, one hue": ["ocean", "flamingo", "freeze", "jungle"],
    "diverging": ["fusion", "waterlily", "iceburn", "redshift"],
    "cyclic": ["infinity", "seasons", "seasons_s", "emergency"],
}
gradient = np.linspace(0, 1, 256)[None, :]

fig = plt.figure(figsize=pa.figsize("full", aspect=0.55))
for subfig, (kind, names) in zip(fig.subfigures(len(groups), 1), groups.items()):
    subfig.suptitle(kind, fontsize=7)
    for ax, name in zip(subfig.subplots(1, len(names)), names):
        rgba = pa.cmasher_cmap(name)(gradient)
        grey = pa.simulate_cvd(rgba, "greyscale")
        ax.imshow(np.vstack([rgba[..., :3]] * 2 + [grey[..., :3]]), aspect="auto")
        ax.set_title(f"cmr.{name}", fontsize=6.5, family="monospace", pad=2)
        ax.set_axis_off()
plt.show()
_images/3736bed7f450c7bb547ee20a995e17ac19ef112a4020bb71762508fac4c67799.png

What the greyscale versions show:

  • In every CMasher sequential map the lightness rises steadily from one end to the other, so the order of the colours survives a black-and-white printout.

  • A diverging map is equally light at equal distances either side of its centre, so in greyscale +x and -x look alike. If the sign matters in print, add contours or say so in the caption. A white centre fades values near zero into the page, which suits print. A black centre makes the extremes the brightest colours, which stands out on dark slides.

  • A cyclic map starts and ends on the same colour. The _s versions are shifted by half a cycle: seasons is white in the middle of its range, seasons_s at the ends.

cmasher.get_cmap_list("sequential") (or "diverging", "cyclic") lists every map, and the CMasher docs show them all.

Discrete colours. pa.cmasher_colors(name, n) samples n colours from a map. By default they come from the middle 70 % of it (cmap_range=(0.15, 0.85)), because most CMasher maps run from black to white and those ends would vanish on the page. Sampling exactly as many colours as you have lines makes them span the whole map:

print(pa.cmasher_colors("rainforest", n=4))

z = [0, 0.25, 0.5, 1, 1.5, 2]
k = np.logspace(-3, 1, 300)

fig, ax = pa.subplots()
ax.set_prop_cycle(color=pa.cmasher_colors("rainforest", n=len(z)))
for zi in z:
    ax.loglog(k, 1e4 * k / (1 + (k / 0.02) ** 2.2) / (1 + zi) ** 1.5,
              label=f"$z = {zi}$")
ax.set_xlabel(r"$k\ [h\,\mathrm{Mpc}^{-1}]$")
ax.set_ylabel(r"$P(k)\ [h^{-3}\,\mathrm{Mpc}^3]$")
ax.legend(ncols=2)
plt.show()
['#33034a', '#035f84', '#41a550', '#edc87d']
_images/b578024ec827d051b97fc239b0ff78a0926217afaaa742022da180ad72f08a67.png

To make a CMasher map the colour cycle of every figure, pass it as the palette (8 colours): pa.set_style("mnras", palette="cmr.rainforest"). For lines that must be easy to tell apart, CMasher recommends maps with a large perceptual range (apple, chroma, neon, rainforest, torch); for steps of one quantity, like the redshifts above, a single-hue map (flamingo, freeze, gothic, jungle, ocean). Check the result as you would any other palette:

fig = pa.check_colors(pa.cmasher_colors("torch", n=6))
plt.show()
_images/2b42ba36367ad7ad16eee94740ae1c25883d494f256981f18bdd4323ec8d4351.png

The colours are plain hex strings, so they work anywhere matplotlib takes a colour. Pass one to pa.lighten for a matching uncertainty band (left). pa.lighten keeps a colour’s saturation, so a near-black navy would turn into a strong blue. For bands, start the range above the darkest end, as with cmap_range=(0.3, 0.8) here. To make lines differ in greyscale as well, pair the colours with markers using plt.cycler (right). pa.style_cycler can’t do this, because it always uses the default palette.

colors = pa.cmasher_colors("torch", n=3, cmap_range=(0.3, 0.8))
x = np.linspace(0, 1, 100)
xd = np.linspace(0.05, 0.95, 10)

fig, (ax1, ax2) = pa.subplots(1, 2, width="full", aspect=0.75)
for i, color in enumerate(colors):
    y = x ** (0.5 + 0.7 * i)
    ax1.plot(x, y, color=color, label=f"model {i + 1}")
    ax1.fill_between(x, 0.9 * y, 1.1 * y, color=pa.lighten(color, 0.6))

ax2.set_prop_cycle(plt.cycler(color=colors) + plt.cycler(marker=pa.MARKERS[:3]))
for i in range(3):
    ax2.plot(xd, xd ** (0.5 + 0.7 * i), label=f"sample {i + 1}")

for ax in (ax1, ax2):
    ax.set_xlabel("$x$")
    ax.set_ylabel("$y$")
    ax.legend(loc="upper left")
pa.label_panels([ax1, ax2], loc="lower right")
plt.show()
_images/9c2fda594b40040e35eccdfa8567385745ad997664a76388d57b5fcd58a39a9d.png

Colormaps. pa.set_style(..., cmap="cmr.<name>") makes a CMasher map the default for imshow, pcolormesh, scatter and the rest, and pa.cmasher_cmap(name) returns the map itself. It can also cut the map to part of its range (cmap_range=) or split it into n discrete levels (n=), which suits filled contours. Once CMasher is imported, matplotlib also accepts its maps by name, as in cmap="cmr.iceburn". Note that iceburn has a black centre, while fusion has a white one:

pa.set_style("mnras", cmap="cmr.ocean")      # the default colormap from now on

yy, xx = np.mgrid[-3:3:200j, -3:3:200j]
blob1 = np.exp(-((xx - 0.8)**2 + yy**2))
blob2 = np.exp(-((xx + 1.2)**2 + (yy - 1)**2) / 0.5)
density = blob1 + 0.6 * blob2              # positive: sequential map
field = blob1 - 0.8 * blob2                # signed: diverging maps
ext = [-3, 3, -3, 3]

fig, axes = pa.subplots(1, 3, width="full", aspect=1)
panels = [
    axes[0].imshow(density, origin="lower", extent=ext),   # the default cmap
    axes[1].imshow(field, origin="lower", extent=ext, vmin=-1, vmax=1,
                   cmap=pa.cmasher_cmap("iceburn")),
    axes[2].contourf(xx, yy, field, levels=np.linspace(-1, 1, 6),
                     cmap=pa.cmasher_cmap("fusion", n=5)),
]
titles = ['default: "cmr.ocean"', 'cmasher_cmap("iceburn")',
          'cmasher_cmap("fusion", n=5)']
for ax, mappable, title in zip(axes, panels, titles):
    fig.colorbar(mappable, ax=ax, orientation="horizontal")
    ax.set_title(title, fontsize=7, family="monospace")
    ax.set(xticks=[], yticks=[], aspect="equal")
    ax.grid(False)
plt.show()

pa.set_style("mnras")   # back to the default colours and colormap
_images/459e52f0e69be8448f92d10431651d3a0cb950bb7f94dd2d4260b48eb1ae5eee.png

Many lines and a colour bar. With a dozen or more lines, a colour bar is clearer than a legend. Take the line colours from the same colormap and normalisation that you give the colour bar, so the two match. Here the map is cut with cmap_range, so that no line is too pale to see:

import matplotlib as mpl

redshifts = np.linspace(0, 3, 13)
cmap = pa.cmasher_cmap("ocean", cmap_range=(0.15, 0.85))
norm = mpl.colors.Normalize(redshifts.min(), redshifts.max())

fig, ax = pa.subplots()
for zi in redshifts:
    ax.loglog(k, 1e4 * k / (1 + (k / 0.02) ** 2.2) / (1 + zi) ** 1.5,
              color=cmap(norm(zi)))
fig.colorbar(mpl.cm.ScalarMappable(norm=norm, cmap=cmap), ax=ax, label="$z$")
ax.set_xlabel(r"$k\ [h\,\mathrm{Mpc}^{-1}]$")
ax.set_ylabel(r"$P(k)\ [h^{-3}\,\mathrm{Mpc}^3]$")
plt.show()
_images/996d317bd644ae9204c53be3055ed058c856f67dc8880fb60ce2da16e4aedc5c.png

Points coloured by a third quantity, and angles. For a scatter plot, cut the white end off a sequential map, so that no point fades into the page (left). For an angle or a phase, use a cyclic map and set the limits to one full period, so that -180° and +180° get the same colour (right):

gen = np.random.default_rng(7)             # mock galaxies
logm = gen.uniform(9, 11.5, 600)
metal = 0.3 * (logm - 10.2) + gen.normal(0, 0.12, logm.size)
sfr = 0.8 * (logm - 10) + 0.4 * metal + gen.normal(0, 0.25, logm.size)

yy, xx = np.mgrid[-3:3:200j, -3:3:200j]   # the phase of a dipole-like field
zz = xx + 1j * yy
phase = np.degrees(np.angle((zz - (1 + 0.5j)) / (zz + (1 + 0.5j))))

fig, (ax1, ax2) = pa.subplots(1, 2, width="full", aspect=0.8)
sc = ax1.scatter(logm, sfr, c=metal, s=4, linewidths=0,
                 cmap=pa.cmasher_cmap("rainforest", cmap_range=(0, 0.85)))
fig.colorbar(sc, ax=ax1, label=r"$[\mathrm{Fe/H}]$")
ax1.set_xlabel(r"$\log(M_\star/\mathrm{M_\odot})$")
ax1.set_ylabel(r"$\log(\mathrm{SFR}/\mathrm{M_\odot\,yr^{-1}})$")

im = ax2.imshow(phase, origin="lower", extent=[-3, 3, -3, 3],
                cmap=pa.cmasher_cmap("infinity"), vmin=-180, vmax=180)
fig.colorbar(im, ax=ax2, label="phase [deg]", ticks=[-180, -90, 0, 90, 180])
ax2.set(xticks=[], yticks=[], aspect="equal")
ax2.grid(False)
plt.show()
_images/9cb25b688a433221d47b3fb55d6f4804cee905353bec6f85f6d5b330434960aa.png

One choice for a whole paper. Set the colours and the colormap once, when you activate the style, and every figure after that uses them: pa.set_style("mnras", palette="cmr.rainforest", cmap="cmr.ocean").

Co-authors without CMasher. Discrete colours are plain hex strings. To let a script run without CMasher installed, print the colours once and paste the list in its place, as palette=[...]. Colormaps can’t be pasted in like this, so code that uses a CMasher colormap still needs CMasher:

print(pa.cmasher_colors("rainforest"))   # paste this list as palette=[...]
['#33034a', '#3a2090', '#10528a', '#05747c', '#1c926a', '#5cad3c', '#b5b815', '#edc87d']

Common errors.

  • ImportError: This feature needs the optional CMasher package: run pip install cmasher in the environment you’re using.

  • ValueError: 'cmr.ocean' is not a valid value for cmap, from matplotlib: CMasher hasn’t been imported yet in this session. Any plotastro CMasher call imports it, or add import cmasher at the top. Passing cmap=pa.cmasher_cmap("ocean") instead of the name also works.

  • ValueError: Unknown palette 'rainforest': set_style(palette=...) needs the cmr. prefix, palette="cmr.rainforest".

If you use CMasher in a paper, please cite it; cmasher.get_bibtex() prints the reference.

4. Checking colour-blind accessibility

Don’t take the palette’s word for it — check. pa.check_colors() shows any palette under simulated deuteranopia, protanopia and greyscale (Machado et al. 2009 model, the same one behind most online simulators):

fig = pa.check_colors()          # the default cycle
plt.show()
_images/1d8490eb67e9f384d3ba49ee3770a74ef7385c49bf25219224acd64003a870d8.png

Even better, pa.check_figure(fig) simulates a whole rendered figure — the perfect final check before submitting:

fig, ax = pa.subplots()
x = np.linspace(0, 2 * np.pi, 200)
for i in range(4):
    ax.plot(x, np.sin(x + i / 2), label=f"C{i}")
ax.legend(ncols=2)
ax.set_xlabel("$x$")

check = pa.check_figure(fig)
plt.show()
_images/1d636def67d33a2613a179999cff68874386a074541dfcf8b9f90777e74e8086.png _images/6f3d6a29cdf3cdddb97df3df3b84b57db0f64bc7bd6de738ebab3ba50c42e138.png

If two lines merge in any panel, add markers or dash patterns (next section) or pick colours further apart in the cycle.

5. Markers and line styles

pa.MARKERS is a sequence chosen to stay distinguishable at small (4 pt) sizes, and pa.LINESTYLES provides named dash patterns beyond matplotlib’s four built-ins:

fig = pa.show_markers()
plt.show()
fig = pa.show_linestyles()
plt.show()
_images/6c1c5b6cd6960fb7d8f1216ac1f43794c64024336673ffa38acbfc5a59b4dc58.png _images/0cfbb2641677711eb213bf689be301f82bc84533e9e6ad6f6d7e5a70b78fbf92.png

Cycling colours, markers and line styles together

pa.style_cycler() builds a property cycle that advances colour, marker and/or dash pattern in step — redundant encoding, so every series is unique in two or three channels at once and survives greyscale printing. Use markevery to avoid a solid wall of markers on dense data:

x = np.linspace(0, 3, 60)

fig, ax = pa.subplots()
ax.set_prop_cycle(pa.style_cycler(markers=True, linestyles=True))
for n in range(4):
    ax.plot(x, x ** (0.5 + 0.4 * n), markevery=7, label=f"model {n + 1}")
ax.set_xlabel("$x$")
ax.set_ylabel("$y$")
ax.legend()
plt.show()
_images/c41652fd25ccd3132319fd1205407e601493916e25013029cf131e0fce05fce0.png

To apply such a cycle to all figures instead of a single axes:

plt.rc("axes", prop_cycle=pa.style_cycler(markers=True))

6. Panel labels

Journals want multi-panel figures labelled (a), (b), (c)… — pa.label_panels() does it in one line, in reading order:

fig, axes = pa.subplots(2, 2, width="full", sharex=True, sharey=True)
x = np.linspace(0, 2 * np.pi, 100)
for i, ax in enumerate(axes.flat):
    ax.plot(x, np.sin((i + 1) * x) / (i + 1), color=f"C{i}")
for ax in axes[1]:
    ax.set_xlabel("$x$")
pa.label_panels(axes)
plt.show()
_images/0ee693a69bf778d0fba21b2e24809b4385e8779a3b878b5f89f3d7a97501ef59.png

Options: loc (“upper left”, “upper right”, “lower left”, “lower right”, or “outside” for the Nature-style bold letter above the corner), fmt ("{}." → “a.”), uppercase=True, explicit labels=[...], and any ax.text keyword:

pa.label_panels(axes, loc="outside", fmt="{}", fontweight="bold")

7. Real-world examples

Data with error bars and a fitted model

x = np.linspace(0.5, 10, 18)
truth = 2.0 * x ** -0.7
y = truth * rng.normal(1, 0.08, x.size)

fig, ax = pa.subplots()
ax.errorbar(x, y, yerr=0.08 * truth, fmt="o", color=pa.COLORS["blue"],
            label="mock data", zorder=3)
xf = np.linspace(0.4, 11, 200)
ax.plot(xf, 2.0 * xf ** -0.7, color=pa.COLORS["red"], label=r"$2\,x^{-0.7}$")
ax.fill_between(xf, 1.8 * xf ** -0.7, 2.2 * xf ** -0.7,
                color=pa.lighten(pa.COLORS["red"], 0.75), zorder=0)
ax.loglog()
ax.set_xlabel(r"$r$ [Mpc]")
ax.set_ylabel(r"$\xi(r)$")
ax.legend()
plt.show()
_images/19d1b308726271434368124b7b165487c59b7ac9ce92206afcfec1bbfe904b49.png

Comparing distributions

Use histtype="step" for overlapping histograms — filled histograms hide each other, stepped ones don’t:

a = rng.normal(0.0, 1.0, 4000)
b = rng.normal(0.8, 1.3, 4000)
bins = np.linspace(-4, 5, 45)

fig, ax = pa.subplots()
ax.hist(a, bins, histtype="step", lw=1.2, label="sample A", density=True)
ax.hist(b, bins, histtype="step", lw=1.2, label="sample B", density=True)
ax.hist(b, bins, color=pa.lighten(pa.COLORS["orange"], 0.75),
        density=True, zorder=0)
ax.set_xlabel(r"$\Delta v\ [100\ \mathrm{km\,s^{-1}}]$")
ax.set_ylabel("probability density")
ax.legend()
plt.show()
_images/927287d4a5f6e1ce3e7bc77cc47e06e2ecf16c3389b8e0067e3419a801f8bc19.png

Images with a colour bar

The default colormap is viridis (perceptually uniform and colour-blind safe). Change it for every image with pa.set_style("mnras", cmap="cividis"), or use a CMasher map (section 3). For images, a square panel usually looks best — pass aspect=1. For astronomical images you typically also want origin="lower":

yy, xx = np.mgrid[-3:3:200j, -3:3:200j]
img = np.exp(-(xx**2 + yy**2) / 2) + 0.35 * np.exp(-((xx - 1.2)**2 + (yy + 0.8)**2) / 0.1)
img += rng.normal(0, 0.01, img.shape)

fig, ax = pa.subplots(aspect=1)
im = ax.imshow(img, extent=[-3, 3, -3, 3], origin="lower")
fig.colorbar(im, ax=ax, label="flux [arbitrary]")
ax.set_xlabel(r"$\Delta\alpha$ [arcsec]")
ax.set_ylabel(r"$\Delta\delta$ [arcsec]")
ax.grid(False)
plt.show()
_images/18e09a995ef11ddc9a8d970dc58f6f75c508fff22b65c113f4f957856d2b6960.png

8. Switching journals

The styles share one visual language — fonts, colours, tick and legend settings — and differ only in figure width (and, for Nature Astronomy, the sans-serif font its guidelines require); the exception is euclid, which matches the Euclid Consortium’s own look (below). Your plots stay consistent across papers; switching is a single call. Note the sans-serif fonts and single-wide- column JCAP layout below:

x = np.linspace(0, 2 * np.pi, 200)

for journal in ["mnras", "aanda", "natastro", "jcap"]:
    pa.set_style(journal)
    fig, ax = pa.subplots()
    ax.plot(x, np.sin(x), label=r"$\sin x$")
    ax.plot(x, np.cos(x), label=r"$\cos x$")
    ax.set_title(f"{journal}  —  {pa.JOURNALS[journal]['name']}", fontsize=8)
    ax.set_xlabel("$x$")
    ax.legend()
    plt.show()

pa.set_style("mnras")   # back to default for the rest of the notebook
_images/60eb95ba868f449ae96c8a5f115efa995ea875c79367e8f2f3a00b8023a7229c.png _images/59f18b3d2791dc8869aaf63933efaf4f2a5ac032e39be6e3d4de16f0453c15ed.png _images/abc2f26f0a7e0685b910d809a25ec1f3d17576aad8d5a815c5d681cb5efca5f1.png _images/788e834972c67a9d2a619264e7f960f54abdb88865ab090bddcfdc48e8477eb0.png

Also available: rasti, apj, oja, prd, the thesis/beamer width presets, and euclid (next). You can override any rcParam per session:

pa.set_style("mnras", grid=False)                    # no grid
pa.set_style("mnras", palette="okabe_ito")           # another colour cycle
pa.set_style("mnras", cmap="cividis")                # another default colormap
pa.set_style("mnras", **{"font.size": 10})           # bigger fonts

Euclid Consortium papers

pa.set_style("euclid") reproduces the look of niceplots, the Euclid Consortium Editorial Board’s matplotlib style for Euclid papers (Euclid-internal, GPL-3.0; set up by Lukas Hergt, with tweaks by Laila Linke). The style and its colour schemes are adapted from that repository: the settings are re-expressed in plotastro’s own template, and nothing is copied from it.

Unlike the other styles, it uses sans-serif 10 pt text with Computer Modern maths, no grid or minor ticks, framed legends, and Petroff’s 8 colours (pa.PETROFF8). It also follows niceplots’ sizing convention: figures are drawn 4 × 3 in and LaTeX scales them into the A&A column, so include them with \includegraphics[width=\columnwidth]{fig.pdf}.

niceplots’ other colour schemes are available under their niceplots names, as a palette (pa.set_style("euclid", palette="categorical3")) or as a list from pa.euclid_colors(): "categorical1" (the default), "categorical2" (Okabe & Ito), "categorical3" (black, then Tol’s vibrant), and n colours from "sequential" (copper) or "diverging" (coolwarm).

pa.set_style("euclid")

x = np.linspace(0, 2 * np.pi, 200)
fig, ax = pa.subplots()                  # 4 x 3 in, as niceplots draws it
for i in range(4):
    ax.plot(x, np.sin(x + i / 2), label=f"C{i}")
ax.set_xlabel("$x$")
ax.set_ylabel("$y$")
ax.legend()
plt.show()

print(pa.euclid_colors("sequential", n=4))
pa.set_style("mnras")   # back to default for the rest of the notebook
_images/d4fb93fa9c95275e64d19a469c1a67b0887c785e16c495573092ec4469617ffa.png
['#000000', '#4f3220', '#9e6440', '#ed9660']

9. LaTeX text rendering

By default the styles use matplotlib’s built-in mathtext with STIX fonts — Times-compatible maths that works everywhere, with no LaTeX installation required.

If you have LaTeX installed and want pixel-perfect consistency with your manuscript (custom macros, real kerning), turn on full LaTeX rendering:

pa.set_style("mnras", usetex=True)

This uses the newtx font packages (the modern Times fonts — MNRAS and A&A are typeset in Times); for Nature Astronomy it switches to Helvetica instead. It needs latex, dvipng and ghostscript on your PATH, and rendering is noticeably slower — a good workflow is to develop with usetex=False and flip it on for the final version.

10. Saving figures for submission

The styles already save with sensible settings: PDF (vector) by default, 450 dpi for rasterised elements (journals ask for ≥ 300–400 dpi), tight bounding box, and TrueType fonts embedded (pdf.fonttype: 42 — avoids the Type-3 font errors journal submission systems complain about).

pa.savefig writes several formats in one call — e.g. a PDF for the manuscript and a PNG to paste into slides or collaboration chats:

import tempfile, os
outbase = os.path.join(tempfile.mkdtemp(), "figure1")

fig, ax = pa.subplots()
ax.plot(np.linspace(0, 1, 50), np.linspace(0, 1, 50) ** 2)
ax.set_xlabel("$x$")
ax.set_ylabel("$x^2$")

pa.savefig(outbase, fig=fig, formats=("pdf", "png"))
['/var/folders/49/gftfyhj56zs1lz_zs90jcw000000gn/T/tmp8_gemow7/figure1.pdf',
 '/var/folders/49/gftfyhj56zs1lz_zs90jcw000000gn/T/tmp8_gemow7/figure1.png']
_images/e0a870ceb8c97dc507127553dd3820b256bfb612e36b2c3246e8445193b483ef.png

Per-journal notes

journal

accepted figure formats

notes

MNRAS / RASTI

EPS preferred, PDF/TIFF fine

≥ 400 dpi raster, ~8 pt lettering, colour-blind friendly required

A&A

PDF/EPS

figures 88 mm (column) or 170–180 mm (page) wide

ApJ / AAS

PDF/EPS/PNG

vector strongly preferred

OJA

PDF (arXiv-ready)

whatever compiles on arXiv works

PRD / JCAP

PDF/EPS

vector preferred

Nature Astronomy

PDF/EPS/AI

sans-serif fonts, 5–7 pt lettering

If a journal insists on EPS, note EPS has no transparency — replace alpha= with pa.lighten() shades (another reason they are the better habit).

11. Author lists from a CSV

Assembling the author/affiliation block by hand is error-prone on long collaborations. Feed plotastro the author CSV your collaboration already maintains — it recognises Authorname (or name, or Firstname + Lastname), Affiliation (several separated by ;, or one row per affiliation — repeated author rows are merged) and optional ORCID/Email columns, ignores everything else (JoinedAsBuilder, …), strips stray spaces, and passes through LaTeX accents like \'{e} untouched. Affiliations are numbered in order of first appearance and shared between authors automatically; the first author with an email becomes the corresponding author. Here’s a realistic list:

print(open("authors_example.csv").read())
Lastname,Firstname,Authorname,Email,JoinedAsBuilder,Affiliation,ORCID,
Bandi,Behnood,Behnood Bandi, b.bandi@sussex.ac.uk, False,"Astronomy Centre, University of Sussex, Falmer, Brighton BN1 9QH, UK",0000-0001-5838-3903,
Rocher,Antoine,Antoine Rocher,antoine.rocher@epfl.ch,False,"EPFL, \'{E}cole polytechnique f\'{e}d\'{e}rale de Lausanne, Chemin des Maillettes, 51, 1290 Versoix, Switzerland",0000-0003-4349-6424,
Verdier,Aur\'{e}lien,Aur\'{e}lien Verdier,aurelien.verdier@epfl.ch,False,"EPFL, \'{E}cole polytechnique f\'{e}d\'{e}rale de Lausanne, Chemin des Maillettes, 51, 1290 Versoix, Switzerland",,
Richard,Johan,Johan Richard,johan.richard@univ-lyon1.fr,False,"CRAL, Centre de Recherche Astrophysique de Lyon, Universit\'{e} de Lyon, 9 avenue Charles Andr\'{e}, 69230 Saint-Genis-Laval, France",0000-0001-5492-1049,
Loveday,Jon ,Jon Loveday, j.loveday@sussex.ac.uk, False,"Astronomy Centre, University of Sussex, Falmer, Brighton BN1 9QH, UK",0000-0001-5290-8940,
Brown,Michael,Michael Brown,michael.brown@monash.edu,False,"Monash, School of Physics and Astronomy, Monash University, Wellington Road, Clayton, VIC 3800, Australia",0000-0002-1207-9137,
print(pa.authorlist("authors_example.csv", journal="mnras"))
% Author list generated by plotastro (mnras format)
% Check addresses/footnotes against the journal template.
\author[B. Bandi et al.]{
Behnood Bandi,$^{1}$\thanks{E-mail: b.bandi@sussex.ac.uk}
Antoine Rocher,$^{2}$
Aur\'{e}lien Verdier,$^{2}$
Johan Richard,$^{3}$
Jon Loveday$^{1}$
and Michael Brown$^{4}$
\\
% List of institutions
$^{1}$Astronomy Centre, University of Sussex, Falmer, Brighton BN1 9QH, UK\\
$^{2}$EPFL, \'{E}cole polytechnique f\'{e}d\'{e}rale de Lausanne, Chemin des Maillettes, 51, 1290 Versoix, Switzerland\\
$^{3}$CRAL, Centre de Recherche Astrophysique de Lyon, Universit\'{e} de Lyon, 9 avenue Charles Andr\'{e}, 69230 Saint-Genis-Laval, France\\
$^{4}$Monash, School of Physics and Astronomy, Monash University, Wellington Road, Clayton, VIC 3800, Australia
}
print(pa.authorlist("authors_example.csv", journal="apj"))
% Author list generated by plotastro (apj format)
% Check addresses/footnotes against the journal template.
\correspondingauthor{Behnood Bandi}
\email{b.bandi@sussex.ac.uk}

\author[0000-0001-5838-3903]{Behnood Bandi}
\affiliation{Astronomy Centre, University of Sussex, Falmer, Brighton BN1 9QH, UK}

\author[0000-0003-4349-6424]{Antoine Rocher}
\affiliation{EPFL, \'{E}cole polytechnique f\'{e}d\'{e}rale de Lausanne, Chemin des Maillettes, 51, 1290 Versoix, Switzerland}

\author{Aur\'{e}lien Verdier}
\affiliation{EPFL, \'{E}cole polytechnique f\'{e}d\'{e}rale de Lausanne, Chemin des Maillettes, 51, 1290 Versoix, Switzerland}

\author[0000-0001-5492-1049]{Johan Richard}
\affiliation{CRAL, Centre de Recherche Astrophysique de Lyon, Universit\'{e} de Lyon, 9 avenue Charles Andr\'{e}, 69230 Saint-Genis-Laval, France}

\author[0000-0001-5290-8940]{Jon Loveday}
\affiliation{Astronomy Centre, University of Sussex, Falmer, Brighton BN1 9QH, UK}

\author[0000-0002-1207-9137]{Michael Brown}
\affiliation{Monash, School of Physics and Astronomy, Monash University, Wellington Road, Clayton, VIC 3800, Australia}

The same CSV works for every journal the package knows — mnras/rasti, aanda, apj/oja (AASTeX), prd (REVTeX), jcap, or generic for a plain numbered block. There is also a command-line tool, so co-authors who don’t use Python can run it too:

plotastro-authors authors.csv --journal aanda
plotastro-authors authors.csv -j apj -o authors.tex

That’s it — happy plotting! See README.md for the full reference.