yampex : yampex.plot.py

#!/usr/bin/env python
# -*- coding: utf-8 -*-
#
# yampex:
# Yet Another Matplotlib Extension
#
# Copyright (C) 2017-2021 by Edwin A. Suominen,
# http://edsuom.com/yampex
#
# See edsuom.com for API documentation as well as information about
# Ed's background and other projects, software and otherwise.
# 
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the
# License. You may obtain a copy of the License at
# 
#   http://www.apache.org/licenses/LICENSE-2.0
# 
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an "AS
# IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
# express or implied. See the License for the specific language
# governing permissions and limitations under the License.

"""
You'll do everything with a L{Plotter} in context.

Keep the API for its L{OptsBase} base class handy, and maybe a copy of
its U{source<http://edsuom.com/yampex/yampex.options.py>}, to see all
the plotting options you can set.
"""

import weakref
import importlib

try:
    import screeninfo
except: screeninfo = None

import numpy as np

from yampex.textbox import TextBoxMaker
from yampex.options import Opts, OptsBase
from yampex.subplot import Subplotter
from yampex.scaling import Scaler
from yampex.adjust import Adjuster
from yampex.util import PLOTTER_NAMES, sub


class PlotterHolder(object):
    """
    L{Plotter} uses a class-wide instance of me to hold weak
    references to its instances.

    @see: L{Plotter.showAll}, which uses my weak references to show
        the Matplotlib figures for all instances of L{Plotter} and
        then remove them.
    """
    def __init__(self):
        """C{PlotterHolder()}"""
        self.pDict = weakref.WeakValueDictionary()

    def add(self, obj):
        """
        Adds the L{Plotter} instance (or anything, really, but plotters
        are what I was intended for) to my weak-reference registry.

        Returns an integer ID that you can use with a call to
        L{remove} and thus avoid having to keep a reference to the
        object yourself.
        """
        ID = id(obj)
        self.pDict[ID] = obj
        return ID

    def remove(self, ID):
        """
        Removes the L{Plotter} instance identified by the supplied I{ID}
        from my weak-reference registry.
        """
        if ID in self.pDict:
            del self.pDict[ID]

    def removeAll(self):
        """
        Removes all L{Plotter} instances from my weak-reference registry.
        """
        self.pDict.clear()
            
    def doForAll(self, methodName, *args, **kw):
        """
        Does the method named I{methodName}, with any args and kw, for
        each L{Plotter} instance I'm keeping track of.
        
        Returns C{True} if there was at least one object that successfully
        performed I{methodName}.
        """
        OK = False
        for ID in list(self.pDict.keys()):
            if ID in self.pDict:
                OK = True
                try:
                    getattr(self.pDict[ID], methodName)(*args, **kw)
                except: OK = False
        return OK


class Dims(object):
    """
    I store dimensions of things for each subplot. If my I{debug}
    class attribute is set C{True}, I print info about what's being
    set and get for debugging purposes.
    """
    debug = False

    def __init__(self):
        self.sp_dicts = {}

    def clear(self):
        """
        Clears out all my info.
        """
        if self.debug:
            print("DIMS cleared")
        self.sp_dicts.clear()

    def setDims(self, k, name, dims):
        """
        For subplot I{k} (index starts at zero), sets the X and Y
        dimensions of an object with the specified I{name} to the
        supplied sequence I{dims}.
        """
        dims = tuple(dims)
        if self.debug:
            print(sub("DIMS {:d}: {} <-- {}", k, name, dims))
        self.sp_dicts.setdefault(k, {})[name] = dims

    def getDims(self, k, name):
        """
        For subplot I{k}, returns the dimension of the object with the
        specified I{name} or C{None} if no such dimension has been
        set.
        """
        if k not in self.sp_dicts: return
        value = self.sp_dicts[k].get(name, None)
        if self.debug:
            print(sub("DIMS {:d}: {} -> {}", k, name, value))
        return value
        
    
class Plotter(OptsBase):
    """
    I provide a Matplotlib C{Figure} with one or more time-vector and
    XY subplots of Numpy vectors.

    Construct an instance of me with the total number of subplots (to
    be intelligently apportioned into one or more rows and columns)
    or, with two constructor arguments, the number of columns followed
    by the number of rows.

    With the I{filePath} keyword, you can specify the file path of a
    PNG file for me to create or overwrite with each call to L{show}.
    
    You can set the I{width} and I{height} of the Figure with
    constructor keywords, and (read-only) access them via my
    properties of the same names. Or set my I{figSize} attribute (in a
    subclass or with that constructor keyword) to a 2-sequence with
    figure width and height. The default width and height is just shy
    of the entire monitor size.

    The dimensions are in inches, converted to pixels at 100 DPI,
    unless they are both integers and either of them exceeds 75 (which
    would equate to a huge 7,500 pixels). In that case, they are
    considered to specify the pixel dimensions directly.

    Use the "Agg" backend by supplying the constructor keyword
    I{useAgg}. This works better for plotting to an image file, and is
    selected automatically if you supply a I{filePath} to the
    constructor. Be aware that, once selected, that backend will be
    used for all instances of me. If you're using the "Agg" backend,
    you should specify it the first time an instance is constructed.

    Setting the I{verbose} keyword C{True} puts out a bit of info
    about annotator positioning. Not for regular use.
    
    Any other keywords you supply to the constructor are supplied to
    the underlying Matplotlib plotting call for all
    subplots.

    Keep the API for L{OptsBase} handy, and maybe a copy of the
    U{source<http://edsuom.com/yampex/yampex.plot.py>}, to see all the
    plotting options you can set.

    @ivar dims: A dict (actually, a subclass of L{Dims}) of sub-dicts
        of the dimensions of various text objects, keyed first by
        subplot index then the object name.

    @ivar Nsp: The number of subplots defined thus far defined with
        calls to my instance.
    """
    ph = PlotterHolder()
    
    fc = None
    DPI = 100 # Don't change this, for reference only
    _settings = {'title', 'xlabel', 'ylabel'}
    figSize = None
    # Flag to indicate if using Agg rendererer (for generating PNG files)
    usingAgg = False
    # Show warnings? (Not for regular use.)
    verbose = False

    @classmethod
    def setupClass(cls, useAgg=False):
        """
        Called by each instance of me during instantiation. Sets a
        class-wide Matplotlib pyplot import the first time it's
        called.

        If any instance of me is using the Agg renderer, all instances
        will.
        """
        mpl = importlib.import_module("matplotlib")
        if useAgg and not cls.usingAgg:
            mpl.use('Agg')
            cls.usingAgg = True
        else:
            try:
                raise Exception(
                    "Neither GTK3Agg nor PyQt5Agg actually work consistently")
                mpl.use('GTK3Agg')
            except:
                try:
                    mpl.use('tkagg')
                except:
                    if verbose:
                        print("WARNING: Neither GTK3Agg nor tkagg available!")
        if not getattr(cls, 'plt', None):
            cls.plt = importlib.import_module("matplotlib.pyplot")

    @classmethod
    def showAll(cls):
        """
        Calls L{show} for the figures generated by all instances of me.
        """
        OK = cls.ph.doForAll('show', noShow=True)
        if OK: cls.plt.show()
        cls.ph.doForAll('clear')
        # They should all have removed themselves now, but what the
        # heck, clear it anyways
        cls.ph.removeAll()
            
    def __init__(self, *args, **kw):
        """
        Constructor possibilities (not including keywords, except I{Nc}):
        
        C{Plotter(Nc, Nr)}: Specify I{Nc} columns and I{Nr} rows.
        
        C{Plotter(N)}: Specify up to I{N} subplots in optimal
        arrangement of columns and rows.

        C{Plotter(N, Nc=x)}: Specify up to I{N} columns subplots with
        I{x} columns.
        
        C{Plotter(Nc, Nr, V)}: Specify I{Nc} columns and I{Nr} rows,
        with container object I{V}.

        C{Plotter(Nc, Nr, V)}: Specify up to I{N} subplots in optimal
        arrangement of columns and rows, with container object I{V}.

        C{Plotter(N, V, Nc=x)}: Specify up to I{N} columns subplots
        with I{x} columns, with container object I{V}.

        @keyword filePath: Specify the path of a PNG file to be
            created instead of a plot window being opened. (Implies
            I{useAgg}.)

        @keyword useAgg: Set C{True} to use the "Agg" backend, which
            works better for creating image files. If you're going to
            specify it for multiple plot images, do so the first time
            an instance of me is constructed.

        @keyword figSize: Set to a 2-sequence with figure width and
            height if not using the default, which is just shy of your
            entire monitor size. Dimensions are in inches, converted
            to pixels at 100 DPI, unless both are integers and either
            exceeds 75. Then they are considered to specify the pixel
            dimensions directly.

        @keyword width: Specify the figure width part of I{figSize}.
        
        @keyword height: Specify the figure height part of I{figSize}.

        @keyword h2: A single index, or a sequence or set containing
            one or more indices, of any rows (starting with 0 for the
            top row) that have twice the normal height. If an invalid
            index is included, an exception will be raised.
    
        @keyword w2: A single index, or a sequence or set containing
            one or more indices, of any columns (starting with 0 for
            the left column) that have twice the normal width. If an
            invalid index is included, an exception will be raised.
        """
        args, kw, N, self.Nc, self.Nr = self.parseArgs(*args, **kw)
        self.V = args[0] if args else None
        self.opts = Opts()
        self.filePath = kw.pop('filePath', None)
        if 'verbose' in kw: self.verbose = kw.pop('verbose')
        useAgg = bool(self.filePath) or kw.pop('useAgg', False)
        self.setupClass(useAgg=useAgg)
        figSize = kw.pop('figSize', self.figSize)
        if figSize is None:
            if useAgg or screeninfo is None:
                figSize = [10.0, 7.0]
            else:
                si = screeninfo.screeninfo.get_monitors()[0]
                figSize = [
                    float(x)/self.DPI for x in (si.width-80, si.height-80)]
        width = kw.pop('width', None)
        if width: figSize[0] = width
        height = kw.pop('height', None)
        if height: figSize[1] = height
        figSize = self._maybePixels(figSize)
        self.fig = self.plt.figure(figsize=figSize)
        self.figSize = figSize
        self.sp = Subplotter(
            self, N, self.Nc, self.Nr, kw.pop('h2', []), kw.pop('w2', []))
        # The ID is an integer, not a reference to anything
        self.ID = self.ph.add(self)
        self.kw = kw
        self.dims = Dims()
        self.xlabels = {}
        self.annotators = {}
        self.adj = Adjuster(self)
        self.reset()

    @staticmethod
    def parseArgs(*args, **kw):
        """
        Parse the supplied I{args} and I{kw} for a constructor call.

        Returns a 5-tuple with a revised args list and kw dict, the
        number of subplots, the number of columns, and the number of
        rows.
        """
        N = args[0]
        args = list(args[1:])
        Nc = kw.pop('Nc', None)
        if args and isinstance(args[0], int):
            # Nc, Nr specified
            if Nc:
                raise ValueError(
                    "You can't specify Nc as both a second arg and keyword")
            Nc = N
            Nr = args.pop(0)
            N = Nc*Nr
        else:
            # N specified
            Nc = Nc if Nc else 3 if N > 6 else 2 if N > 3 else 1
            Nr = int(np.ceil(float(N)/Nc))
        return args, kw, N, Nc, Nr
        
    def reset(self):
        """
        Clears everything out to start fresh.
        """
        self.dims.clear()
        self.xlabels.clear()
        self.annotators.clear()
        self._figTitle = None
        self.tbmTitle = None
        self._isSubplot = False
        self._universal_xlabel = False
        self._plotter = None
        self.Nsp = 0

    def __del__(self):
        """
        Safely ensures that I am removed from the class-wide I{ph}
        instance of L{PlotterHolder}.
        """
        # Only an integer is passed to the call
        self.ph.remove(self.ID)
        # No new references were created, nothing retained

    def _maybePixels(self, figSize):
        """
        Considers the supplied I{figSize} to be in pixels if both its
        elements are integers and at least one of them exceeds 75. In
        that case, scales it down by DPI.

        Returns the figSize in inches.
        """
        bigDim = False
        newFigSize = []
        for dim in figSize:
            if not isinstance(dim, int):
                # Not both integers, use original
                return figSize
            if dim > 75: bigDim = True
            # Convert from (presumed) pixels to Matplotlib's stupid inches
            newFigSize.append(float(dim)/self.DPI)
        # Use the converted dims unless neither was > 75
        return newFigSize if bigDim else figSize
    
    @property
    def width(self):
        """
        Figure width (inches).
        """
        return self.fig.get_figwidth()

    @property
    def height(self):
        """
        Figure height (inches).
        """
        return self.fig.get_figheight()
    
    def __getattr__(self, name):
        """
        You can access plotting methods and a given subplot's plotting
        options as attributes.

        If you request a plotting method, you'll get an instance of me
        with my I{_plotter} method set to I{name} first.
        """
        if name in PLOTTER_NAMES:
            self._plotter = name
            return self
        if name in self.opts:
            return self.opts[name]
        raise AttributeError(sub(
            "No plotting option or attribute '{}'", name))
        
    def __enter__(self):
        """
        Upon an outer context entry, sets up the first subplot with
        cleared axes, preserves a copy of my global options, and
        returns a reference to myself as a subplotting tool.
        """
        # TODO: Allow my instance to be context-called again
        #self.reset()
        self.sp.setup()
        self._isSubplot = True
        self.opts.newLocal()
        return self

    def __exit__(self, *args):
        """
        Upon completion of context, turns minor ticks and grid on if
        enabled for this subplot's axis, restores global (all
        subplots) options.

        If the Agg rendererer is not being used (for generating PNG
        files), also sets a hook to adjust the subplot spacings and
        annotation positions upon window resizing.

        The args are just placeholders for the three args that
        C{contextmanager} supplies at the end of context: I{exc_type},
        I{exc_val}, I{exc_tb}. (None are useful here.)
        
        @see: L{_doPlots}.
        """
        # Do the last (and perhaps only) call's plotting
        self._doPlots()
        self._isSubplot = False
        self.opts.goGlobal()
        if not self.usingAgg:
            self.fig.canvas.mpl_connect('resize_event', self.subplots_adjust)

    def start(self):
        """
        An alternative to the context-manager way of using me. Just call
        this method and a reference to myself as a subplotting tool
        will be returned.

        Call L{done} when finished, which is the same thing as exiting
        my subplotting context.
        """
        if self._isSubplot:
            raise Exception("You are already in a subplotting context!")
        return self.__enter__()

    def done(self):
        """
        Call this after a call to L{start} when done plotting. This is the
        alternative to the context-manager way of using me.

        B{Note}: If you don't call this to close out a plotting
        session with the alternative method, the last subplot will not
        get drawn!

        @see: L{start}, which gets called to start the
            alternative-method plotting session.
        """
        if not self._isSubplot:
            raise Exception("You are not in a subplotting context!")
        self.__exit__()
            
    def subplots_adjust(self, *args):
        """
        Adjusts spacings.
        """
        dimThing = args[0] if args else self.fig.get_window_extent()
        fWidth, fHeight = [getattr(dimThing, x) for x in ('width', 'height')]
        self.adj.updateFigSize(fWidth, fHeight)
        if self._figTitle:
            kw = {
                'm': 10,
                'fontsize': self.fontsize('title', 14),
                'alpha': 1.0,
                'fDims': (fWidth, fHeight),
            }
            ax = self.fig.get_axes()[0]
            if self.tbmTitle: self.tbmTitle.remove()
            self.tbmTitle = TextBoxMaker(self.fig, **kw)("N", self._figTitle)
            titleObj = self.tbmTitle.tList[0]
        else: self.tbmTitle = titleObj = None
        kw = self.adj(self._universal_xlabel, titleObj)
        try:
            self.fig.subplots_adjust(**kw)
        except ValueError as e:
            if self.verbose:
                print((sub(
                    "WARNING: ValueError '{}' doing subplots_adjust({})",
                    e.message, ", ".join(
                        [sub("{}={}", x, kw[x]) for x in kw]))))
        self.updateAnnotations()
    
    def updateAnnotations(self, annotator=None):
        """
        Updates the positions of all annotations in an already-drawn plot.

        When L{PlotHelper} calls this, it will supply the annotator
        for its subplot.
        """
        plt = self.plt
        updated = False
        if annotator is None:
            for annotator in self.annotators.values():
                if annotator.update():
                    updated = True
        elif annotator.update(): updated = True
        if updated:
            # This raises a warning with newer matplotlib
            #plt.pause(0.0001)
            plt.draw()
        
    def _doPlots(self):
        """
        This gets called by L{__call__} at the beginning of each call to
        my subplot-context instance, and by L{__exit__} when subplot
        context ends, to do all the plotting for the previous subplot.

        Adds minor ticks and a grid, depending on the subplot-specific
        options. Then calls L{Opts.newLocal} on my I{opts} to create a
        new set of local options.
        """
        ax = self.sp.ax
        if ax: ax.helper.doPlots()
        # Setting calls now use new local options
        self.opts.newLocal()
    
    def show(self, windowTitle=None, fh=None, filePath=None, noShow=False):
        """
        Call this to show the figure with suplots after the last call to
        my instance.
        
        If I have a non-C{None} I{fc} attribute (which must reference
        an instance of Qt's C{FigureCanvas}, then the FigureCanvas is
        drawn instead of PyPlot doing a window show.

        You can supply an open file-like object for PNG data to be
        written to (instead of a Matplotlib Figure being displayed)
        with the I{fh} keyword. (It's up to you to close the file
        object.)

        Or, with the I{filePath} keyword, you can specify the file
        path of a PNG file for me to create or overwrite. (That
        overrides any I{filePath} you set in the constructor.)
        """
        try:
            self.fig.tight_layout()
        except ValueError as e:
            if self.verbose:
                proto = "WARNING: ValueError '{}' doing tight_layout "+\
                        "on {:.5g} x {:.5g} figure"
                print((sub(proto, e.message, self.width, self.height)))
        self.subplots_adjust()
        # Calling plt.draw massively slows things down when generating
        # plot images on Rpi. And without it, the (un-annotated) plot
        # still updates!
        if False and self.annotators:
            # This is not actually run, see above comment
            self.plt.draw()
            for annotator in list(self.annotators.values()):
                if self.verbose: annotator.setVerbose()
                annotator.update()
        if fh is None:
            if not filePath:
                filePath = self.filePath
            if filePath:
                fh = open(filePath, 'wb+')
        if fh is None:
            self.plt.draw()
            if windowTitle: self.fig.canvas.set_window_title(windowTitle)
            if self.fc is not None: self.fc.draw()
            elif not noShow: self.plt.show()
        else:
            self.fig.savefig(fh, format='png')
            self.plt.close()
            if filePath is not None:
                # Only close a file handle I opened myself
                fh.close()
        if not noShow: self.clear()

    def clear(self):
        """
        Clears my figure with all annotators and artist
        dimensions. Removes my ID from the class-wide
        L{PlotterHolder}.
        """
        try:
            # This causes stupid errors with tkagg, so just wrap it in
            # try-except for now
            self.fig.clear()
        except: pass
        self.annotators.clear()
        self.dims.clear()
        self.ph.remove(self.ID)
            
    def xBounds(self, *args, **kw):
        """
        See L{Subplotter.xBounds}.
        """
        self.sp.xBounds(*args, **kw)
    
    def yBounds(self, *args, **kw):
        """
        See L{Subplotter.yBounds}.
        """
        self.sp.yBounds(*args, **kw)

    def fontsize(self, name, default=None):
        return self.opts['fontsizes'].get(name, default)

    def doKeywords(self, kVector, kw):
        """
        Applies line style/marker/color settings as keywords for this
        vector, except for options already set with keywords.
        
        Then applies plot keywords set via the set_plotKeyword call
        and then, with higher priority, those set via the constructor,
        if they don't conflict with explicitly set keywords to this
        call which takes highest priority.

        Returns the new kw dict.
        """
        kw = self.opts.kwModified(kVector, kw)
        for thisDict in (self.kw, self.plotKeywords):
            for name in thisDict:
                if name not in kw:
                    kw[name] = thisDict[name]
        return kw
    
    def doSettings(self, k):
        """
        Does C{set_XXX} calls on the C{Axes} object for the subplot at
        index I{k}.
        """
        def bbAdd(textObj):
            dims = self.adj.tsc.dims(textObj)
            self.dims.setDims(k, name, dims)

        for name in self._settings:
            value = self.opts[name]
            if not value: continue
            fontsize = self.fontsize(name, None)
            kw = {'size':fontsize} if fontsize else {}
            bbAdd(self.sp.set_(name, value, **kw))
            if name == 'xlabel':
                self.xlabels[k] = value
                continue
        settings = self.opts['settings']
        for name in settings:
            bbAdd(self.sp.set_(name, settings[name]))
    
    def __call__(self, *args, **kw):
        """
        In the next (perhaps first) subplot, or one whose index is
        specified with keyword I{k}, plots the second supplied vector
        (and any further ones) versus the first.

        If you supply a container object that houses vectors and
        provides access to them as items as the first argument, you
        can supply vector names instead of the vectors themselves. The
        container object must evaluate C{b in a} as C{True} if it
        contains a vector with I{b}, and must return the vector with
        C{a[b]}.
        
        Many options can be set via the methods in L{OptsBase},
        including a title, a list of plot markers and linestyles, and
        a list of legend entries for the plots with those keywords.

        Set I{useLabels} to C{True} to have annotation labels pointing
        to each plot line instead of a legend, with text taken from
        the legend list.

        You can override my default plotter by specifying the name of
        another one with the I{plotter} keyword, e.g.,
        C{plotter="step"}. But the usual way to do that is to call the
        corresponding method of my instance, e.g., C{sp.step(X, Y)}.
        
        Any other keywords you supply to this call are supplied to the
        underlying Matplotlib plotting call. (B{NOTE:} This is a
        change from previous versions of Yampex where keywords to this
        method were used to C{set_X} the axes, e.g., C{ylabel="foo"}
        results in a C{set_ylabel("foo")} command to the C{axes}
        object, for this subplot only. Use the new L{OptsBase.set}
        command instead.)

        Returns a L{SpecialAx} wrapper object for the C{Axes} object
        created for the plot.
        
        If you want to do everything with the next subplot on your
        own, bit by bit, and only want a reference to its C{Axes}
        object (still with special treatment via L{SpecialAx}) just
        call this with no args.

        For low-level Matplotlib operations, you can access the
        underlying C{Axes} object via the returned L{SpecialAx}
        object's I{ax} attribute. But none of its special features
        will apply to what you do that way.

        @keyword k: Set this to the integer index of the subplot you
            want the supplied vectors plotted in if not in sequence.
        
        @see: L{_doPlots}.
        """
        # Do plotting for the previous call (if any)
        self._doPlots()
        if 'plotter' not in kw:
            plotter = self._plotter
            self._plotter = None
        if plotter: kw.setdefault('plotter', plotter)
        k = kw.pop('k', None)
        ax = self.sp[k]
        ax.helper.addCall(args, kw)
        self.Nsp += 1
        return ax