"""Journal definitions, style activation and figure sizing."""
from __future__ import annotations
from pathlib import Path
import matplotlib as mpl
import matplotlib.pyplot as plt
from cycler import cycler
STYLE_DIR = Path(__file__).resolve().parent / "styles"
#: Golden ratio (height/width) used for default figure heights.
GOLDEN = (5 ** 0.5 - 1) / 2 # 0.618...
_SERIF_TEX = r"\usepackage{newtxtext}\usepackage{newtxmath}"
_SANS_TEX = (r"\usepackage{helvet}\usepackage{sansmath}\sansmath"
r"\renewcommand{\familydefault}{\sfdefault}")
#: Text-block widths in LaTeX points (1 pt = 1/72.27 inch) per journal,
#: the style sheet each journal uses, and its LaTeX font preamble.
JOURNALS = {
"mnras": {"column": 240.0, "full": 504.0, "style": "mnras",
"tex": _SERIF_TEX,
"name": "Monthly Notices of the RAS"},
"rasti": {"column": 240.0, "full": 504.0, "style": "rasti",
"tex": _SERIF_TEX,
"name": "RAS Techniques and Instruments"},
"aanda": {"column": 250.38, "full": 512.15, "style": "aanda",
"tex": _SERIF_TEX,
"name": "Astronomy & Astrophysics"},
"apj": {"column": 242.26653, "full": 513.11743, "style": "apj",
"tex": _SERIF_TEX,
"name": "The Astrophysical Journal (AASTeX)"},
"oja": {"column": 245.26653, "full": 508.0, "style": "oja",
"tex": _SERIF_TEX,
"name": "The Open Journal of Astrophysics"},
"prd": {"column": 246.0, "full": 510.0, "style": "prd",
"tex": _SERIF_TEX,
"name": "Physical Review D (REVTeX 4.2)"},
"jcap": {"column": 455.24, "full": 455.24, "style": "jcap",
"tex": _SERIF_TEX,
"name": "J. of Cosmology and Astroparticle Physics"},
"natastro": {"column": 253.23, "full": 520.68, "style": "natastro",
"tex": _SANS_TEX,
"name": "Nature Astronomy"},
# Euclid Consortium papers (A&A's aaEC class) in the look of the ECEB's
# niceplots, whose convention is to draw figures 4.0 in wide (8.0 in
# for two columns) at 10 pt and let LaTeX scale them into the column.
# No LaTeX preamble: matplotlib's own \sffamily gives Computer Modern
# Sans, as in niceplots.
"euclid": {"column": 289.08, "full": 578.16, "style": "euclid",
"tex": "", "aspect": 0.75,
"name": "Euclid Consortium (A&A, niceplots look)"},
# Not journals, but handy width presets (they use the MNRAS look):
"thesis": {"column": 426.79135, "full": 426.79135, "style": "mnras",
"tex": _SERIF_TEX,
"name": "A4 thesis text width"},
"beamer": {"column": 307.28987, "full": 307.28987, "style": "mnras",
"tex": _SERIF_TEX,
"name": "Beamer slide text width"},
}
_ALIASES = {
"a&a": "aanda", "aa": "aanda", "astronomy&astrophysics": "aanda",
"apjl": "apj", "aj": "apj", "aas": "apj", "aastex": "apj",
"openjournal": "oja", "theoj": "oja", "openjournalofastrophysics": "oja",
"prl": "prd", "aps": "prd", "revtex": "prd",
"nature": "natastro", "natureastronomy": "natastro", "natastron": "natastro",
"ec": "euclid", "euclidconsortium": "euclid", "niceplots": "euclid",
"mnras_full": "mnras", # legacy name from the old set_size() API
}
_state = {"journal": "mnras"}
def _resolve(journal):
key = str(journal).lower().replace(" ", "").replace("-", "")
key = _ALIASES.get(key, key)
if key not in JOURNALS:
options = ", ".join(sorted(JOURNALS))
raise ValueError(f"Unknown journal {journal!r}. Choose one of: {options}")
return key
[docs]
def current_journal():
"""Name of the journal activated by the last :func:`set_style` call.
Returns
-------
str : a key of :data:`JOURNALS`, e.g. ``"mnras"`` (also the value
before any :func:`set_style` call). Aliases are resolved, so after
``set_style("a&a")`` this is ``"aanda"``.
"""
return _state["journal"]
# ----------------------------------------------------------------------
# Style activation
# ----------------------------------------------------------------------
[docs]
def set_style(journal="mnras", *, usetex=False, grid=None, palette=None,
cmap=None, **rc_overrides):
"""Activate the plotting style for a journal.
Parameters
----------
journal : str
One of ``"mnras"``, ``"rasti"``, ``"aanda"`` (aliases ``"a&a"``,
``"aa"``), ``"apj"`` (aliases ``"apjl"``, ``"aastex"``), ``"oja"``,
``"prd"`` (aliases ``"prl"``, ``"revtex"``), ``"jcap"``,
``"natastro"`` (alias ``"nature"``), ``"euclid"`` (alias ``"ec"``;
Euclid Consortium papers in the look of the ECEB's niceplots),
``"thesis"`` or ``"beamer"``.
usetex : bool, optional
If True, render all text with a real LaTeX installation using
fonts matching the journal (newtx Times for the serif journals,
Helvetica for Nature Astronomy, Computer Modern Sans for Euclid).
Default False (portable mathtext).
grid : bool, optional
Override the style's grid setting (the styles default to a
subtle grid, except ``"euclid"``; pass ``grid=False`` for a clean
journal look).
palette : str or sequence of colours, optional
Replace the colour cycle. A name — ``"default"``, ``"okabe_ito"``,
``"petroff8"``, ``"petroff10"``, ``"tol_vibrant"``, or one of the
Euclid niceplots schemes ``"categorical1"``, ``"categorical2"``,
``"categorical3"``, ``"sequential"``, ``"diverging"`` (see
:func:`euclid_colors`); ``"cmr.<name>"`` for 8 colours sampled
from a CMasher colormap (see :func:`cmasher_colors`) — or any
list or dict of colours.
cmap : str, optional
Default colormap for ``imshow``, ``pcolormesh``, ``scatter`` etc.
(the styles use ``viridis``): any matplotlib colormap name, or
``"cmr.<name>"`` for a CMasher colormap (see :func:`cmasher_cmap`).
CMasher is optional: it is only imported when a ``"cmr."`` name
is used here or in ``palette``.
**rc_overrides
Any extra rcParams, e.g. ``set_style("mnras", **{"font.size": 10})``.
Examples
--------
>>> pa.set_style("aanda")
>>> pa.set_style("mnras", usetex=True, grid=False)
>>> pa.set_style("euclid", palette="categorical3")
>>> pa.set_style("mnras", palette="cmr.rainforest", cmap="cmr.ocean")
"""
key = _resolve(journal)
# Resolve the colours before touching rcParams, so a bad name (or a
# missing CMasher) leaves the current style intact. (_colors imports
# figsize from here, hence the local import.)
from ._colors import _resolve_cmap, _resolve_palette
colors = _resolve_palette(palette) if palette is not None else None
cmap = _resolve_cmap(cmap) if cmap is not None else None
style_file = STYLE_DIR / f"{JOURNALS[key]['style']}.mplstyle"
plt.style.use(style_file)
_state["journal"] = key
# Presets sharing a style file (thesis, beamer) still get the right
# default figure size:
mpl.rcParams["figure.figsize"] = figsize("column", journal=key)
if usetex:
mpl.rcParams.update({
"text.usetex": True,
"text.latex.preamble": JOURNALS[key]["tex"],
})
if grid is not None:
mpl.rcParams["axes.grid"] = bool(grid)
if colors is not None:
mpl.rcParams["axes.prop_cycle"] = cycler(color=colors)
if cmap is not None:
mpl.rcParams["image.cmap"] = cmap
if rc_overrides:
mpl.rcParams.update(rc_overrides)
#: Alias for :func:`set_style`, for those who prefer ``pa.use("mnras")`` —
#: after which everything is plain matplotlib.
use = set_style
# ----------------------------------------------------------------------
# Figure sizing
# ----------------------------------------------------------------------
[docs]
def figsize(width="column", *, journal=None, fraction=1.0, nrows=1, ncols=1,
aspect=None, height=None):
"""Figure dimensions (inches) that match the journal's text layout,
so the figure is never rescaled (and its fonts shrunk) by LaTeX.
Parameters
----------
width : {"column", "full"} or float
``"column"`` for a one-column figure, ``"full"`` for the full
text width, or a number = a custom width in LaTeX points
(get yours with ``\\the\\columnwidth`` in your .tex file).
journal : str, optional
Journal to size for; defaults to the one from the last
:func:`set_style` call.
fraction : float, optional
Fraction of that width to occupy (e.g. 0.5 for half a column).
nrows, ncols : int, optional
Subplot grid shape; the height scales so each panel keeps the
requested aspect ratio.
aspect : float, optional
Height/width ratio of one panel. Default: the journal's own — the
golden ratio (0.618) everywhere except ``"euclid"``, which follows
niceplots' 4:3. Use ``aspect=1`` for square panels.
height : float, optional
Explicit figure height in inches (overrides ``aspect``).
Returns
-------
(width_in, height_in) : tuple of float
"""
key = _resolve(journal) if journal is not None else _state["journal"]
if aspect is None:
aspect = JOURNALS[key].get("aspect", GOLDEN)
if isinstance(width, str):
w = width.lower()
if w in ("column", "col", "onecolumn", "one", "single"):
width_pt = JOURNALS[key]["column"]
elif w in ("full", "fullwidth", "two", "twocolumn", "page", "text"):
width_pt = JOURNALS[key]["full"]
else:
raise ValueError(
f"width must be 'column', 'full' or a number of points, got {width!r}")
else:
width_pt = float(width)
fig_width_in = width_pt * fraction / 72.27
if height is not None:
fig_height_in = float(height)
else:
fig_height_in = fig_width_in * aspect * (nrows / ncols)
return (fig_width_in, fig_height_in)
[docs]
def subplots(nrows=1, ncols=1, *, width="column", journal=None, fraction=1.0,
aspect=None, height=None, **kwargs):
"""`plt.subplots` with the figure size computed by :func:`figsize`.
Parameters
----------
nrows, ncols : int, optional
Subplot grid shape.
width, journal, fraction, aspect, height
Passed to :func:`figsize`, together with ``nrows`` and ``ncols``,
so each panel keeps the requested aspect ratio.
**kwargs
Forwarded to ``plt.subplots`` (e.g. ``sharex=True``). Passing
``figsize=`` yourself overrides the computed size.
Returns
-------
fig : Figure
ax : Axes or array of Axes
As returned by ``plt.subplots``.
Examples
--------
>>> fig, ax = pa.subplots() # one-column figure
>>> fig, axes = pa.subplots(2, 2, width="full") # full-width 2x2 grid
>>> fig, ax = pa.subplots(aspect=1) # square panel
"""
if "figsize" not in kwargs:
kwargs["figsize"] = figsize(width, journal=journal, fraction=fraction,
nrows=nrows, ncols=ncols, aspect=aspect,
height=height)
return plt.subplots(nrows, ncols, **kwargs)
[docs]
def savefig(name, fig=None, formats=("pdf",), **kwargs):
"""Save a figure under one or more formats at once.
Parameters
----------
name : str or Path
Output path without extension (a known extension is stripped).
fig : Figure, optional
Defaults to the current figure.
formats : sequence of str, optional
e.g. ``("pdf", "png")`` to get both a vector file for the paper
and a raster preview.
**kwargs
Forwarded to ``fig.savefig`` (e.g. ``dpi=600``).
Returns
-------
list of str : the files written.
"""
fig = fig if fig is not None else plt.gcf()
base = Path(name)
if base.suffix.lower() in (".pdf", ".png", ".eps", ".svg", ".jpg", ".tiff"):
formats = (base.suffix[1:],)
base = base.with_suffix("")
written = []
for ext in formats:
out = f"{base}.{ext}"
fig.savefig(out, **kwargs)
written.append(out)
return written
# ----------------------------------------------------------------------
# Backwards compatibility with the original myfigsize.set_size()
# ----------------------------------------------------------------------
[docs]
def set_size(width="mnras", fraction=1, subplots=(1, 1), hight_ratio=1):
"""Deprecated — use :func:`figsize` instead. Kept so old scripts run.
``set_size('mnras')`` == ``figsize('column', journal='mnras')`` and
``set_size('mnras_full')`` == ``figsize('full', journal='mnras')``.
Parameters
----------
width : str or float
A journal key (one-column width), ``"mnras_full"`` for the full
MNRAS text width, or a width in LaTeX points.
fraction : float
Fraction of that width to occupy.
subplots : (int, int)
Subplot grid shape, (rows, columns).
hight_ratio : float
Multiplies the golden-ratio height (spelled as in the original
API).
Returns
-------
(width_in, height_in) : tuple of float
"""
if width == "mnras_full":
return figsize("full", journal="mnras", fraction=fraction,
nrows=subplots[0], ncols=subplots[1],
aspect=GOLDEN * hight_ratio)
if isinstance(width, str):
return figsize("column", journal=width, fraction=fraction,
nrows=subplots[0], ncols=subplots[1],
aspect=GOLDEN * hight_ratio)
return figsize(width, fraction=fraction,
nrows=subplots[0], ncols=subplots[1],
aspect=GOLDEN * hight_ratio)