Source code for pdrtpy.plot.modelplot

import warnings
from copy import deepcopy

import astropy.units as u
import astropy.wcs as wcs
import numpy as np
import numpy.ma as ma
from astropy import log
from matplotlib import ticker
from matplotlib.lines import Line2D

from .. import utils
from ..measurement import Measurement
from .plotbase import PlotBase

log.setLevel("WARNING")  # see issue 163


[docs] class ModelPlot(PlotBase): """Tool for exploring sets of models. Can plot individual intensity or ratio models, phase-space diagrams, and optionally overlay observations. Units are seamlessly transformed, so you can plot in Habing units, Draine units, or any conformable quantity. ModelPlot does not require model fitting with :class:`~pdrtpy.tool.lineratiofit.LineRatioFit` first. The methods of this class can take a variety of optional keywords. See the general `Plot Keywords`_ documentation. """ def __init__(self, modelset, figure=None, axis=None): """ Parameters ---------- modelset : :class:`~pdrtpy.modelset.ModelSet` The set of models to use in these plots. """ super().__init__(tool=None) self._modelset = modelset self._figure = figure self._axis = axis
[docs] def plot(self, identifier, **kwargs): """Plot a model intensity or ratio. Parameters ---------- identifier : str Identifier tag for the model to plot, e.g., ``"CII_158"``, ``"OI_145"``, ``"CO_43/CO_21"``. See Also -------- :meth:`~pdrtpy.modelset.ModelSet.supported_lines` : for a list of available identifier tags. """ kwargs_opts = {"measurements": None} kwargs_opts.update(kwargs) if "/" in identifier: self.ratio(identifier, **kwargs_opts) else: self.intensity(identifier, **kwargs_opts)
[docs] def ratio(self, identifier, **kwargs): """Plot a model ratio. Parameters ---------- identifier : str Identifier tag for the model to plot, e.g., ``"OI_63+CII_158/FIR"``, ``"CO_43/CO_21"``. See Also -------- :meth:`~pdrtpy.modelset.ModelSet.supported_ratios` : for a list of available identifier tags. """ ms = self._modelset model = ms.get_model(identifier) kwargs_opts = { "title": ms.table.loc[identifier]["title"], "colorbar": True, "contours": True, "colors": ["white"], "meas_color": [self._CB_color_cycle[0]], "legend": True, "bbox_to_anchor": None, "loc": "upper center", "image": True, "measurements": None, } kwargs_opts.update(kwargs) # make a sensible choice about contours if image is not shown if not kwargs_opts["image"] and kwargs_opts["colors"][0] == "white": kwargs_opts["colors"][0] = "black" self._plot_no_wcs(model, **kwargs_opts) if kwargs_opts["legend"]: lines = list() labels = list() if kwargs_opts["contours"]: lines.append(Line2D([0], [0], color=kwargs_opts["colors"][0], linewidth=3, linestyle="-")) labels.append(f"{self._modelset.name} model") if kwargs_opts["measurements"] is not None: lencol = len(kwargs_opts["meas_color"]) if lencol > 1: for i in range(lencol): lines.append(Line2D([0], [0], color=kwargs_opts["meas_color"][i], linewidth=3, linestyle="-")) labels.append(f"observed #{i}") else: lines.append(Line2D([0], [0], color=kwargs_opts["meas_color"][0], linewidth=3, linestyle="-")) self._axis[0].legend( lines, labels, loc=kwargs_opts["loc"], bbox_to_anchor=kwargs_opts["bbox_to_anchor"], title=kwargs_opts["title"], )
[docs] def intensity(self, identifier, **kwargs): """Plot a model intensity. Parameters ---------- identifier : str Identifier tag for the model to plot, e.g., ``"OI_63"``, ``"CII_158"``, ``"CO_10"``. See Also -------- :meth:`~pdrtpy.modelset.ModelSet.supported_intensities` : for a list of available identifier tags. """ ms = self._modelset meas = kwargs.get("measurements", None) model = ms.get_models([identifier], model_type="intensity") if meas is not None: if type(meas[0]) is not Measurement: raise TypeError("measurement keyword value must be a list of Measurements") if model[identifier]._unit != meas[0].unit: raise TypeError( f"Model and Measurement for {identifier} have different units:" f" ({model[identifier]._unit},{meas[0].unit})" ) if identifier != meas[0].id: msg = ( f"Identifiers of model {identifier} and supplied Measurement {meas[0].id} do not match. Plotting" " anyway." ) warnings.warn(msg, stacklevel=2) kwargs_opts = { "title": ms.table.loc[identifier]["title"], "colorbar": True, "contours": True, "colors": ["white"], "meas_color": [self._CB_color_cycle[0]], "legend": True, "bbox_to_anchor": None, "loc": "upper center", "image": True, } kwargs_opts.update(kwargs) if not kwargs_opts["image"] and kwargs_opts["colors"][0] == "white": kwargs_opts["colors"][0] = "black" self._plot_no_wcs(model[identifier], **kwargs_opts) if kwargs_opts["legend"]: lines = list() labels = list() if kwargs_opts["contours"]: lines.append(Line2D([0], [0], color=kwargs_opts["colors"][0], linewidth=3, linestyle="-")) labels.append("model") if meas is not None: lines.append(Line2D([0], [0], color=kwargs_opts["meas_color"][0], linewidth=3, linestyle="-")) labels.append("observed") # maybe loc should be 'best' but then it bounces around self._axis[0].legend( lines, labels, loc=kwargs_opts["loc"], bbox_to_anchor=kwargs_opts["bbox_to_anchor"], title=kwargs_opts["title"], )
[docs] def overlay(self, measurements, **kwargs): r"""Overlay one or more single-pixel measurements in the model space :math:`(n,F_{FUV})`. Parameters ---------- measurements : list of :class:`~pdrtpy.measurement.Measurement` A list of one or more Measurements to overlay. shading : float, optional Controls how measurements and errors are drawn. If 0, Measurements will be drawn as solid contours for the value and dashed for the +/- errors. If between 0 and 1, Measurements are drawn as filled contours representing the size of the errors (see :func:`matplotlib.pyplot.contourf`) with alpha set to the ``shading`` value. Default: 0.4. """ kwargs_opts = { "units": None, "image": False, "contours": False, "meas_color": self._CB_color_cycle, "levels": None, "label": False, "linewidths": 1.0, "ncols": 1, "norm": None, "title": None, "reset": True, "legend": True, "bbox_to_anchor": None, "loc": "upper center", "shading": 0.4, } kwargs_opts.update(kwargs) if kwargs_opts["shading"] < 0 or kwargs_opts["shading"] > 1: raise ValueError("Shading must be between 0 and 1 inclusive") ids = [m.id for m in measurements] meas = dict(zip(ids, measurements, strict=False)) models = [self._modelset.get_model(i) for i in ids] # need to trim model grids if H2 is present if utils._has_H2(ids) and self._modelset.is_wk2006: warnings.warn("Trimming all model grids to match H2 grid: log(n) = 1-5, log(G0) = 1-5", stacklevel=2) utils._trim_all_to_H2(models) i = 0 nratio = 0 nintensity = 0 for val in models: if np.size(meas[val.id].data) != 1: raise ValueError( f"Can't plot {val.id}. This method only works with single pixel Measurements [len(measurement.data)" " must be 1]" ) if i > 0: kwargs_opts["reset"] = False # pass the index of the contour color to use via the "secret" colorcounter keyword. self._plot_no_wcs( val, header=None, measurements=[utils.convert_if_necessary(meas[val.id])], colorcounter=i, **kwargs_opts ) if val.modeltype == "ratio": nratio = nratio + 1 if val.modeltype == "intensity": nintensity = nintensity + 1 i = i + 1 if kwargs_opts["legend"]: if nratio == 0 and nintensity > 0: word = "Intensities" elif nratio > 0 and nintensity == 0: word = "Ratios" else: word = "Values" lines = [Line2D([0], [0], color=c, linewidth=3, linestyle="-") for c in kwargs_opts["meas_color"][0:i]] labels = [k.title for k in models] self._plt.legend( lines, labels, loc=kwargs_opts["loc"], bbox_to_anchor=kwargs_opts["bbox_to_anchor"], title="Observed " + word, )
[docs] def isoplot(self, identifier, plotnaxis, nax_clip=None, **kwargs): """Plot lines of constant model parameter as a function of the other model parameter and a model intensity or ratio. Parameters ---------- identifier : str Identifier tag for the model to plot, e.g., ``"OI_63/CO_21"`` or ``"CII_158"``. plotnaxis : int Which NAXIS to use to compute lines of constant value. Since models have two axes, this must be either 1 or 2. nax_clip : array-like of :class:`~astropy.units.Quantity`, optional The range of model parameters on NAXIS{plotnaxis} to show. e.g. ``[10, 1E7]*Unit("cm-3")``. Default: None (full range). step : int, optional Allows skipping lines of constant value, e.g. plot every ``step``-th value. Useful when parameter space is crowded. Default: 1. """ kwargs_opts = { "errorbar": False, "fmt": None, "label": None, "legend": True, "title": None, "grid": True, "figsize": (8, 5), "linewidth": 2.0, "aspect": "auto", "step": 1, "logx": False, "logy": False, "measurements": None, "bbox_to_anchor": (1.024, 1), "loc": "upper left", "xaxis_unit": None, "yaxis_unit": None, "test": False, } kwargs_opts.update(kwargs) model = self._modelset.get_model(identifier) self._figure, self._axis = self._plt.subplots(nrows=1, ncols=1, figsize=kwargs_opts["figsize"]) if plotnaxis != 1 and plotnaxis != 2: raise ValueError("plotnaxis must be 1 or 2") if plotnaxis == 1: otherindex = 1 if plotnaxis == 2: otherindex = 0 pindex = plotnaxis - 1 naxis_is_log = ["log" in c for c in model.wcs.wcs.ctype] naxis = list(self._get_xy_from_wcs(model, quantity=True, linear=True)) naxislog = self._get_xy_from_wcs(model, quantity=True, linear=False) if nax_clip is None: nax_clip = [naxis[pindex][0].value, naxis[pindex][-1].value] * naxis[pindex].unit dcc = nax_clip.to(naxis[pindex].unit) # Select the model naxis *indices* within the NAX limits xi = np.where((naxis[pindex] >= dcc[0]) & (naxis[pindex] <= dcc[1]))[0] if naxis_is_log[pindex]: # Create an array containing *indices* of the range of naxis[pindex] alues. # -5,12 is a large enough logarithmic range to contain any model we have # this picks out integer values of log(naxis). x2 = np.hstack([np.where((np.round(naxislog[pindex].value, 1)) == i)[0] for i in np.arange(-5, 12)]) else: x2 = np.arange(model.wcs._naxis[pindex]) xi2 = np.intersect1d(xi, x2) naxis[otherindex], xlabel = utils.rescale_axis_units( naxis[otherindex], naxis[otherindex].unit, model.wcs.wcs.ctype[otherindex], kwargs_opts["xaxis_unit"], loglabel=False, ) # @TODO possibly support plotaxis_unit lines = [] # convert the yaxis if requested. The yaxis is the pixel values in the model, # which have units as specified in BUNIT header item. bunit = 1.0 * u.Unit(model.header.get("BUNIT", "")) bscale = 1.0 if kwargs_opts["yaxis_unit"] is not None: bunit = bunit.to(kwargs_opts["yaxis_unit"]) bscale = bunit.value if bunit != u.dimensionless_unscaled: ylabel = rf"{model.title} [{bunit.unit:latex_inline}]" else: ylabel = model.title for j in xi2[:: kwargs_opts["step"]]: if plotnaxis == 1: yy = bscale * model[0 : len(naxis[otherindex]), j] else: yy = bscale * model[j, 0 : len(naxis[otherindex])] if naxis_is_log[pindex]: label = f"{np.round(np.log10(naxis[pindex][j].to(nax_clip.unit).value), 1):.1f}" else: label = f"{np.round(naxis[pindex][j].to(nax_clip.unit).value, 0):.0f}" lines.extend(self._axis.plot(naxis[otherindex], yy, label=label)) # Format the plot in a sensible way self._axis.set_ylabel(ylabel) self._axis.set_xlabel(xlabel) self._axis.set_aspect(kwargs_opts["aspect"]) self._axis.ticklabel_format(axis="both", useMathText=True) if kwargs_opts["logx"]: self._axis.set_xscale("log") if kwargs_opts["logy"]: self._axis.set_yscale("log") self._set_standard_ticks(self._axis) if kwargs_opts["grid"]: self._draw_grid(self._axis, kwargs_opts["linewidth"]) if kwargs_opts["legend"]: title1 = model.wcs.wcs.ctype[pindex] if "_" in title1: title1 = r"${\rm " + title1 + "}$" unit1 = "[" + nax_clip.unit.to_string("latex_inline") + "]" handles, labels = self._axis.get_legend_handles_labels() phantom = self._make_phantom_handles(self._axis, 2) labels = [title1, unit1, *labels] handles = phantom + lines leg = self._axis.legend( handles, labels, ncol=1, markerfirst=True, bbox_to_anchor=kwargs_opts["bbox_to_anchor"], loc=kwargs_opts["loc"], ) self._zero_legend_header_widths(leg) if kwargs_opts["title"] is not None: self._axis.set_title(kwargs_opts["title"])
[docs] def phasespace( self, identifiers, nax1_clip=None, nax2_clip=None, reciprocal=None, **kwargs, ): r"""Plot lines of constant density and radiation field on a ratio-ratio, ratio-intensity, or intensity-intensity map. Parameters ---------- identifiers : list of str List of two identifier tags for the model to plot, e.g., ``["OI_63/CO_21", "CII_158"]``. nax1_clip : array-like of :class:`~astropy.units.Quantity`, optional Range of model densities on NAXIS1 to show. For most models, NAXIS1 is hydrogen number density :math:`n_H` in :math:`{\rm cm}^{-3}`. For ionized gas models, it is electron temperature :math:`T_e` in K. Default: ``[10, 1E7]*Unit("cm-3")``. nax2_clip : array-like of :class:`~astropy.units.Quantity`, optional Range of model parameters on NAXIS2. For most models, NAXIS2 is radiation field intensities in Habing or cgs units. For ionized gas models, it is electron volume density :math:`n_e`. Default: ``[10, 1E6]*utils.habing_unit``. reciprocal : array-like of bool, optional Whether to plot the reciprocal of the model on each axis. e.g. ``[False, True]`` means don't flip the X axis but flip the Y axis. Default: ``[False, False]``. measurements : array-like of :class:`~pdrtpy.measurement.Measurement`, optional A list of two Measurements, one per identifier, to plot as data points on the grid. Pairs must be given as ``[m1x, m1y, m2x, m2y, ...]``. Default: None. errorbar : bool, optional Plot error bars when given measurements. Default: True. fmt : array of str, optional Plot format for each Measurement pair. See :meth:`matplotlib.axes.Axes.plot`. Default: ``'sk'`` for all points. label : array of str, optional Legend label(s) for each Measurement pair. Default: ``'data'`` for all. legend : bool, optional Draw a legend on the plot. Default: True. title : str, optional Title to draw on the plot. Default: None. linewidth : float, optional Line width. grid : bool, optional Show grid. Default: True. figsize : 2-tuple of float, optional Figure dimensions (width, height) in inches. Default: ``(8, 5)``. capsize : float, optional End cap length of errorbars in points. Default: 3. markersize : float, optional Size of data point marker in points. Default: 8. """ kwargs_opts = { "errorbar": False, "fmt": None, "label": None, "legend": True, "measurements": None, "title": None, "grid": True, "figsize": (8, 5), "linewidth": 2.0, "capsize": 5.0, "markersize": 8.0, "aspect": "auto", "bbox_to_anchor": (1.024, 1), "loc": "upper left", } kwargs_opts.update(kwargs) if nax1_clip is None: nax1_clip = [10, 1e7] * u.Unit("cm-3") if nax2_clip is None: nax2_clip = [10, 1e6] * utils.habing_unit if reciprocal is None: reciprocal = [False, False] # various input checks if len(list(identifiers)) != 2: raise ValueError("Length of identifiers list must be exactly 2") models = [self._modelset.get_model(i) for i in identifiers] mids = [m.id for m in models] mdict = dict(zip(mids, models, strict=False)) if kwargs_opts["measurements"] is not None: for m in kwargs_opts["measurements"]: if type(m) is not Measurement: raise TypeError("measurement keyword value must be a list of Measurements") if m.id not in mids: raise TypeError(f"Can't find measurement identifier {m.id} in model list {mids}.") if mdict[m.id]._unit != m.unit: raise TypeError( f"Model and Measurement for {m.id} have different units: ({mdict[m.id]._unit},{m.unit})" ) # Now we need to find the model points that fall within the user-specified NAXIS limits. # First get the x,y of the models xlog, ylog = self._get_xy_from_wcs(models[0], quantity=True, linear=False) xlin, ylin = self._get_xy_from_wcs(models[0], quantity=True, linear=True) x_is_log = "log" in models[0].wcs.wcs.ctype[0] y_is_log = "log" in models[0].wcs.wcs.ctype[1] # linear and log units are same so doesn't matter which is used for conversion dcc = nax1_clip.to(xlog.unit) rcc = nax2_clip.to(ylog.unit) # Select the model x,y *indices* within the NAX limits xi = np.where((xlin >= dcc[0]) & (xlin <= dcc[1]))[0] yi = np.where((ylin >= rcc[0]) & (ylin <= rcc[1]))[0] # Create an array containing *indices* of the range of x,y values. # -5,12 is a large enough logarithmic range to contain any model we have if x_is_log: x2 = np.hstack([np.where((np.round(xlog.value, 1)) == i)[0] for i in np.arange(-5, 12)]) else: x2 = np.arange(len(xlin)) # for 2020 models FUV is not an integral value in erg s-1 cm-2 # so rounding is necessary. if y_is_log: y2 = np.hstack([np.where((np.round(ylog.value, 1)) == i)[0] for i in np.arange(-5, 12)]) else: y2 = np.arange(len(ylin)) # Intersection of these two arrays contain the indices of desired model plot points. xi2 = np.intersect1d(xi, x2) yi2 = np.intersect1d(yi, y2) self._figure, self._axis = self._plt.subplots(nrows=1, ncols=1, figsize=kwargs_opts["figsize"]) linesN = [] linesG = [] # Sort out the axes labels depending on whether reciprocal=True or not. for j in xi2: if x_is_log: label = np.round(np.log10(xlin[j].to(nax1_clip.unit).value), 1) else: label = f"{np.round(xlin[j].to(nax1_clip.unit).value, 0):.0f}" if models[0].unit == "": m0label = models[0].title else: m0label = models[0].title + " [" + u.Unit(models[0].unit).to_string("latex_inline") + "]" if models[1].unit == "": m1label = models[1].title else: m1label = models[1].title + " [" + u.Unit(models[1].unit).to_string("latex_inline") + "]" if reciprocal[0]: xx = 1 / models[0][yi2[0] : yi2[-1] + 1, j] self._axis.set_xlabel(utils.fliplabel(m0label)) else: xx = models[0][yi2[0] : yi2[-1] + 1, j] self._axis.set_xlabel(m0label) if reciprocal[1]: yy = 1 / models[1][yi2[0] : yi2[-1] + 1, j] self._axis.set_ylabel(utils.fliplabel(m1label)) else: yy = models[1][yi2[0] : yi2[-1] + 1, j] self._axis.set_ylabel(m1label) linesN.extend(self._axis.loglog(xx, yy, label=label, lw=2)) for j in yi2: if y_is_log: label = np.round(np.log10(ylin[j].to(nax2_clip.unit).value), 1) else: label = f"{np.round(ylin[j].to(nax2_clip.unit).value, 0):.0f}" if reciprocal[0]: xx = 1 / models[0][j, xi2[0] : xi2[-1] + 1] else: xx = models[0][j, xi2[0] : xi2[-1] + 1] if reciprocal[1]: yy = 1 / models[1][j, xi2[0] : xi2[-1] + 1] else: yy = models[1][j, xi2[0] : xi2[-1] + 1] linesG.extend(self._axis.loglog(xx, yy, label=label, lw=2, ls="--")) # plot the input measurement with error bar. Keywords are # [m1x,m1y,m2x,m2y,...] # [fmt1,fmt2,...] # [label1,label2,...] if kwargs_opts["measurements"] is not None: l_meas = len(kwargs_opts["measurements"]) if l_meas % 2 != 0: msg = f"Number of Measurements must be even. You provided {l_meas}" raise ValueError(msg) n_meas = int(l_meas / 2) fmt = kwargs_opts["fmt"] # Set the default format to black squares. if fmt is None: fmt = np.full([n_meas], "sk") elif len(fmt) != n_meas: msg = f"Number of plot formats {len(fmt)} doesn't match number of Measurement pairs {n_meas}." raise ValueError(msg) label = kwargs_opts["label"] if label is None: label = ["data"] elif len(label) != n_meas: msg = f"Number of data labels {len(label)} doesn't match number of Measurement pairs {n_meas}." raise ValueError(msg) args = [] i = 0 # ensure measurements are assigned to correct axis, based on their identifiers. for k in range(0, l_meas, 2): kk = k + 1 if kwargs_opts["measurements"][k].id == identifiers[0]: _x = kwargs_opts["measurements"][k] _y = kwargs_opts["measurements"][kk] else: _x = kwargs_opts["measurements"][kk] _y = kwargs_opts["measurements"][k] # collect the args args.extend([_x, _y, fmt[i]]) # Plot the error bars. Since, unlike axis.loglog(), axis.errorbar() can't # take a *args, we have to call this each time. # Note use of zorder to ensure points are on top of lines. if kwargs_opts["errorbar"]: self._axis.errorbar( x=_x.data, y=_y.data, xerr=_x.error, yerr=_y.error, capsize=kwargs_opts["capsize"], fmt=fmt[i], capthick=2, ls=None, zorder=6, markersize=kwargs_opts["markersize"], ) i = i + 1 # the data points dataline = self._axis.loglog(*args, zorder=5, markersize=kwargs_opts["markersize"]) self._axis.set_aspect(kwargs_opts["aspect"]) self._axis.set_xscale("log") self._axis.set_yscale("log") self._set_standard_ticks(self._axis) if kwargs_opts["grid"]: self._draw_grid(self._axis, kwargs_opts["linewidth"]) if kwargs_opts["legend"]: # Manually build the legend. Create the column headers for the legend # and blank handles and labels to take up space for the headers and # when the number of naxis1 and naxis2 traces # are not equal. title1 = models[0].wcs.wcs.ctype[0] if "_" in title1: title1 = r"${\rm " + title1 + "}$" unit1 = "[" + nax1_clip.unit.to_string("latex_inline") + "]" rs = nax2_clip.unit.to_string() rsl = nax2_clip.unit.to_string("latex_inline") if "G0" in models[0].wcs.wcs.ctype[1] or "FUV" in models[0].wcs.wcs.ctype[1]: title2 = "log(" + utils.get_rad(rs) + ")" else: title2 = models[0].wcs.wcs.ctype[1] if "_" in title2: title2 = r"${\rm " + title2 + "}$" unit2 = "[" + rsl + "]" handles, labels = self._axis.get_legend_handles_labels() phantom = self._make_phantom_handles(self._axis, 2) lN = len(linesN) lG = len(linesG) diff = lN - lG adiff = abs(diff) phantom2 = self._make_phantom_handles(self._axis, adiff) blank = [""] * adiff if diff == 0: labels.insert(lN, unit2) labels.insert(lN, title2) labels = [title1, unit1, *labels] linesN = phantom + linesN linesG = phantom + linesG elif diff > 0: # more densities than radiation fields labels.insert(lN, unit2) labels.insert(lN, title2) labels = [title1, unit1, *labels, *blank] linesN = phantom + linesN linesG = phantom + linesG + phantom2 elif diff < 0: # more radiation fields than densities labels = labels[0:lN] + blank + labels[lN:] labels.insert(lN + adiff, unit2) labels.insert(lN + adiff, title2) labels = [title1, unit1, *labels] linesN = phantom + linesN + phantom2 linesG = phantom + linesG handles = linesN + linesG # add a second legend if user supplied measurements if kwargs_opts["measurements"] is not None: dl = self._axis.legend(dataline, label, loc="best") self._axis.add_artist(dl) leg = self._axis.legend( handles, labels, ncol=2, markerfirst=True, bbox_to_anchor=kwargs_opts["bbox_to_anchor"], loc=kwargs_opts["loc"], ) self._zero_legend_header_widths(leg) # Put the plot title on if given. if kwargs_opts["title"] is not None: self._axis.set_title(kwargs_opts["title"])
def _get_xy_from_wcs(self, data, quantity=False, linear=False): """Get the x,y axis vectors from the WCS of the input data. Parameters ---------- data : :class:`astropy.io.fits.ImageHDU`, :class:`astropy.nddata.CCDData`, or :class:`~pdrtpy.measurement.Measurement` The input image. quantity : bool, optional If True, return arrays as :class:`astropy.units.Quantity`. Default: False. linear : bool, optional If True, return arrays in linear space; if False, in log space. Default: False. Returns ------- :class:`numpy.ndarray` or :class:`astropy.units.Quantity` The axis values as arrays. Values are center of pixel. """ return utils.get_xy_from_wcs(data, quantity, linear) # @todo allow data to be an array? see overlay() def _plot_no_wcs(self, data, header=None, **kwargs): """generic plotting method for images with no WCS, used by other plot methods""" measurements = kwargs.pop("measurements", None) _dataheader = getattr(data, "header", None) if _dataheader is None and header is None: raise Exception( "Either your data must have a header dictionary or you must provide one via the header parameter" ) # input header supercedes data header, under assumption user knows what they are doing. if header is not None: _header = deepcopy(header) if getattr(data, "wcs", None) is None: data.wcs = wcs.WCS(_dataheader) else: _header = deepcopy(_dataheader) # CRxxx might be in wcs and not in header if getattr(data, "wcs", None) is not None: _header.update(data.wcs.to_header()) else: # needed for get_xy_from_wcs call later. data.wcs = wcs.WCS(_header) # however, get the usual complaint about non-FITS units # in WCS, so remove them here because they aren't needed. # We have to convolute ourselves later to add them back in! data.wcs.wcs.cunit = ["" for i in range(data.wcs.naxis)] kwargs_opts = { "units": None, "image": True, "colorbar": False, "contours": True, "label": False, "title": None, "xaxis_unit": None, "yaxis_unit": None, "logx": True, "xlim": None, "logy": True, "ylim": None, "legend": False, "meas_color": ["#4daf4a"], "shading": 0.4, "test": False, } 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) # if self._tool is not None: # if self._tool._modelnaxis is None and "NAXIS" not in _header: # raise Exception("Image header/WCS has no NAXIS keyword") if "NAXIS" not in _header: raise Exception("Image header/WCS has no NAXIS keyword") else: _naxis = _header["NAXIS"] if _naxis == 2: if kwargs_opts["units"] is not None: k = utils.to(kwargs_opts["units"], data) else: k = data elif _naxis == 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 NAXIS value: {_naxis: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, "projection": data.wcs, } # delay merge until min_ and max_ are known kwargs_imshow.update(kwargs) _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"]: # @todo can probably consolidate this self._figure, self._axis = self._plt.subplots( kwargs_subplot["nrows"], kwargs_subplot["ncols"], figsize=kwargs_subplot["figsize"], subplot_kw={"aspect": kwargs_imshow["aspect"]}, constrained_layout=kwargs_subplot["constrained_layout"], ) # Make sure self._axis is an array because we will index it below. if type(self._axis) is not np.ndarray: self._axis = np.array([self._axis]) # When using ncols>1, either the index needs to be 2-d # or the axis array needs to be 1-d. This takes the second approach: if len(self._axis.shape) > 1: self._axis = self._axis.flatten() x, y = self._get_xy_from_wcs(data, quantity=True, linear=True) loglabel = not kwargs_opts["logx"] x, xlab = utils.rescale_axis_units(x, _header["CUNIT1"], _header["CTYPE1"], kwargs_opts["xaxis_unit"], loglabel) loglabel = not kwargs_opts["logy"] y, ylab = utils.rescale_axis_units(y, _header["CUNIT2"], _header["CTYPE2"], kwargs_opts["yaxis_unit"], loglabel) # Finish up axes details. locmaj = ticker.LogLocator(base=10.0, subs=(1.0,), numticks=10) locmin = ticker.LogLocator(base=10.0, subs="auto", numticks=10) locmaj2 = ticker.LogLocator(base=10.0, subs=(1.0,), numticks=10) locmin2 = ticker.LogLocator(base=10.0, subs="auto", numticks=10) if kwargs_opts["logx"]: # order matters here: set_xscale will set a default LogLocator with ticks every 2 decades, so we need to override that. self._axis[axidx].set_xscale("log") self._axis[axidx].xaxis.set_major_locator(locmaj) self._axis[axidx].xaxis.set_minor_locator(locmin) self._axis[axidx].xaxis.set_minor_formatter(ticker.NullFormatter()) if kwargs_opts["logy"]: self._axis[axidx].set_yscale("log") self._axis[axidx].yaxis.set_major_locator(locmaj2) self._axis[axidx].yaxis.set_minor_locator(locmin2) self._axis[axidx].yaxis.set_minor_formatter(ticker.NullFormatter()) self._axis[axidx].set_ylabel(ylab) self._axis[axidx].set_xlabel(xlab) if kwargs_opts["xlim"] is not None: xlim = kwargs_opts["xlim"] self._axis[axidx].set_xlim(left=xlim[0], right=xlim[1]) if kwargs_opts["ylim"] is not None: ylim = kwargs_opts["ylim"] self._axis[axidx].set_ylim(bottom=ylim[0], top=ylim[1]) if kwargs_opts["image"]: # pass shading = auto to avoid deprecation warning # see https://matplotlib.org/3.3.0/gallery/images_contours_and_fields/pcolormesh_grids.html im = self._axis[axidx].pcolormesh( x.value, y.value, km, cmap=kwargs_imshow["cmap"], norm=_norm, shading="auto" ) if kwargs_opts["colorbar"]: cbar = self._figure.colorbar(im, ax=self._axis[axidx]) _norm_val = kwargs_imshow["norm"] if not (isinstance(_norm_val, str) and _norm_val.lower() == "log"): # avoid AttributeError: 'LogFormatterSciNotation' object has no attribute 'set_powerlimits' cbar.formatter = ticker.ScalarFormatter(useMathText=True) cbar.formatter.set_scientific(True) cbar.formatter.set_powerlimits((0, 0)) cbar.update_ticks() if "BUNIT" in _header: lstr = u.Unit(_header["BUNIT"]).to_string("latex_inline") cbar.ax.set_ylabel(lstr, rotation=90) if kwargs_opts["contours"]: if kwargs_contour["levels"] is None: # Figure out some autolevels kwargs_contour["levels"] = self._autolevels(km, "log") # suppress warnings about unused keywords and potential error # about cmap not being None. Also norm being a string will cause an error # in matplotlib==3.3.1+ # @todo need a better solution for this, it is not scalable. for kx in [ "units", "image", "contours", "label", "title", "cmap", "aspect", "colorbar", "reset", "nrows", "ncols", "index", "yaxis_unit", "xaxis_unit", "norm", "legend", "figsize", "constrained_layout", "stretch", ]: kwargs_contour.pop(kx, None) warnings.simplefilter("ignore", category=UserWarning) contourset = self._axis[axidx].contour(x.value, y.value, km.data, **kwargs_contour) warnings.resetwarnings() if kwargs_opts["label"]: self._axis[axidx].clabel(contourset, contourset.levels, inline=True, fmt="%1.2e") if kwargs_opts["title"] is not None and not kwargs_opts["legend"]: self._axis[axidx].set_title(kwargs_opts["title"]) if measurements is None: mlen = 0 else: mlen = len(measurements) if len(kwargs_opts["meas_color"]) < mlen: raise ValueError( f"Number of measurement colors (meas_color keyword) must match number of measurements ({mlen})" ) if measurements is not None: lstyles = ["--", "-", "--"] # for serial calls to plot_no_wcs in an outside method (i.e. lineratioplot.overlay_all_ratios), # we need to keep track of the index for the measurement color, otherwise we always # select color index 0, resulting in all measurements contours having the same color. # this is a kluge but it's all I can think of right now. if "colorcounter" in kwargs: jj = kwargs["colorcounter"] else: jj = 0 for m in measurements: # for the case of colorcounter kluge len(m) will always be 1, so we don't # run into issues with incrementing of jj interfering with colorcounter. colors = [kwargs_opts["meas_color"][jj]] * mlen if kwargs_opts["shading"] != 0: self._axis[axidx].contourf( x.value, y.value, k.data, levels=m.levels, colors=colors, alpha=kwargs_opts["shading"] ) # Add extra call to plot contour because savefig("file.pdf") # gets zorder of shading vs. contour wrong and contour lines # don't show up. Only a bug when output is pdf. Harumph. # See https://github.com/mpound/pdrtpy/issues/23 self._axis[axidx].contour( x.value, y.value, k.data, levels=[m.levels[1]], colors=colors, alpha=kwargs_opts["shading"] ) else: self._axis[axidx].contour( x.value, y.value, k.data, levels=m.levels, linestyles=lstyles, colors=colors ) jj = jj + 1