import warnings
from copy import copy, deepcopy
import matplotlib.axes as maxes
import numpy as np
import numpy.ma as ma
from astropy.visualization import ImageNormalize, ZScaleInterval, simple_norm
from astropy.visualization.stretch import (
AsinhStretch,
LinearStretch,
LogStretch,
PowerStretch,
SqrtStretch,
)
from cycler import cycler
from matplotlib.colors import LogNorm
from mpl_toolkits.axes_grid1 import make_axes_locatable
from .. import utils
# WCS-projected axes over NaN-filled maps produce NaN sky coordinates for some
# tick positions. astropy's Angle.to_string() wraps do_format in np.vectorize;
# numpy's ufunc layer warns "invalid value encountered in do_format (vectorized)"
# before do_format even runs (it handles NaN correctly internally). Suppress it.
warnings.filterwarnings("ignore", message=".*do_format.*", category=RuntimeWarning)
[docs]
class PlotBase:
"""Base class for plotting.
Parameters
----------
tool : any class derived from :class:`~pdrtpy.tool.toolbase.ToolBase`
Reference to a :mod:`~pdrtpy.tool` object or ``None``. This is used for
classes that inherit from PlotBase and are coupled to a specific tool,
e.g. :class:`~pdrtpy.plot.LineRatioPlot` and :class:`~pdrtpy.tool.LineRatioFit`.
"""
def __init__(self, tool):
import matplotlib.pyplot
self._plt = matplotlib.pyplot
# don't use latex in text labels etc by default.
# because legends and titles wind up using a different font than axes
# @TODO figure out how to make them all use the same font (e.g. CMBright)
self._plt.rcParams["text.usetex"] = False
self._figure = None
self._axis = None
self._tool = tool
self._valid_norms = ["simple", "zscale", "log"]
self._valid_stretch = ["linear", "sqrt", "power", "log", "asinh"]
# color blind/friendly color cyle courtesy https://gist.github.com/thriveth/8560036
# also added some from matplotlib 'tableau-colorblind10'
self._CB_color_cycle = [
"#377eb8",
"#ff7f00",
"#4daf4a",
"#f781bf",
"#a65628",
"#984ea3",
"#999999",
"#e41a1c",
"#dede00",
"#595959",
"#5F9ED1",
"#C85200",
"#898989",
"#A2C8EC",
"#FFBC79",
"#CFCFCF",
]
self.colorcycle(self._CB_color_cycle)
def _autolevels(self, data, steps="log", numlevels=None, verbose=False):
"""Compute contour levels automatically based on data.
Parameters
----------
data : :class:`numpy.ndarray`, :mod:`astropy.io.fits` HDU, or CCDData
The data to contour.
steps : str, optional
The type of steps to compute: ``"log"`` for logarithmic, or ``"lin"`` for linear. Default: ``"log"``.
numlevels : int, optional
The number of contour levels to compute. Default: None, which means
autocompute (typically gives about 10 levels).
verbose : bool, optional
Print the computed levels. Default: False.
Returns
-------
:class:`numpy.ndarray`
Array containing level values.
"""
# tip of the hat to the WIP autolevels code lev.
# http://admit.astro.umd.edu/wip , wip/src/plot/levels.c
# CVS at http://www.astro.umd.edu/~teuben/miriad/install.html
# print(type(data))
max_ = data.max()
min_ = data.min()
if min_ <= 0:
min_ = 1e-10
# print("Auto contour levels: min %f max %f"%(min_,max_))
if numlevels is None:
try:
numlevels = int(0.5 + 3 * (np.log(max_) - np.log(min_)) / np.log(10))
except ValueError:
print(f"Bad numlevels with [min,max]=[{min_},{max_}]")
raise
# print("levels start %d levels"%numlevels)
# force number of levels to be between 5 and 15
numlevels = max(numlevels, 5)
numlevels = min(numlevels, 15)
if steps[0:3] == "lin":
slope = (max_ - min_) / (numlevels - 1)
levels = np.array([min_ + slope * j for j in range(0, numlevels)])
elif steps[0:3] == "log":
# if data minimum is non-positive (shouldn't happen for models),
# , min_cut=min_,max_cut=max_, stretch='log', clip=False) start log contours at lgo10(1) = 0
if min_ <= 0:
min_ = 1
slope = np.log10(max_ / min_) / (numlevels - 1)
levels = np.array([min_ * np.power(10, slope * j) for j in range(0, numlevels)])
else:
raise ValueError("steps must be 'lin' or 'log'")
if verbose:
print(f"Computed {numlevels:d} contour autolevels: {levels}")
return levels
@property
def figure(self):
"""The last figure that was drawn.
Returns
-------
:class:`matplotlib.figure.Figure`
"""
return self._figure
@property
def axis(self):
"""The last axis that was drawn.
Returns
-------
:class:`matplotlib.axes._subplots.AxesSubplot`
"""
return self._axis
[docs]
def text(self, x, y, s, fontdict=None, **kwargs):
r"""Add text to the Axes at location ``x, y`` in data coordinates.
Calls through to :meth:`matplotlib.pyplot.text`.
Parameters
----------
x : float
The horizontal coordinate for the text.
y : float
The vertical coordinate for the text.
s : str
The text.
fontdict : dict, optional
A dictionary to override the default text properties. If None,
the defaults are determined by rcParams.
\*\*kwargs
Other miscellaneous :class:`~matplotlib.text.Text` parameters.
"""
self._plt.text(x, y, s, fontdict, **kwargs)
def _zscale(self, image, vmin, vmax, stretch, contrast=0.25):
"""Normalization object using Zscale algorithm.
See :mod:`astropy.visualization.ZScaleInterval`.
Parameters
----------
image : :mod:`astropy.io.fits` HDU or CCDData
The image object.
vmin : float
Minimum value for normalization.
vmax : float
Maximum value for normalization.
stretch : str
Stretch type to apply.
contrast : float, optional
Scaling factor (0 to 1) for determining min/max display values.
Larger values increase the difference. Default: 0.25.
Returns
-------
:class:`astropy.visualization.ImageNormalize`
"""
# clip=False required or NaNs get max color value, see https://github.com/astropy/astropy/issues/8165
if stretch == "linear":
s = LinearStretch()
elif stretch == "sqrt":
s = SqrtStretch()
elif stretch == "power":
s = PowerStretch(2)
elif stretch == "log":
s = LogStretch(1000)
elif stretch == "asinh":
s = AsinhStretch(0.1)
else:
raise ValueError(f"Unknown stretch: {stretch}.")
norm = ImageNormalize(
data=image, vmin=vmin, vmax=vmax, interval=ZScaleInterval(contrast=contrast), stretch=s, clip=False
)
return norm
def _get_norm(self, norm, km, vmin, vmax, stretch):
"""Get a Normalization object.
Parameters
----------
norm : str
The normalization type: ``'simple'``, ``'zscale'``, or ``'log'``.
km : :mod:`astropy.io.fits` HDU or CCDData
The image object.
vmin : float
The image minimum to use.
vmax : float
The image maximum to use.
stretch : str
The stretch to use: ``'linear'``, ``'log'``, ``'power'``, or ``'asinh'``.
Returns
-------
:class:`astropy.visualization.ImageNormalize` or :class:`matplotlib.colors.LogNorm`
"""
if isinstance(norm, str):
norm = norm.lower()
if norm not in self._valid_norms:
raise ValueError(f"Unrecognized normalization {norm}. Valid values are {self._valid_norms}")
if stretch not in self._valid_stretch:
raise ValueError(f"Unrecognized stretch {stretch}. Valid values are {self._valid_stretch}")
# print("norm cut at %.1e %.1e"%(vmin,vmax))
if norm == "simple":
# astropy made a non-backwards compatible argument name change.
# if astropy.version.major > 6 or astropy.version.version[0:3] == "6.1":
# return simple_norm(km, vmin=vmin, vmax=vmax, stretch=stretch, clip=False)
# else:
# @deprecated_renamed_argument should fix this in astropy 6.1+
return simple_norm(km, vmin=vmin, vmax=vmax, stretch=stretch, clip=False)
elif norm == "zscale":
return self._zscale(km, vmin, vmax, stretch)
elif norm == "log":
# stretch ignored in this case
return LogNorm(vmin=vmin, vmax=vmax, clip=False)
else:
return norm
def _wcs_colorbar(self, image, axis, pos="right", width="5%", pad=0.05, orientation="vertical"):
"""Create a colorbar for a subplot with WCSAxes (as opposed to matplotlib Axes).
There are some side-effects of using WCS projection that need to be
ameliorated. Also for subplots, we want the colorbars to have the same
height as the plot, which is not the default behavior.
Parameters
----------
image : :class:`numpy.ndarray`, :mod:`astropy.io.fits` HDU, or CCDData
The mappable object for the plot. Must not be masked.
axis : :class:`matplotlib.axis.Axes`
Which Axes object for the plot.
pos : str, optional
Colorbar position: ``"left"``, ``"right"``, ``"bottom"``, or ``"top"``. Default: ``"right"``.
width : str, optional
Width of the colorbar as a percent of the plot width. Default: ``"5%"``.
pad : float, optional
Padding between colorbar and plot, in inches. Default: 0.05.
orientation : str, optional
Orientation of colorbar: ``"vertical"`` or ``"horizontal"``. Default: ``"vertical"``.
"""
divider = make_axes_locatable(axis)
cax = divider.append_axes(pos, size=width, pad=pad, axes_class=maxes.Axes)
cax.yaxis.set_ticks_position(pos)
return self._figure.colorbar(image, ax=axis, cax=cax, orientation=orientation)
def _draw_grid(self, axis, linewidth):
"""Draw a standard major+minor grid on *axis*."""
axis.grid(visible=True, which="major", axis="both", lw=linewidth / 2, color="k", alpha=0.33)
axis.grid(visible=True, which="minor", axis="both", lw=linewidth / 2, color="k", alpha=0.22, linestyle="--")
def _set_standard_ticks(self, axis):
"""Set inward ticks on all four sides of *axis*."""
axis.tick_params(axis="both", direction="in", which="both")
axis.tick_params(axis="both", bottom=True, top=True, left=True, right=True, which="both")
def _make_phantom_handles(self, axis, n):
"""Return a list of *n* invisible line handles for use as legend column headers."""
return [axis.plot([], marker="", markersize=0, ls="", lw=0)[0]] * n
def _zero_legend_header_widths(self, leg):
"""Remove extra left-side space from phantom column-header entries in *leg*.
Applies the trick from https://stackoverflow.com/a/44072076 that zeroes
out the handle width for the first two entries in each legend column so
that text-only headers appear centred rather than left-shifted.
"""
for vpack in leg._legend_handle_box.get_children():
for hpack in vpack.get_children()[:2]:
hpack.get_children()[0].set_width(0)
[docs]
def savefig(self, fname, **kwargs):
"""Save the current figure to a file.
Parameters
----------
fname : str
Filename to save to.
**kwargs
Additional arguments passed to :meth:`matplotlib.pyplot.savefig`,
e.g. ``bbox_inches='tight'`` for a tight layout.
"""
kwargs_opts = {"bbox_inches": "tight", "transparent": False, "facecolor": "white"}
kwargs_opts.update(kwargs)
self._figure.savefig(fname=fname, **kwargs_opts)
[docs]
def usetex(self, use):
"""Control whether plots delegate rendering to the system LaTeX or use matplotlib's rendering.
Sets matplotlib parameter ``rcParams["text.usetex"]`` in the local pyplot instance.
Note: You must have LaTeX installed if setting this to True or an exception will be
raised when you try to plot.
Parameters
----------
use : bool
Whether to use LaTeX or not.
"""
self._plt.rcParams["text.usetex"] = use
[docs]
def colorcycle(self, colorcycle):
"""Set the plot color cycle for multi-trace plots.
The default color cycle is optimized for color-blind users.
Parameters
----------
colorcycle : list
List of colors to use, typically hex color strings. Passed to
:meth:`matplotlib.pyplot.rc` as the *axes prop_cycle* parameter
using :class:`matplotlib.cycler`.
"""
self._plt.rc("axes", prop_cycle=(cycler("color", colorcycle)))
[docs]
def reset_colorcycle(self):
"""Reset the color cycle to the default color-blind friendly one"""
self.colorcycle(self._CB_color_cycle)
def _plot(self, data, **kwargs):
"""generic plotting method used by other plot methods"""
kwargs.pop("test", False)
kwargs_plot = {"show": "data"} # or 'mask' or 'error'
kwargs_opts = {
"units": None,
"image": True,
"colorbar": True,
"contours": True,
"label": False,
"title": None,
"log": False,
"axis": None,
}
kwargs_contour = {"levels": None, "colors": ["white"], "linewidths": 1.0}
# Merge in any keys the user provided, overriding defaults.
kwargs_contour.update(kwargs)
kwargs_opts.update(kwargs)
kwargs_plot.update(kwargs)
_data = deepcopy(data) # default is show the data
if kwargs_plot["show"] == "error":
_data = deepcopy(data)
_data.data = _data.error
# do the log here, because we won't take log of a mask.
if kwargs_opts["log"]:
_data.data = np.log10(_data.data)
kwargs_opts.pop("log", None)
kwargs.pop("log", None)
if kwargs_plot["show"] == "mask":
_data = deepcopy(data)
_data.data = _data.mask
# can't contour a boolean
kwargs_opts["contours"] = False
if self._tool._modelnaxis == 2 or len(_data.shape) == 2:
if kwargs_opts["units"] is not None:
k = utils.to(kwargs_opts["units"], _data)
else:
k = _data
elif self._tool._modelnaxis == 3:
if kwargs_opts["units"] is not None:
k = utils.to(kwargs_opts["units"], _data[0, :, :])
else:
k = _data[0, :, :]
else:
raise Exception(f"Unexpected model naxis: {self._tool._modelnaxis:d}")
km = ma.masked_invalid(k)
if getattr(k, "mask", None) is not None:
km.mask = np.logical_or(k.mask, km.mask)
# make sure nans don't affect the color map
min_ = np.nanmin(km)
max_ = np.nanmax(km)
kwargs_imshow = {
"origin": "lower",
"norm": "simple",
"stretch": "linear",
"vmin": min_,
"vmax": max_,
"cmap": "plasma",
"aspect": "auto",
}
kwargs_subplot = {
"nrows": 1,
"ncols": 1,
"index": 1,
"reset": True,
"constrained_layout": False, # this appears to have no effect
}
# delay merge until min_ and max_ are known
kwargs_imshow.update(kwargs)
kwargs_imshow["norm"] = self._get_norm(
kwargs_imshow["norm"], km, kwargs_imshow["vmin"], kwargs_imshow["vmax"], kwargs_imshow["stretch"]
)
kwargs_subplot.update(kwargs)
# swap ncols and nrows in figsize to preserve aspect ratio
kwargs_subplot["figsize"] = kwargs.get("figsize", (kwargs_subplot["ncols"] * 5, kwargs_subplot["nrows"] * 5))
axidx = kwargs_subplot["index"] - 1
if kwargs_subplot["reset"] and kwargs_opts["axis"] is None:
self._figure, self._axis = self._plt.subplots(
kwargs_subplot["nrows"],
kwargs_subplot["ncols"],
figsize=kwargs_subplot["figsize"],
subplot_kw={"projection": k.wcs, "aspect": kwargs_imshow["aspect"]},
constrained_layout=kwargs_subplot["constrained_layout"],
)
if kwargs_opts["axis"] is not None:
self._axis = kwargs_opts["axis"]
if type(self._axis) is not np.ndarray:
self._axis = np.array([self._axis])
for a in self._axis:
a.tick_params(axis="both", direction="in") # axes vs axis???
if hasattr(a, "coords"):
for c in a.coords:
c.display_minor_ticks(True)
if kwargs_opts["image"]:
current_cmap = copy(self._plt.get_cmap(kwargs_imshow["cmap"]))
current_cmap.set_bad(color="white", alpha=1)
# suppress errors and warnings about unused keywords
# @todo need a better solution for this, it is not scalable.
# push onto a stack? or pop everything that is NOT related to imshow.
for kx in [
"units",
"image",
"contours",
"label",
"title",
"linewidths",
"levels",
"nrows",
"ncols",
"test",
"index",
"reset",
"colors",
"colorbar",
"show",
"axis",
"yaxis_unit",
"xaxis_unit",
"bbox_to_anchor",
"loc",
"constrained_layout",
"figsize",
"stretch",
"legend",
"markersize",
"show_fit",
]:
kwargs_imshow.pop(kx, None)
# eliminate deprecation warning. vmin,vmax are passed to Normalization object.
if kwargs_imshow["norm"] is not None:
kwargs_imshow.pop("vmin", None)
kwargs_imshow.pop("vmax", None)
im = self._axis[axidx].imshow(km, **kwargs_imshow)
if kwargs_opts["colorbar"]:
self._wcs_colorbar(im, self._axis[axidx])
# reset the axis so that users can call plot._plt.whatever()
# ipympl and similar backends may not register a canvas manager,
# causing pyplot.sca() to raise ValueError. Skip when unmanaged.
_ax = self._axis[axidx]
_fig = _ax.get_figure()
if _fig is not None and getattr(_fig.canvas, "manager", None) is not None:
self._plt.sca(_ax)
if kwargs_opts["contours"]:
if kwargs_contour["levels"] is None:
# Figure out some autolevels
kwargs_contour["levels"] = self._autolevels(km, "log")
# suppress errors and warnings about unused keywords
for kx in [
"units",
"image",
"contours",
"label",
"title",
"cmap",
"aspect",
"colorbar",
"reset",
"nrows",
"ncols",
"index",
"show",
"yaxis_unit",
"xaxis_unit",
"norm",
"constrained_layout",
"figsize",
"stretch",
"legend",
"markersize",
"show_fit",
]:
kwargs_contour.pop(kx, None)
contourset = self._axis[axidx].contour(km, **kwargs_contour)
if kwargs_opts["label"]:
self._axis[axidx].clabel(contourset, contourset.levels, inline=True, fmt="%1.1e")
if kwargs_opts["title"] is not None:
# self.figure.subplots_adjust(top=0.95)
# self._axis[axidx].set_title(kwargs_opts['title'])
# Using ax.set_title causes the title to be cut off. No amount of
# diddling with tight_layout, constrained_layout, subplot adjusting, etc
# would affect this. However using Figure.suptitle seems to work.
self.figure.suptitle(kwargs_opts["title"], y=0.95)
if k.wcs is not None:
ax = self._axis[axidx]
if hasattr(ax, "coords"):
# WCSAxes: use the coords interface; set_xlabel/ylabel triggers
# layout/tick machinery that accesses _coord_range before draw.
ax.coords[0].set_axislabel(k.wcs.wcs.lngtyp)
ax.coords[1].set_axislabel(k.wcs.wcs.lattyp)
else:
ax.set_xlabel(k.wcs.wcs.lngtyp)
ax.set_ylabel(k.wcs.wcs.lattyp)