Source code for pynbody.plot.util

"""
Utility functions for the plotting module

"""

import matplotlib.patches
import matplotlib.quiver
import matplotlib.transforms
import numpy as np
import scipy as sp
import scipy.signal
import scipy.sparse
from matplotlib import pyplot as plt

from ..analysis import cosmology


[docs] def add_redshift_axis(sim, labelzs=[0.0, 0.2, 0.5, 1.0, 1.5, 2.0, 3.0, 5.0]): """Add a top redshift axis to a plot with time on the x-axis Parameters ---------- sim : :class:`pynbody.snapshot.SimSnap` The simulation snapshot to which the plot refers labelzs : list List of redshifts at which to place labels """ old_axis = plt.gca() x0, x1 = plt.gca().get_xlim() pz = plt.twiny() times = cosmology.age(sim, labelzs, unit='Gyr') pz.set_xticks(times) pz.set_xticklabels([str(x) for x in labelzs]) pz.set_xlim(x0, x1) pz.set_xlabel('$z$') plt.sca(old_axis)
[docs] def fast_kde(x, y, kern_nx=None, kern_ny=None, gridsize=(100, 100), extents=None, nocorrelation=False, weights=None, norm = False, **kwargs): """Gaussian kernel density estimation (KDE) This function is typically several orders of magnitude faster than scipy.stats.kde.gaussian_kde for large (>1e7) numbers of points and produces an essentially identical result. Unlike the scipy original, however, it is limited to using a regular grid. Parameters ---------- x : array The x-coords of the input data points y : array The y-coords of the input data points kern_nx : float size (in units of x) of the kernel. If None, the size is determined automatically based on the Scott's factor. kern_ny : float size (in units of y) of the kernel. If None, the size is determined automatically based on the Scott's factor. gridsize : tuple Size of the output grid (default 100x100) extents : tuple Extents of the output grid as (xmin, xmax, ymin, ymax). Default: extent of input data nocorrelation : bool If True, the correlation between the x and y coords will be ignored when preforming the KDE. weights : array An array of the same shape as x & y that weighs each sample (x_i, y_i) by each value in weights (w_i). Defaults to an array of ones the same size as x & y. norm : bool If False, the output is only corrected for the kernel. If True, the result is normalized such that the integral over the area yields 1. Returns ------- A gridded 2D kernel density estimate of the input points. :Authors: Joe Kington :License: MIT License <http://www.opensource.org/licenses/mit-license.php> """ #---- Setup -------------------------------------------------------------- x, y = np.asarray(x), np.asarray(y) x, y = np.squeeze(x), np.squeeze(y) if x.size != y.size: raise ValueError('Input x & y arrays must be the same size!') nx, ny = gridsize n = x.size if weights is None: # Default: Weight all points equally weights = np.ones(n) else: weights = np.squeeze(np.asarray(weights)) if weights.size != x.size: raise ValueError('Input weights must be an array of the same size' ' as input x & y arrays!') # Default extents are the extent of the data if extents is None: xmin, xmax = x.min(), x.max() ymin, ymax = y.min(), y.max() else: xmin, xmax, ymin, ymax = list(map(float, extents)) dx = (xmax - xmin) / nx dy = (ymax - ymin) / ny #---- Preliminary Calculations ------------------------------------------- # First convert x & y over to pixel coordinates # (Avoiding np.digitize due to excessive memory usage!) xyi = np.vstack((x, y)).T xyi -= [xmin, ymin] xyi /= [dx, dy] xyi = np.floor(xyi, xyi).T # Next, make a 2D histogram of x & y # Avoiding np.histogram2d due to excessive memory usage with many points grid = sp.sparse.coo_matrix((weights, xyi), shape=(nx, ny)).toarray() # Calculate the covariance matrix (in pixel coords) cov = np.cov(xyi) if nocorrelation: cov[1, 0] = 0 cov[0, 1] = 0 # Scaling factor for bandwidth scotts_factor = np.power(n, -1.0 / 6) # For 2D #---- Make the gaussian kernel ------------------------------------------- # First, determine how big the kernel needs to be std_devs = np.diag(np.sqrt(cov)) if kern_nx is None or kern_ny is None: kern_nx, kern_ny = np.round(scotts_factor * 2 * np.pi * std_devs) else: kern_nx = np.round(kern_nx / dx) kern_ny = np.round(kern_ny / dy) # make sure the kernel size is odd, so that there is a center pixel kern_nx = int(kern_nx) + 1 if int(kern_nx) % 2 == 0 else int(kern_nx) kern_ny = int(kern_ny) + 1 if int(kern_ny) % 2 == 0 else int(kern_ny) # Determine the bandwidth to use for the gaussian kernel inv_cov = np.linalg.inv(cov * scotts_factor ** 2) # x & y (pixel) coords of the kernel grid, with <x,y> = <0,0> in center xx = np.arange(kern_nx, dtype=np.float64) - kern_nx / 2.0 + 0.5 yy = np.arange(kern_ny, dtype=np.float64) - kern_ny / 2.0 + 0.5 xx, yy = np.meshgrid(xx, yy) # Then evaluate the gaussian function on the kernel grid kernel = np.vstack((xx.flatten(), yy.flatten())) kernel = np.dot(inv_cov, kernel) * kernel kernel = np.sum(kernel, axis=0) / 2.0 kernel = np.exp(-kernel) kernel = kernel.reshape((int(kern_ny), int(kern_nx))) #---- Produce the kernel density estimate -------------------------------- # Convolve the gaussian kernel with the 2D histogram, producing a gaussian # kernel density estimate on a regular grid grid = sp.signal.convolve2d(grid, kernel, mode='same', boundary='fill').T # Normalization factor to divide result by so that units are in the same # units as scipy.stats.kde.gaussian_kde's output. norm_factor = 2 * np.pi * cov * scotts_factor ** 2 norm_factor = np.linalg.det(norm_factor) #norm_factor = n * dx * dy * np.sqrt(norm_factor) norm_factor = np.sqrt(norm_factor) if norm: norm_factor *= n * dx * dy # Normalize the result grid /= norm_factor return grid
def _val_or_rc(val, rc_key): """Return val if it is not None, otherwise return the value of rc_key in matplotlib.rcParams. This is available in some versions of matplotlib, but was added in mid-2023, so we should support older versions for now""" return val if val is not None else matplotlib.rcParams[rc_key]
[docs] class PynbodyQuiverKey(matplotlib.quiver.QuiverKey): """An improved version of matplotlib's QuiverKey, allowing a background color to be specified."""
[docs] def __init__(self, *args, **kwargs): """An improved quiver key implementation. In addition to the arguments of matplotlib.quiver.QuiverKey, additional parameters are below. Parameters ---------- boxfacecolor : str or None The background color of the key. If None, the default legend face color is used. boxedgecolor : str or None The edge color of the key. If None, the default legend edge color is used. fancybox : bool or None If True, the box is drawn with a fancy box style. If None, the default legend fancybox style is used. If False, a square-cornered box is drawn. *args: Additional arguments for matplotlib.quiver.QuiverKey **kwargs: Additional keyword arguments for matplotlib.quiver.QuiverKey """ self.boxfacecolor = _val_or_rc(kwargs.pop('boxfacecolor', None), 'legend.facecolor') self.boxedgecolor = _val_or_rc(kwargs.pop('boxedgecolor', None), 'legend.edgecolor') if self.boxfacecolor == 'inherit': self.boxfacecolor = matplotlib.rcParams['axes.facecolor'] if self.boxedgecolor == 'inherit': self.boxedgecolor = matplotlib.rcParams['axes.edgecolor'] self.fancybox = _val_or_rc(kwargs.pop("fancybox", None), 'legend.fancybox') super().__init__(*args, **kwargs)
[docs] def draw(self, renderer): super()._init() # the following duplication of bits of super(renderer).draw is necessary to get the # text bbox in the right place. Alternative is to actually call super(renderer).draw, # but then we end up having to draw twice so that the contents is above the background. pos = self.get_transform().transform((self.X, self.Y)) self.text.set_position(pos + self._text_shift()) if self.boxfacecolor is not None: figure_inverse_trans = self.figure.transFigure.inverted() # first find the bbox of the text, in figure coords bbox = self.text.get_window_extent(renderer) bbox = bbox.transformed(figure_inverse_trans) # now find a bbox for the arrow, which is a subtle/annoying thing because the offsets and vertices # are transformed differently arrow_offsets = self.get_transform().transform(self.vector.get_offsets()) arrow_vertices = self.Q.get_transform().transform(self.verts[0]) arrow_vertices_offset = arrow_offsets + arrow_vertices x0y0 = arrow_vertices_offset.min(axis=0) x1y1 = arrow_vertices_offset.max(axis=0) x0y0 = figure_inverse_trans.transform(x0y0) x1y1 = figure_inverse_trans.transform(x1y1) # expand bbox to include all arrow_vertices: bbox = matplotlib.transforms.Bbox.union([bbox, matplotlib.transforms.Bbox([x0y0, x1y1])]) # and, at last, we know the coordinates of the background that we need! boxstyle = ("round,pad=0.02,rounding_size=0.02" if self.fancybox else "square,pad=0.02") background = matplotlib.patches.FancyBboxPatch( bbox.min, bbox.width, bbox.height, boxstyle=boxstyle, fc=self.boxfacecolor, ec=self.boxedgecolor, transform=self.figure.transFigure) background.draw(renderer) super().draw(renderer)
def _test_quiverkey(scale=10.0, labelpos='E'): """A simple test for the PynbodyQuiverKey class.""" import matplotlib.pyplot as p import numpy as np X, Y = np.meshgrid(np.arange(0, 2 * np.pi, .2), np.arange(0, 2 * np.pi, .2)) U = np.cos(X) V = np.sin(Y) fig, ax = p.subplots() q = ax.quiver(X, Y, U, V) qk = PynbodyQuiverKey(q, .5, .95, scale, "Quiver key", labelpos=labelpos, boxfacecolor='w', boxedgecolor='k', fancybox=True) ax.add_artist(qk) qk.set_zorder(5)