def make_figure(width, height, nrows, ncols, interactive=None, **kwargs): """ Make a :class:`matplotlib.figure.Figure` and its axes. :param width: Width of the figure. :type width: int :param height: Height of the figure. :type height: int :param interactive: If ``True``, create an interactive figure. Defaults to ``True`` when running under IPython, ``False`` otherwise. :type interactive: bool or None :Variable keyword arguments: Forwarded to :class:`matplotlib.figure.Figure` :returns: A tuple of: * :class:`matplotlib.figure.Figure` * :class:`matplotlib.axes.Axes` as a scalar, an iterable (1D) or iterable of iterable matrix (2D) """ if interactive is None: interactive = is_running_ipython() if not interactive and tuple(map(int, mpl.__version__.split('.'))) <= (3, 0, 3): warnings.warn( 'This version of matplotlib does not allow saving figures from axis created using Figure(), forcing interactive=True' ) interactive = True width *= ncols height *= nrows if interactive: figure, axes = plt.subplots( figsize=(width, height), nrows=nrows, ncols=ncols, **kwargs, ) else: figure = Figure(figsize=(width, height)) axes = figure.subplots(ncols=ncols, nrows=nrows, **kwargs) return (figure, axes)
def setup_plot(cls, width=16, height=4, ncols=1, nrows=1, interactive=None, link_dataframes=None, cursor_delta=None, **kwargs): """ Common helper for setting up a matplotlib plot :param width: Width of the plot (inches) :type width: int or float :param height: Height of each subplot (inches) :type height: int or float :param ncols: Number of plots on a single row :type ncols: int :param nrows: Number of plots in a single column :type nrows: int :param link_dataframes: Link the provided dataframes to the axes using :func:`lisa.notebook.axis_link_dataframes` :type link_dataframes: list(pandas.DataFrame) or None :param cursor_delta: Add two vertical lines set with left and right clicks, and show the time delta between them in a widget. :type cursor_delta: bool or None :param interactive: If ``True``, use the pyplot API of matplotlib, which integrates well with notebooks. However, it can lead to memory leaks in scripts generating lots of plots, in which case it is better to use the non-interactive API. Defaults to ``True`` when running under IPython or Jupyter notebook, `False`` otherwise. :type interactive: bool :Keywords arguments: Extra arguments to pass to :obj:`matplotlib.figure.Figure.subplots` :returns: tuple(matplotlib.figure.Figure, matplotlib.axes.Axes (or an array of, if ``nrows`` > 1)) """ figure, axes = make_figure( interactive=interactive, width=width, height=height, ncols=ncols, nrows=nrows, **kwargs, ) if interactive is None: interactive = is_running_ipython() use_widgets = interactive if link_dataframes: if not use_widgets: cls.get_logger().error( 'Dataframes can only be linked to axes in interactive widget plots' ) else: for axis in figure.axes: axis_link_dataframes(axis, link_dataframes) if cursor_delta or cursor_delta is None and use_widgets: if not use_widgets and cursor_delta is not None: cls.get_logger().error( 'Cursor delta can only be used in interactive widget plots' ) else: for axis in figure.axes: axis_cursor_delta(axis) for axis in figure.axes: axis.relim(visible_only=True) axis.autoscale_view(True) # Needed for multirow plots to not overlap with each other figure.set_tight_layout(dict(h_pad=3.5)) return figure, axes
def wrapper(self, *args, filepath=None, axis=None, output=None, img_format=None, always_save=False, colors: TypedList[str] = None, linestyles: TypedList[str] = None, markers: TypedList[str] = None, rc_params=None, **kwargs): def is_f_param(param): """ Return True if the parameter is for `f`, False if it is for setup_plot() """ try: desc = inspect.signature(f).parameters[param] except KeyError: return False else: # Passing kwargs=42 to a function taking **kwargs # should not return True here, as we only consider # explicitly listed arguments return desc.kind not in ( inspect.Parameter.VAR_KEYWORD, inspect.Parameter.VAR_POSITIONAL, ) # Factor the *args inside the **kwargs by binding them to the # user-facing signature, which is the one of the wrapper. kwargs.update( inspect.signature(wrapper).bind_partial(self, *args).arguments) f_kwargs = { param: val for param, val in kwargs.items() if is_f_param(param) } img_format = img_format or guess_format(filepath) or 'png' local_fig = axis is None # When we create the figure ourselves, always save the plot to # the default location if local_fig and filepath is None and always_save: filepath = self.get_default_plot_path( img_format=img_format, plot_name=f.__name__, ) cyclers = dict( color=colors, linestyle=linestyles, marker=markers, ) cyclers = { name: value for name, value in cyclers.items() if value } if cyclers: cyclers = [ make_cycler(**{name: value}) for name, value in cyclers.items() ] set_cycler = lambda axis: cls.set_axis_cycler( axis, *cyclers) else: set_cycler = lambda axis: nullcontext() if rc_params: set_rc_params = lambda axis: cls.set_axis_rc_params( axis, rc_params) else: set_rc_params = lambda axis: nullcontext() # Allow returning an axis directly, or just update a given axis if return_axis: # In that case, the function takes all the kwargs with set_cycler(axis), set_rc_params(axis): axis = f(**kwargs, axis=axis) else: if local_fig: setup_plot_kwargs = { param: val for param, val in kwargs.items() if param not in f_kwargs } fig, axis = self.setup_plot(**setup_plot_kwargs) f_kwargs.update( axis=axis, local_fig=f_kwargs.get('local_fig', local_fig), ) with set_cycler(axis), set_rc_params(axis): f(**f_kwargs) if isinstance(axis, numpy.ndarray): fig = axis[0].get_figure() else: fig = axis.get_figure() def resolve_formatter(fmt): format_map = { 'rst': cls._get_rst_content, 'html': cls._get_html, } try: return format_map[fmt] except KeyError: raise ValueError(f'Unsupported format: {fmt}') if output is None: out = axis # Show the LISA figure toolbar if is_running_ipython(): # Make sure we only add one button per figure try: toolbar = self._get_fig_data(fig, 'toolbar') except KeyError: toolbar = self._make_fig_toolbar(fig) self._set_fig_data(fig, 'toolbar', toolbar) display(toolbar) mplcursors.cursor(fig) else: out = resolve_formatter(output)(f, [], f_kwargs, axis) if filepath: if img_format in ('html', 'rst'): content = resolve_formatter(img_format)(f, [], f_kwargs, axis) with open(filepath, 'wt', encoding='utf-8') as fd: fd.write(content) else: fig.savefig(filepath, format=img_format, bbox_inches='tight') return out
def setup_plot(cls, width=16, height=4, ncols=1, nrows=1, interactive=None, link_dataframes=None, cursor_delta=None, **kwargs): """ Common helper for setting up a matplotlib plot :param width: Width of the plot (inches) :type width: int or float :param height: Height of each subplot (inches) :type height: int or float :param ncols: Number of plots on a single row :type ncols: int :param nrows: Number of plots in a single column :type nrows: int :param link_dataframes: Link the provided dataframes to the axes using :func:`lisa.notebook.axis_link_dataframes` :type link_dataframes: list(pandas.DataFrame) or None :param cursor_delta: Add two vertical lines set with left and right clicks, and show the time delta between them in a widget. :type cursor_delta: bool or None :param interactive: If ``True``, use the pyplot API of matplotlib, which integrates well with notebooks. However, it can lead to memory leaks in scripts generating lots of plots, in which case it is better to use the non-interactive API. Defaults to ``True`` when running under IPython or Jupyter notebook, `False`` otherwise. :type interactive: bool :Keywords arguments: Extra arguments to pass to :obj:`matplotlib.figure.Figure.subplots` :returns: tuple(matplotlib.figure.Figure, matplotlib.axes.Axes (or an array of, if ``nrows`` > 1)) """ running_ipython = is_running_ipython() if interactive is None: interactive = running_ipython if tuple(map(int, matplotlib.__version__.split('.'))) <= (3, 0, 3): warnings.warn('This version of matplotlib does not allow saving figures from axis created using Figure(), forcing interactive=True') interactive = True if interactive: figure, axes = plt.subplots( ncols=ncols, nrows=nrows, figsize=(width, height * nrows), **kwargs ) else: figure = Figure(figsize=(width, height * nrows)) axes = figure.subplots(ncols=ncols, nrows=nrows, **kwargs) if isinstance(axes, Iterable): ax_list = axes else: ax_list = [axes] use_widgets = interactive and running_ipython if link_dataframes: if not use_widgets: cls.get_logger().error('Dataframes can only be linked to axes in interactive widget plots') else: for axis in ax_list: axis_link_dataframes(axis, link_dataframes) if cursor_delta or cursor_delta is None and use_widgets: if not use_widgets and cursor_delta is not None: cls.get_logger().error('Cursor delta can only be used in interactive widget plots') else: for axis in ax_list: axis_cursor_delta(axis) # Needed for multirow plots to not overlap with each other figure.set_tight_layout(dict(h_pad=3.5)) return figure, axes
def wrapper(self, *args, filepath=None, output='holoviews', img_format=None, always_save=False, backend=None, _compat_render=False, link_dataframes=None, cursor_delta=None, width=None, height=None, # Deprecated parameters rc_params=None, axis=None, interactive=None, colors: TypedList[str]=None, linestyles: TypedList[str]=None, markers: TypedList[str]=None, **kwargs ): def deprecation_warning(msg): warnings.warn( msg, DeprecationWarning, stacklevel=2, ) if interactive is not None: deprecation_warning( '"interactive" parameter is deprecated and ignored', ) interactive = is_running_ipython() # If the user did not specify a backend, we will return a # holoviews object, but we need to know what is the current # backend so we can apply the relevant options. if backend is None: backend = hv.Store.current_backend # For backward compat, return a matplotlib Figure when this # backend is selected if output is None and _compat_render and backend == 'matplotlib': output = 'render' # Before this point "None" indicates the default. if output is None: # TODO: Switch the default to be "ui" when interactive once a # solution is found for that issue: # https://discourse.holoviz.org/t/replace-holoviews-notebook-rendering-with-a-panel/2519/12 # output = 'ui' if interactive else 'holoviews' output = 'holoviews' # Deprecated, but allows easy backward compat if axis is not None: output = 'render' deprecation_warning( 'axis parameter is deprecated, use holoviews APIs to combine plots (see overloading of ``*`` operator for holoviews elements)' ) if link_dataframes and output != 'ui': warnings.warn(f'"link_dataframes" parameter ignored since output != "ui"', stacklevel=2) img_format = img_format or guess_format(filepath) or 'png' # When we create the figure ourselves, always save the plot to # the default location if filepath is None and always_save: filepath = self.get_default_plot_path( img_format=img_format, plot_name=f.__name__, ) # Factor the *args inside the **kwargs by binding them to the # user-facing signature, which is the one of the wrapper. kwargs.update( inspect.signature(wrapper).bind_partial(self, *args).arguments ) with lisa.notebook._hv_set_backend(backend): hv_fig = f(**kwargs) # For each element type, only set the option if it has not # been set already. This allows the plot method to give # customized options that will not be overridden here. set_by_method = {} for category in ('plot', 'style'): for name, _opts in hv_fig.traverse( lambda element: ( element.__class__.name, hv.Store.lookup_options( backend, element, category ).kwargs.keys() ) ): set_by_method.setdefault(name, set()).update(_opts) def set_options(fig, opts, typs): return fig.options( { typ: { k: v for k, v in opts.items() if k not in set_by_method.get(typ, tuple()) } for typ in typs }, # Specify the backend explicitly, in case the user # asked for a specific backend backend=backend, ) def set_option(fig, name, val, typs, extra=None): return set_options( fig=fig, opts={name: val, **(extra or {})}, typs=typs, ) def set_cycle(fig, name, xs, typs, extra=None): return set_option( fig=fig, name=name, val=hv.Cycle(xs), typs=typs, extra=extra, ) # Deprecated options if colors: deprecation_warning( '"colors" is deprecated and has no effect anymore, use .options() on the resulting holoviews object' ) if markers: deprecation_warning( '"markers" is deprecated and has no effect anymore, use .options() on the resulting holoviews object' ) if linestyles: deprecation_warning( '"linestyles" is deprecated and has no effect anymore, use .options() on the resulting holoviews object' ) if rc_params: deprecation_warning( 'rc_params deprecated, use holoviews APIs to set matplotlib parameters' ) if backend == 'matplotlib': hv_fig = hv_fig.opts(fig_rcparams=rc_params) else: self.logger.warning('rc_params is only used with matplotlib backend') # Markers added by lisa.notebook.plot_signal if backend == 'bokeh': marker_opts = dict( # Disable muted legend for now, as they will mute # everything: # https://github.com/holoviz/holoviews/issues/3936 # legend_muted=True, muted_alpha=0, tools=[], ) elif backend == 'matplotlib': # Hide the markers since it clutters static plots, making # them hard to read. marker_opts = dict( visible=False, ) else: marker_opts = {} hv_fig = set_options( hv_fig, opts=marker_opts, typs=('Scatter.marker',), ) # Tools if backend == 'bokeh': hv_fig = set_option( hv_fig, name='tools', val=[ 'undo', 'redo', 'crosshair', 'hover', ], typs=('Curve', 'Path', 'Points', 'Scatter', 'Bars', 'Histogram', 'Distribution', 'HeatMap', 'Image', 'Rectangles', 'Area', 'Spikes'), ).options( backend=backend, # Sometimes holoviews (or bokeh) decides to put it on # the side, which crops it toolbar='above', ) # Workaround: # https://github.com/holoviz/holoviews/issues/4981 hv_fig = set_option( hv_fig, name='color', val=hv.Cycle(), typs=('Rectangles',), ) # Figure size if backend in ('bokeh', 'plotly'): aspect = 4 if (width, height) == (None, None): size = dict( aspect=aspect, responsive=True, ) elif height is None: size = dict( width=width, height=int(width / aspect), ) elif width is None: size = dict( height=height, responsive=True, ) else: size = dict( width=width, height=height, ) hv_fig = set_options( hv_fig, opts=size, typs=('Curve', 'Path', 'Points', 'Scatter', 'Overlay', 'Bars', 'Histogram', 'Distribution', 'HeatMap', 'Image', 'Rectangles', 'Area', 'HLine', 'VLine', 'Spikes', 'HSpan', 'VSpan'), ) elif backend == 'matplotlib': width = 16 if width is None else width height = 4 if height is None else height fig_inches = max(width, height) hv_fig = set_options( hv_fig, opts=dict( aspect=width / height, fig_inches=fig_inches, ), typs=('Curve', 'Path', 'Points', 'Scatter', 'Overlay', 'Bars', 'Histogram', 'Distribution', 'HeatMap', 'Image', 'Rectangles', 'Area', 'HLine', 'VLine', 'Spikes'), ) # Not doing this on the Layout will prevent getting big # figures, but the "aspect" cannot be set on a Layout hv_fig = set_options( hv_fig, opts=dict(fig_inches=fig_inches), typs=('Layout',), ) # Use a memoized function to make sure we only do the rendering once @memoized def rendered_fig(): if backend == 'matplotlib': # Make sure to use an interactive renderer for notebooks, # otherwise the plot will not be displayed renderer = hv.plotting.mpl.MPLRenderer.instance( interactive=interactive ) return renderer.get_plot( hv_fig, interactive=interactive, axis=axis, fig=axis.figure if axis else None, ).state else: return hv.renderer(backend).get_plot(hv_fig).state def resolve_formatter(fmt): format_map = { 'rst': cls._get_rst_content, 'sphinx-rst': cls._get_rst_content, 'html': cls._get_html, 'sphinx-html': cls._get_html, } try: return format_map[fmt] except KeyError: raise ValueError(f'Unsupported format: {fmt}') if filepath: if backend in ('bokeh', 'matplotlib') and img_format in ('html', 'sphinx-html', 'rst', 'sphinx-rst'): content = resolve_formatter(img_format)( fmt=img_format, f=f, args=[], kwargs=kwargs, fig=rendered_fig(), backend=backend ) with open(filepath, 'wt', encoding='utf-8') as fd: fd.write(content) else: # Avoid cropping the legend on some backends static_fig = set_options( hv_fig, opts=dict(responsive=False), typs=('Curve', 'Path', 'Points', 'Scatter', 'Overlay', 'Bars', 'Histogram', 'Distribution', 'HeatMap', 'Image', 'Rectangles', 'HLine', 'VLine', 'VSpan', 'HSpan', 'Spikes'), ) hv.save(static_fig, filepath, fmt=img_format, backend=backend) if output == 'holoviews': out = hv_fig # Show the LISA figure toolbar elif output == 'ui': # TODO: improve holoviews so we can return holoviews # objects that are displayed with extra widgets around # https://discourse.holoviz.org/t/replace-holoviews-notebook-rendering-with-a-panel/2519/12 make_pane = functools.partial( self._make_fig_ui, link_dataframes=link_dataframes, ) out = _hv_fig_to_pane(hv_fig, make_pane) elif output == 'render': if _compat_render and backend == 'matplotlib': axes = rendered_fig().axes if len(axes) == 1: out = axes[0] else: out = axes else: out = rendered_fig() else: out = resolve_formatter(output)( fmt=output, f=f, args=[], kwargs=kwargs, fig=rendered_fig(), backend=backend ) return out
def wrapper(self, *args, filepath=None, axis=None, output=None, img_format=None, always_save=False, colors=None, **kwargs): # Bind the function to the instance, so we avoid having "self" # showing up in the signature, which breaks parameter # formatting code. f = func.__get__(self, type(self)) def is_f_param(param): """ Return True if the parameter is for `f`, False if it is for setup_plot() """ try: desc = inspect.signature(f).parameters[param] except KeyError: return False else: # Passing kwargs=42 to a function taking **kwargs # should not return True here, as we only consider # explicitely listed arguments return desc.kind not in ( inspect.Parameter.VAR_KEYWORD, inspect.Parameter.VAR_POSITIONAL, ) f_kwargs = { param: val for param, val in kwargs.items() if is_f_param(param) } img_format = img_format or guess_format(filepath) or 'png' local_fig = axis is None # When we create the figure ourselves, always save the plot to # the default location if local_fig and filepath is None and always_save: filepath = self.get_default_plot_path( img_format=img_format, plot_name=f.__name__, ) if colors: cycler = make_cycler(color=colors) set_cycler = lambda axis: cls.set_axis_cycler(axis, cycler) else: set_cycler = lambda axis: nullcontext() # Allow returning an axis directly, or just update a given axis if return_axis: # In that case, the function takes all the kwargs with set_cycler(axis): axis = f(*args, **kwargs, axis=axis) else: if local_fig: setup_plot_kwargs = { param: val for param, val in kwargs.items() if param not in f_kwargs } fig, axis = self.setup_plot(**setup_plot_kwargs) with set_cycler(axis): f(*args, axis=axis, local_fig=local_fig, **f_kwargs) if isinstance(axis, numpy.ndarray): fig = axis[0].get_figure() else: fig = axis.get_figure() def resolve_formatter(fmt): format_map = { 'rst': cls._get_rst_content, 'html': cls._get_html, } try: return format_map[fmt] except KeyError: raise ValueError('Unsupported format: {}'.format(fmt)) if output is None: out = axis # Show the LISA figure toolbar if is_running_ipython(): # Make sure we only add one button per figure try: toolbar = self._get_fig_data(fig, 'toolbar') except KeyError: toolbar = self._make_fig_toolbar(fig) self._set_fig_data(fig, 'toolbar', toolbar) display(toolbar) mplcursors.cursor(fig) else: out = resolve_formatter(output)(f, args, f_kwargs, axis) if filepath: if img_format in ('html', 'rst'): content = resolve_formatter(img_format)(f, args, f_kwargs, axis) with open(filepath, 'wt', encoding='utf-8') as fd: fd.write(content) else: fig.savefig(filepath, format=img_format, bbox_inches='tight') return out