Source code for sherpa.plot.bokeh_backend

#
#  Copyright (C) 2023, 2024
#  MIT
#
#
#  This program is free software; you can redistribute it and/or modify
#  it under the terms of the GNU General Public License as published by
#  the Free Software Foundation; either version 3 of the License, or
#  (at your option) any later version.
#
#  This program is distributed in the hope that it will be useful,
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#  GNU General Public License for more details.
#
#  You should have received a copy of the GNU General Public License along
#  with this program; if not, write to the Free Software Foundation, Inc.,
#  51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
#
'''
Plotting using bokeh.
'''

import logging
from collections import ChainMap
from functools import partialmethod
from itertools import cycle

import numpy as np

import bokeh
from bokeh.plotting import figure, show
from bokeh.models.scales import LinearScale, LogScale
from bokeh.models.annotations import Span
from bokeh.models import Whisker, Scatter
from bokeh.models import ColumnDataSource
from bokeh import embed
from bokeh.layouts import gridplot
from bokeh import palettes

from sherpa.utils.err import ArgumentErr, NotImplementedErr
from sherpa.utils import formatting
from sherpa.plot.utils import histogram_line

from sherpa.plot.backends import BasicBackend
from sherpa.plot.backends import kwargs_doc as orig_kwargs_doc
from sherpa.plot.backend_utils import (translate_args,
                                       add_kwargs_to_doc)

__all__ = ['BokehBackend', ]

logger = logging.getLogger(__name__)

# Most of the kwargs docstrings are still true, but some can be updated
# because bokeh does support a wider range than just the
# set of Sherpa backend-independent options.
updated_kwarg_docs = {
    'color': ['str or tuple', 'Any bokeh color'],
    'linecolor': ['string or tuple', 'Any bokeh color'],
    'marker': ['str',
               '''"None" (as a string, no marker shown), "" (empty string, no marker shown),
or any bokeh marker (see bokeh documentation).'''],
    'linestyle': ['str',
                  '''``'noline'``,
``'None'`` (as string, same as ``'noline'``),
``'solid'``, ``'dot'``, ``'dash'``, ``'dotdash'``, ``'-'`` (solid
line), ``':'`` (dotted), ``'--'`` (dashed), ``'-.'`` (dot-dashed),
``''`` (empty string, no line shown), `None` (default - usually
solid line) or any other bokeh linestyle.'''],
    'drawstyle': ['str', 'bokeh drawstyle'],
}

kwargs_doc = ChainMap(updated_kwarg_docs, orig_kwargs_doc)


[docs] class BokehBackend(BasicBackend): """Sherpa plotting backend for the bokeh package. :term:`bokeh` is a plotting library that generates a json representation of a plot, which is then rendered in a browser (either in a jupyter notebook or in a new tab for traditional python scripts). For the relatively simple cases supporting standard sherpa plotting commands, the json representation can be saved and displayed in a browser without the need for a running python kernel. Thus, plots like this can be embedded into webpages or as "interactive figures" in e.g. AAS journals. Notes ----- The sherpa plotting API is procedural and most of the commands act on the "current plot" or "current figure". This fits in very well with the `matplotlib.pyplot` model, but requires some extra work for object-oriented plotting packages. term:`bokeh` is such an object-oriented package, which, by itself, does not keep a plotting state. Usually, bokeh commands act on an axis object that is passed in as a parameter. Sherpa, on the other hand, does not keep track of those objects, it expects the plotting package to know what the "current" plot is. We solve this problem with attributes in the BokehBackend class: - current_fig: the current figure - current_axis: the current axis. This is a reference to one of the panels in the current figure. We follow a similar approach to default to cycling through colors like matplotlib does. The bokeh package does not have a similar mechanism, so we wrap `bokeh.plotting.figure` to add an additional attribute `_color_cycle` that is an `itertools.cycle` object cycling through the colors in the palette defined in the `palette` attribute of the BokehBackend class. """ translate_colors = { "r": "red", "g": "green", "b": "blue", "k": "black", "w": "white", "c": "cyan", "y": "yellow", "m": "magenta", } translate_dict = { "color": translate_colors, "ecolor": translate_colors, "markerfacecolor": translate_colors, "linecolor": translate_colors, "linestyle": { "None": "noline", None: "solid", "solid": "solid", "dot": "dotted", "dash": "dashed", "longdash": "dashed", "-": "solid", ":": "dotted", "--": "dashed", "-.": "dotdash", }, "linewidth": { None: 1, }, "drawstyle": { "steps": "before", "steps-pre": "before", "steps-mid": "center", "steps-post": "after", }, "marker": { #'None': 'circle', None: "", ".": "circle", "o": "circle", "+": "cross", "s": "square", }, "markersize": { None: 5, }, "alpha": { None: 1.0, }, "capsize": { None: 0, }, "linewidths": {None: 1}, "linestyles": {None: "solid"}, "colors": {None: "black"}, "aspect": {"auto": None, "equal": 1.0}, } '''Dict of keyword arguments that need to be translated for this backend. The keys in this dict are keyword arguments (e.g. ``'markerfacecolor'``) and the values are one of the following: - A dict where the keys are the backend-independent values and the values are the values expressed for this backend. For values not listed in the dict, no translation is done. - A callable. The callable translation function is called with any argument given and allows the backend arbitrary translations. It should, at the very least, accept all backend independent values for this parameter without error. In particular, sherpa uses None to mean "the default", while in bokeh None often means "don't show this", so None values are translated to a sensible default value (e.g. line thickness of 1) ''' palette = palettes.Category10[10] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.clear_window() def __exit__(self, exec_type, value, traceback): '''Called from the UI after an interactive plot is done.''' return False def _figure(self): fig = figure() fig._color_cycle = cycle(self.palette) return fig
[docs] def clear_window(self): fig = self._figure() self.current_fig = gridplot([fig], ncols=1) self.current_axis = fig
[docs] def setup_axes(self, overplot, clearwindow): """Return the axes object, creating it if necessary. Parameters ---------- overplot : bool clearwindow : bool Returns ------- axis The bokeh axes object. """ if not overplot and clearwindow: self.clear_window() return self.current_axis
[docs] @add_kwargs_to_doc(kwargs_doc) def setup_plot(self, axes, title=None, xlabel=None, ylabel=None, xlog=False, ylog=False): """Basic plot setup. Parameters ---------- axes The plot axes (output of setup_axes). {kwargs} """ axes.x_scale = LogScale() if xlog else LinearScale() axes.y_scale = LogScale() if ylog else LinearScale() # To-do: DO I need the "if" or does bokeh accept to set to None? if title: axes.title.text = title if xlabel: axes.xaxis.axis_label = xlabel if ylabel: axes.yaxis.axis_label = ylabel
[docs] @add_kwargs_to_doc(kwargs_doc) @translate_args def histo(self, xlo, xhi, y, *, yerr=None, title=None, xlabel=None, ylabel=None, overplot=False, clearwindow=True, xerrorbars=False, yerrorbars=False, ecolor=None, capsize=None, xlog=False, ylog=False, linestyle='None', drawstyle='default', color=None, alpha=None, marker='None', markerfacecolor=None, markersize=None, label=None, linewidth=None, linecolor=None, ): """Draw histogram data. The histogram is drawn as horizontal lines connecting the start and end points of each bin, with vertical lines connecting consecutive bins. Non-consecutive bins are drawn with a (NaN, NaN) between them so no line is drawn connecting them. Points are drawn at the middle of the bin, along with any error values. Note that the linecolor is not used, and is only included to support old code that may have set this option (use `color` instead). Parameters ---------- x0 : array-like or scalar number lower bin boundary values x1 : array-like or scalar number upper bin boundary values y : array-like or scalar number y values, same dimension as `x0`. {kwargs} """ if linecolor is not None: logger.warning("The linecolor attribute (%s) is unused.", linecolor) x, y2 = histogram_line(xlo, xhi, y) # Note: this handles clearing the plot if needed. # objs = self.plot(x, y2, yerr=None, xerr=None, title=title, xlabel=xlabel, ylabel=ylabel, overplot=overplot, clearwindow=clearwindow, xerrorbars=False, yerrorbars=False, ecolor=ecolor, capsize=capsize, xlog=xlog, ylog=ylog, linestyle=linestyle, linewidth=linewidth, drawstyle=drawstyle, color=color, marker=None, alpha=alpha, xaxis=False, ratioline=False) # Draw points and error bars at the mid-point of the bins. # Use the color used for the data plot: should it # also be used for marker[face]color and ecolor? # # NOTE: this ignores any xerr parameter. # xmid = 0.5 * (xlo + xhi) xerr = (xhi - xlo) / 2 if xerrorbars else None yerr = yerr if yerrorbars else None try: color = objs[0].get_color() except (AttributeError, IndexError): pass ebars = self.plot(xmid, y, # Plot error y bars if present xerrorbars=False, yerrorbars=True, yerr=yerr, xerr=xerr, overplot=True, clearwindow=False, color=color, alpha=alpha, linestyle='noline', marker=marker, markersize=markersize, markerfacecolor=markerfacecolor, ecolor=ecolor, capsize=capsize, )
# Note that this is an internal method that is not wrapped in # @ translate_args # This is called from methods like plot, histo, etc. # so the arguments passed to this method are already translated and we do # not want to apply the translation a second time. def _set_line(self, line, linecolor=None, linestyle=None, linewidth=None): """Apply the line attributes, if set. Parameters ---------- line : The line to change linecolor, linestyle, linewidth : optional The attribute value or None. """ if linecolor is not None: line.glyph.line_color = linecolor if linestyle is not None: line.glyph.line_dash = linestyle if linewidth is not None: line.glyph.line_width = linewidth # There is no support for alpha in the Plot.vline class
[docs] @add_kwargs_to_doc(kwargs_doc) @translate_args def vline(self, x, *, ymin=0, ymax=1, linecolor=None, linestyle=None, linewidth=None, overplot=False, clearwindow=True): """Draw a vertical line Parameters ---------- x : float x position of the vertical line in data units {kwargs} """ axes = self.setup_axes(overplot, clearwindow) line = Span(location=x, dimension='height', line_width=linewidth, line_color=linecolor, line_dash=linestyle) axes.add_layout(line)
# There is no support for alpha in the Plot.hline class
[docs] @add_kwargs_to_doc(kwargs_doc) @translate_args def hline(self, y, *, xmin=0, xmax=1, linecolor=None, linestyle=None, linewidth=None, overplot=False, clearwindow=True): """Draw a horizontal line Parameters ---------- y : float x position of the vertical line in data units {kwargs} """ axes = self.setup_axes(overplot, clearwindow) line = Span(location=y, dimension='width', line_width=linewidth, line_color=linecolor, line_dash=linestyle) axes.add_layout(line)
[docs] @add_kwargs_to_doc(kwargs_doc) @translate_args def plot(self, x, y, *, yerr=None, xerr=None, title=None, xlabel=None, ylabel=None, overplot=False, clearwindow=True, xerrorbars=False, yerrorbars=False, ecolor=None, capsize=0, xlog=False, ylog=False, linestyle='solid', drawstyle='default', color=None, marker='None', markerfacecolor=None, markersize=1, alpha=1, label=None, linewidth=1, linecolor=None, xaxis=None, ratioline=None, ): """Draw x, y data. This method combines a number of different ways to draw x/y data: - a line connecting the points - scatter plot of symbols - errorbars All three of them can be used together (symbols with errorbars connected by a line), but it is also possible to use only one or two of them. By default, a line is shown (``linestyle='solid'``), but marker and error bars are not (``marker='None'`` and ``xerrorbars=False`` as well as ``yerrorbars=False``). Note that the linecolor is not used, and is only included to support old code that may have set this option (use `color` instead). Parameters ---------- x : array-like or scalar number x values y : array-like or scalar number y values, same dimension as `x`. {kwargs} ratioline, xaxis : None These parameters are deprecated and not used any longer. """ axes = self.setup_axes(overplot, clearwindow) # Set up the axes if not overplot: self.setup_plot(axes, title, xlabel, ylabel, xlog=xlog, ylog=ylog) if color is None: color = next(axes._color_cycle) if linecolor is not None: logger.warning("The linecolor attribute, set to {}, is unused.".format(linecolor)) if markerfacecolor is None: markerfacecolor = color if ecolor is None: ecolor = color objs = [] kwargs = {} if label is not None: kwargs['legend_label'] = label if linestyle != 'noline': if drawstyle == 'default': objs.append(axes.line(x, y, line_dash=linestyle, line_color=color, line_width=linewidth, alpha=alpha, **kwargs )) else: objs.append(axes.steps(x, y, mode=drawstyle, line_dash=linestyle, line_color=color, alpha=alpha, **kwargs )) if marker not in ('None', ''): source = ColumnDataSource({'x': np.atleast_1d(x), 'y': np.atleast_1d(y)}) glyph = Scatter(marker=marker, line_alpha=alpha, fill_alpha=alpha, hatch_alpha=alpha, line_color=markerfacecolor, fill_color=markerfacecolor, hatch_color=markerfacecolor, size=markersize, *kwargs ) axes.add_glyph(source, glyph) objs.append(glyph) if xerrorbars and xerr is not None: source = ColumnDataSource({'x': np.atleast_1d(x), 'y': np.atleast_1d(y), 'lower': np.atleast_1d(x - xerr), 'upper': np.atleast_1d(x + xerr), }) xwhisker = Whisker( source=source, base='y', lower='lower', upper='upper', dimension='width', line_alpha=alpha, line_color=ecolor, *kwargs ) xwhisker.upper_head.size=capsize xwhisker.lower_head.size=capsize axes.add_layout(xwhisker) objs.append(xwhisker) if yerrorbars and yerr is not None: source = ColumnDataSource({'x': np.atleast_1d(x), 'y': np.atleast_1d(y), 'lower': np.atleast_1d(y - yerr), 'upper': np.atleast_1d(y + yerr), }) ywhisker = Whisker( source=source, base='x', lower='lower', upper='upper', dimension='height', line_alpha=alpha, line_color=ecolor, **kwargs ) ywhisker.upper_head.size=capsize ywhisker.lower_head.size=capsize axes.add_layout(ywhisker) objs.append(ywhisker) if xaxis: axes.axhline(y=0, xmin=0, xmax=1, color='k', linewidth=1) if ratioline: axes.axhline(y=1, xmin=0, xmax=1, color='k', linewidth=1) return objs
[docs] @add_kwargs_to_doc(kwargs_doc) @translate_args def contour(self, x0, x1, y, *, levels=None, title=None, xlabel=None, ylabel=None, overcontour=False, clearwindow=True, xlog=False, ylog=False, alpha=None, linewidths=None, linestyles='solid', colors=None, label=None, ): """Draw 2D contour data. Parameters ---------- x0 : array-like independent axis in the first dimenation (on regular grid, flattened) x1 : array-like independent axis in the second dimenation (on regular grid, flattened) y : array-like dependent axis (i.e. image values) (on regular grid, flattened) {kwargs} """ x0 = np.unique(x0) x1 = np.unique(x1) y = np.asarray(y) if x0.size * x1.size != y.size: raise NotImplementedErr('contourgrids') y = y.reshape(x1.size, x0.size) axes = self.setup_axes(overcontour, clearwindow) # Set up the axes if not overcontour: self.setup_plot(axes, title, xlabel, ylabel, xlog=xlog, ylog=ylog) if levels is None: levels = 7 if isinstance(levels, int): # Matplotlib has defaults for levels and sherpa can pass in "None" to mean # "use the default". Bokeh always requires to specify the levels, so # we have to define our own defaults here. # We try to pick something sensible, but not too complicated. levels = np.linspace(np.min(y), np.max(y), levels)[1: -1] axes.contour(x0, x1, y, levels, line_alpha=alpha, line_color=colors, line_width=linewidths)
[docs] @add_kwargs_to_doc(kwargs_doc) @translate_args def image(self, x0, x1, y, *, aspect=None, title=None, xlabel=None, ylabel=None, clearwindow=True, overplot=False, **kwargs): """Draw 2D image data. .. warning:: This function is experimental and is currently only used in the rish display code. Parameters ---------- x0 : array-like independent axis in the first dimension x1 : array-like independent axis in the second dimension y : array-like, with shape (len(x0), len(x1)) dependent axis (i.e. image values) in 2D with shape (len(x0), len(x1)) {kwargs} """ print(x0, x1, y, aspect, kwargs) axes = self.setup_axes(overplot, clearwindow) # Set up the axes if not overplot: self.setup_plot(axes, title, xlabel, ylabel) axes.image(image=[y], x=x0[0], y=x1[0], dw=x0[-1] - x0[0], dh=x1[-1] - x1[0], level='image', palette="Sunset11") axes.aspect_ratio = aspect
def _index_axis(self, row, col): '''Find index number of subplot in a grid of plots self.current_fig is a `bokeh.models.plots.GridPlot` object. It has a children attribute, which is a list of tuples, where each tuple is a (figure, row, col) tuple. This method returns the index of the tuple in the list that matches the given row and col or, if no such tuple exists, None. ''' for i, fig in enumerate(self.current_fig.children): if fig[1] == row and fig[2] == col: return i return None
[docs] def set_subplot(self, row, col, nrows, ncols, clearaxes=True, left=None, right=None, bottom=None, top=None, wspace=0.3, hspace=0.4): """Select a plot space in a grid of plots or create new grid This method adds a new subplot in a grid of plots. Parameters ---------- row, col : int index (starting at 0) of a subplot in a grid of plots nrows, ncols : int Number of rows and column in the plot grid clearaxes : bool If True, clear entire plotting area before adding the new subplot. """ index = self._index_axis(row, col) if index is not None and not clearaxes: # We found the right subplot, now make it the current one self.current_axis = self.current_fig.children[index] return # We found the subplot, but we want to replace it with a new one if index is not None and clearaxes: self.current_fig.children.pop(index) # At this stage, the subplot does not exist, so we make a new one # and add it to the grid. newf = self._figure() self.current_fig.children.append((newf, row, col)) self.current_axis = newf
[docs] def set_jointplot(self, row, col, nrows, ncols, create=True, top=0, ratio=2): """Move to the plot, creating them if necessary. Parameters ---------- row : int The row number, starting from 0. col : int The column number, starting from 0. nrows : int The number of rows. ncols : int The number of columns. create : bool, optional If True then create the plots top : int The row that is set to the ratio height, numbered from 0. ratio : float The ratio of the height of row number top to the other rows. """ # This function is written to work with an incomplete grid of plots, # so there is a whole lot of "if plotnum is not None" in here. # If we were to require that all grid are complete (i.e. a 3x2 grid) # always has 6 plots in a fixed order, the implementation would be # simpler. if create: figs = [self._figure() for n in range(nrows * ncols)] self.current_fig = gridplot(figs, ncols=ncols) self.current_axis = figs[row * ncols + col] # Space is dynamically allocated and lots of things can change the # height, e.g. a user may have changed bokeh defaults. # It's easy to change the height of a plot, but we first need to find # what height we consider "normal". # If we simply were to multiply the height of the top row by the ratio, # then the total height of the plot gets larger every time this method # is called, which, for ncols > 1, might be several times. # We pick the first plot in the grid that's not in row "top" and use # that as the "normal" height. That might fail for more complex grids, # but I declare that outside the scope of this function. height = None for fig in self.current_fig.children: if fig[1] != top: height = fig[0].height break # `height` is None if we only have one row, so we don't need to # change anything. if height is not None: for i in range(ncols): plotnum = self._index_axis(top, i) # We might have an incomplete grid of plots, and then # plotnum might be None. if plotnum is not None: self.current_fig.children[plotnum][0].height = height * ratio # Axis sharing for c in range(ncols): basenum = None for r in range(nrows): index = self._index_axis(0, c) if basenum is None: basenum = index else: self.current_fig.children[index][ 0 ].x_range = self.current_fig.children[basenum][0].x_range plotnum = self._index_axis(row, col) if plotnum is None: raise ArgumentErr(f"No plot at row {row} and column {col}." + "Use create=True to create an entirely new, " + "complete grid of plots.") from None self.current_axis = self.current_fig.children[plotnum][0]
[docs] def set_title(self, title: str) -> None: """Change the display title. Parameters ---------- title : str The title text to use. """ # It is not at all obvious how to do this since is it the # first component or all components or ? # plotnum = self._index_axis(0, 0) if plotnum is None: # Should this error out? return self.current_fig.children[plotnum][0].title.text = title
# HTML representation
[docs] def as_html(self, func): """Create HTML representation of a plot Parameters ---------- func : function The function, which takes no arguments, which will create the plot. It creates and returns the Figure. Returns ------- plot : str or None The HTML, or None if there was an error (e.g. prepare not called). """ try: func() except Exception as e: logger.debug("Unable to create bokeh plot: %s", str(e)) return None image = embed.file_html(self.current_fig) # See https://docs.bokeh.org/en/latest/docs/reference/embed.html#bokeh.embed.components # for a list of other js files that might be needed. # Also, do we want to use this offline instead of CDN? return f''' <script src="https://cdn.bokeh.org/bokeh/release/bokeh-{bokeh.__version__}.min.js"></script> ''' + image
[docs] def as_html_plot_or_contour(self, data, summary=None, plotting_func="plot"): """Create HTML representation of a plot Parameters ---------- data : Plot instance The plot object to display. It has already had its prepare method called. summary : str or None, optional The summary of the detail. If not set then the data type name is used. plotting_func : str, optional The function to call on the data object to make the plot. Returns ------- plot : str or None The HTML, or None if there was an error (e.g. prepare not called). """ image = self.as_html(getattr(data, plotting_func)) if image is None: return None if summary is None: summary = type(data).__name__ ls = [formatting.html_svg(image, summary)] return formatting.html_from_sections(data, ls)
as_html_plot = partialmethod(as_html_plot_or_contour, plotting_func="plot") as_html_contour = partialmethod(as_html_plot_or_contour, plotting_func="contour") as_html_image = as_html_plot as_html_histogram = as_html_plot as_html_pdf = as_html_plot as_html_cdf = as_html_plot as_html_lr = as_html_plot as_html_data = as_html_plot as_html_datacontour = as_html_contour as_html_model = as_html_plot as_html_modelcontour = as_html_contour as_html_fit = as_html_plot as_html_fitcontour = as_html_contour as_html_contour1d = as_html_plot as_html_contour2d = as_html_contour