r"""The figure conventions every plot in this package follows.
A journal page is 7 inches wide and its columns are 3.5. A figure that is not
one of those two widths gets rescaled by the typesetter, and rescaling a figure
rescales its text with it: 7-point labels drawn at 4.7 inches arrive at the
reader as 5.2-point labels. So this module offers two widths and nothing else,
as a small table of named presets, and every figure in the package is built from
one of them.
Appearance and geometry are kept apart
--------------------------------------
:data:`PLOT_RC_PARAMS` describes only how a figure *looks* -- text sizes, the
grid, the colour cycle, how fonts are embedded. It deliberately says nothing
about margins. An earlier version of this style fixed ``figure.subplot.left``
and its three siblings to values tuned for one 3.5x3.5-inch corner plot; every
figure of a different shape then had to abandon the entire style to get its
margins right, which is exactly what the orbital-decay plot did.
Margins are instead handled per figure, by passing ``layout="constrained"`` when
the figure is built. Constrained layout shrinks the *axes* until the labels fit
and leaves the *figure* the size it was asked for, which is the property that
makes a 3.5-inch figure still 3.5 inches wide when it lands in the paper. For
the same reason ``savefig.bbox`` is pinned to ``"standard"``: ``"tight"`` crops
the canvas down to its ink, and a figure cropped to its ink is no longer any
particular width.
Corner plots are the exception. :func:`corner.corner` ends by calling
``subplots_adjust`` on whatever figure it is handed, which constrained layout
refuses, and :mod:`ell1fit.orbit_plot` places its panels in absolute inches on
purpose. Those figures keep their manual layout -- which is why the layout
engine is chosen per figure rather than set here.
Reading a figure
----------------
The drawing conventions, which the plotting code follows and which a new figure
should follow too:
* measurements are black points with error bars (:data:`DATA_COLOR`);
* a model is a colour from the cycle -- solid for the one being adopted, dashed
for the alternative it is being compared against;
* a model's uncertainty is the same colour as a ``fill_between``, at
:data:`BAND_ALPHA` for one sigma and :data:`WIDE_BAND_ALPHA` for two;
* guides, zero lines and anything else that is not data are grey and dotted
(:data:`GUIDE_COLOR`);
* the grid is light, dotted, and drawn *behind* the data. On an axis holding an
image -- a phaseogram, say -- it is turned off instead, by
:func:`image_axes`: behind a ``pcolormesh`` it cannot be seen, and in front of
one it is clutter;
* a figure destined for a paper carries no title. A diagnostic may.
"""
import logging
import os
import matplotlib as mpl
__all__ = [
"BAND_ALPHA",
"COLUMN_WIDTH",
"CORNER_LABEL_SIZE",
"CORNER_SCATTER_NDIM",
"DATA_COLOR",
"DEFAULT_FIGURE_FORMAT",
"FIGURE_FORMATS",
"FIGURE_SIZES",
"GUIDE_COLOR",
"LARGE_FIGURE_FORMAT",
"MAX_CANVAS_PX",
"MIN_RASTER_DPI",
"PLOT_RC_PARAMS",
"RASTER_DPI",
"SUMMARY_TITLE_SIZE",
"TEXT_WIDTH",
"WIDE_BAND_ALPHA",
"add_figure_format_argument",
"corner_plot_kwargs",
"current_figure_format",
"figure_path",
"figure_size",
"fitted_raster_dpi",
"image_axes",
"plot_style_context",
"save_figure",
"set_figure_format",
]
#: Width of one journal column, in inches.
COLUMN_WIDTH = 3.5
#: Width of the full text block, in inches.
TEXT_WIDTH = 7.0
#: The figure shapes this package draws, by name. Every width here is either
#: :data:`COLUMN_WIDTH` or :data:`TEXT_WIDTH`; only the height varies, and it
#: varies over a handful of aspect ratios rather than continuously, so that two
#: figures side by side in a paper look like a pair.
FIGURE_SIZES = {
#: The default: one panel, 4:3, filling a column.
"column": (COLUMN_WIDTH, 2.65),
#: A histogram or anything else whose natural aspect is square.
"column-square": (COLUMN_WIDTH, COLUMN_WIDTH),
#: A data panel over a residual panel, still inside one column.
"column-tall": (COLUMN_WIDTH, 4.2),
#: Landscape across the text width, for a figure with many details.
"wide": (TEXT_WIDTH, COLUMN_WIDTH),
#: Two stacked panels across the text width.
"wide-tall": (TEXT_WIDTH, 5.25),
#: Square across the text width: corner plots, phaseogram grids.
"page": (TEXT_WIDTH, TEXT_WIDTH),
}
#: Base text size, in points. Everything else is quoted relative to it.
BASE_FONT_SIZE = 7.0
#: Corner-plot axis labels carry a subtracted centre as well as a parameter
#: name, so they run long and are set smaller than the rest.
CORNER_LABEL_SIZE = BASE_FONT_SIZE - 1.5
#: The two-line numerical summary printed above a posterior panel.
SUMMARY_TITLE_SIZE = BASE_FONT_SIZE - 2.0
#: Resolution used whenever a figure is written in a raster format.
RASTER_DPI = 300
#: What a figure too large to be written as vector falls back to. JPEG rather
#: than PNG because these are diagnostics read by zooming in, where a file half
#: the size matters more than the artifacts described under "Output format" in
#: ``docs/ell1fit/figures.rst``.
LARGE_FIGURE_FORMAT = "jpg"
#: Longest side, in pixels, a raster canvas is allowed to reach before the
#: resolution is reduced to fit. It is what bounds the cost of a figure whose
#: size nobody chose; at :data:`RASTER_DPI` it leaves every ordinary figure --
#: and every corner plot up to about twelve parameters -- untouched.
MAX_CANVAS_PX = 8000
#: The resolution :func:`fitted_raster_dpi` will not go below, whatever
#: :data:`MAX_CANVAS_PX` would ask for. The smallest text in these figures is
#: 7 pt, which at 150 dpi renders about 15 pixels tall -- the size of ordinary
#: screen text, and checked by eye to be clearly legible. Below that the axis
#: labels start to go, and an illegible diagnostic is not worth the memory it
#: saves.
MIN_RASTER_DPI = 150
#: Number of parameters above which a corner plot stops drawing the individual
#: samples behind its contours.
#:
#: This is the whole of the size problem. A corner plot grows by about 2.1
#: inches per parameter in each direction, and :func:`corner.corner` marks the
#: scatter of individual samples in every one of its ``n(n-1)/2`` panels as
#: rasterized. A vector back-end honours that by allocating one *whole-canvas*
#: pixel buffer per panel, so the cost grows as the square of the parameter
#: count: measured peak memory for a twelve-parameter fit was 6.7 GB, which is
#: what a cluster run died of. Without the scatter the same figure has no
#: rasterized element at all, needs no canvas, and costs 0.45 GB.
#:
#: Eight is where a corner plot also stops being a figure anyone would place in
#: a paper -- it is 18 inches on a side -- and where the point cloud stops
#: being readable anyway: each panel is by then small enough that the samples
#: are a grey smudge under the contours that already describe them.
CORNER_SCATTER_NDIM = 8
#: Measurements.
DATA_COLOR = "black"
#: Zero lines, reference levels, and anything else that is not data.
GUIDE_COLOR = "0.5"
#: Alpha for a one-sigma band, and for the two-sigma band drawn under it.
BAND_ALPHA = 0.30
WIDE_BAND_ALPHA = 0.15
#: Okabe-Ito, minus its yellow, which is unreadable on white. Chosen over
#: matplotlib's ``tab10`` because it stays distinguishable both for a reader
#: with a red/green deficiency and in greyscale print. Pure black is left out
#: so that ``C0`` and friends never collide with :data:`DATA_COLOR`.
COLOR_CYCLE = (
"#0072B2", # blue
"#D55E00", # vermillion
"#009E73", # bluish green
"#CC79A7", # reddish purple
"#E69F00", # orange
"#56B4E9", # sky blue
"#8C8C8C", # grey
)
PLOT_RC_PARAMS = {
# Text. One size for everything the reader has to read off an axis; the
# legend a shade smaller, since it is read once rather than scanned.
"font.size": BASE_FONT_SIZE,
"font.family": "sans-serif",
"axes.labelsize": BASE_FONT_SIZE,
"axes.titlesize": BASE_FONT_SIZE,
"xtick.labelsize": BASE_FONT_SIZE,
"ytick.labelsize": BASE_FONT_SIZE,
"legend.fontsize": BASE_FONT_SIZE - 0.5,
"legend.title_fontsize": BASE_FONT_SIZE - 0.5,
"figure.titlesize": BASE_FONT_SIZE + 1,
# Set explicitly so that $\Delta$TASC and $\sigma$ are drawn in the same
# family as the words around them rather than in matplotlib's own default.
"mathtext.fontset": "dejavusans",
# Ticks: short, inward, on all four spines.
"xtick.direction": "in",
"ytick.direction": "in",
"xtick.top": True,
"ytick.right": True,
"xtick.major.size": 3.0,
"ytick.major.size": 3.0,
"xtick.major.width": 0.6,
"ytick.major.width": 0.6,
"xtick.minor.size": 1.5,
"ytick.minor.size": 1.5,
"xtick.minor.width": 0.5,
"ytick.minor.width": 0.5,
"xtick.minor.visible": True,
"ytick.minor.visible": True,
# Grid: on the major ticks only -- a grid on the minor ticks as well, now
# that they are drawn, is a mesh -- and underneath the data, so that a line
# of it never crosses a marker or an error bar.
"axes.grid": True,
"axes.grid.axis": "both",
"axes.grid.which": "major",
"axes.axisbelow": True,
"grid.color": "grey",
"grid.linewidth": 0.3,
"grid.linestyle": ":",
# Marks.
"axes.linewidth": 0.6,
"axes.prop_cycle": mpl.cycler(color=list(COLOR_CYCLE)),
"lines.linewidth": 1.0,
"lines.markersize": 3.0,
"errorbar.capsize": 1.5,
"legend.frameon": False,
"legend.handlelength": 1.6,
"legend.borderpad": 0.3,
"legend.labelspacing": 0.3,
# Output. ``savefig.bbox`` is pinned rather than left to the user's own
# matplotlibrc: "tight" would crop the canvas to its ink and the figure
# would no longer be a column wide. Type 42 embeds fonts as TrueType;
# several journals reject the Type 3 that matplotlib emits by default.
"figure.figsize": FIGURE_SIZES["column"],
"figure.dpi": RASTER_DPI,
"savefig.dpi": RASTER_DPI,
"savefig.bbox": "standard",
"pdf.fonttype": 42,
"ps.fonttype": 42,
}
#: Formats a figure may be written in.
FIGURE_FORMATS = ("pdf", "png", "svg", "eps", "jpg")
#: What is used when nothing says otherwise. Vector, so that text stays sharp
#: at whatever size the journal ends up placing the figure.
DEFAULT_FIGURE_FORMAT = "pdf"
#: Environment variable consulted when no format has been set explicitly.
FIGURE_FORMAT_ENV_VAR = "ELL1FIT_FIGURE_FORMAT"
#: Extensions recognised as "the caller already said what they wanted".
#: Deliberately wider than :data:`FIGURE_FORMATS`: we refuse to *choose* a
#: format we do not endorse, but we honour one that is asked for by name.
_KNOWN_EXTENSIONS = frozenset(
{".pdf", ".png", ".svg", ".eps", ".ps", ".jpg", ".jpeg", ".tif", ".tiff", ".webp"}
)
_figure_format = None
[docs]
def plot_style_context():
"""Return a matplotlib rc_context using the project plotting style.
Nothing is set globally: importing this module leaves a caller's own
matplotlib configuration untouched, and the style applies only inside the
``with`` block.
"""
return mpl.rc_context(PLOT_RC_PARAMS)
def _validate_format(fmt, source):
fmt = str(fmt).strip().lower().lstrip(".")
if fmt not in FIGURE_FORMATS:
raise ValueError(f"Unsupported {source} {fmt!r}. Available: {', '.join(FIGURE_FORMATS)}")
return fmt
#: Formats there is no point retrying a failed save in: JPEG is what the
#: fallback writes, and PNG holds the same uncompressed canvas in memory.
_NO_CHEAPER_FALLBACK = frozenset({".jpg", ".jpeg", ".png"})
def _save_smaller(fig, fname, dpi, kwargs):
"""Rewrite a figure that ran out of memory as :data:`LARGE_FIGURE_FORMAT`.
Called only from :func:`save_figure`'s ``except MemoryError`` branch, so a
bare ``raise`` here re-raises that error with its original traceback.
"""
root, ext = os.path.splitext(fname)
if ext.lower() in _NO_CHEAPER_FALLBACK:
raise
fallback = f"{root}.{LARGE_FIGURE_FORMAT}"
logging.warning(
f"Ran out of memory writing {fname}: a vector canvas needs one whole-figure "
f"pixel buffer per rasterized element. Falling back to {fallback}. It stays "
f"readable zoomed in, and `img2pdf {os.path.basename(fallback)} -o "
f"{os.path.basename(root)}.pdf` wraps it back into a PDF if one is needed."
)
# The partial file is not a figure and must not be left looking like one:
# anything downstream globbing for the output would pick it up.
if os.path.exists(fname):
os.remove(fname)
fig.savefig(fallback, dpi=dpi, **kwargs)
return fallback
[docs]
def fitted_raster_dpi(fig):
"""The resolution ``fig`` can be rasterized at without an unbounded canvas.
:data:`RASTER_DPI` for anything of a size somebody chose -- every figure
built from :func:`figure_size` is orders of magnitude below the ceiling.
It only bites on a figure whose size is a consequence rather than a
decision, which in this package means a corner plot: it grows by about
2.1 inches per parameter in each direction, so a twenty-epoch fit asks for
a canvas 13000 pixels on a side.
The resolution is then reduced continuously to hold the canvas at
:data:`MAX_CANVAS_PX`, and never below :data:`MIN_RASTER_DPI` -- past that
point the labels stop being legible, and a figure nobody can read is not a
cheaper figure but a wasted one.
Parameters
----------
fig : matplotlib.figure.Figure
The figure about to be written.
Returns
-------
float
Resolution in dots per inch.
"""
side = max(fig.get_size_inches())
if side <= 0:
return RASTER_DPI
dpi = min(RASTER_DPI, max(MIN_RASTER_DPI, MAX_CANVAS_PX / side))
if dpi < RASTER_DPI:
logging.info(
f"Figure is {side:.0f} inches on its longer side: writing it at {dpi:.0f} dpi "
f"instead of {RASTER_DPI:.0f}, to hold the canvas near {MAX_CANVAS_PX} pixels."
)
return dpi
[docs]
def corner_plot_kwargs(ndim):
"""Extra arguments :func:`corner.corner` should be called with for ``ndim``.
Empty up to :data:`CORNER_SCATTER_NDIM`. Above it the per-sample scatter
is turned off, for the reasons on that constant. A caller's own keywords
are applied after these, so an explicit choice still wins.
Parameters
----------
ndim : int
Number of parameters the corner plot covers.
Returns
-------
dict
Keywords to pass before the caller's own.
"""
if ndim <= CORNER_SCATTER_NDIM:
return {}
logging.info(
f"Corner plot has {ndim} parameters (over {CORNER_SCATTER_NDIM}): not drawing "
"the individual samples behind the contours, which is what makes it expensive."
)
return {"plot_datapoints": False}
[docs]
def image_axes(ax):
"""Mark an axis as holding an image, and drop its grid.
A grid behind a ``pcolormesh`` or an ``imshow`` cannot be seen, and one in
front of it is clutter over the data. Returns ``ax``, so it can be applied
inline.
"""
ax.grid(False)
return ax