# -*- coding: utf-8 -*- # pylint: disable=E1101, C0330, C0103 # E1101: Module X has no Y member # C0330: Wrong continued indentation # C0103: Invalid attribute/variable/method name """ polyobjects.py ============== This contains all of the PolyXXX objects used by :mod:`wx.lib.plot`. """ __docformat__ = "restructuredtext en" # Standard Library import time as _time import wx import warnings from collections import namedtuple # Third-Party try: import numpy as np except: msg = """ This module requires the NumPy module, which could not be imported. It probably is not installed (it's not part of the standard Python distribution). See the Numeric Python site (http://numpy.scipy.org) for information on downloading source or binaries.""" raise ImportError("NumPy not found.\n" + msg) # Package from .utils import pendingDeprecation from .utils import TempStyle from .utils import pairwise class PolyPoints(object): """ Base Class for lines and markers. :param points: The points to plot :type points: list of ``(x, y)`` pairs :param attr: Additional attributes :type attr: dict .. warning:: All methods are private. """ def __init__(self, points, attr): self._points = np.array(points).astype(np.float64) self._logscale = (False, False) self._absScale = (False, False) self._symlogscale = (False, False) self._pointSize = (1.0, 1.0) self.currentScale = (1, 1) self.currentShift = (0, 0) self.scaled = self.points self.attributes = {} self.attributes.update(self._attributes) for name, value in attr.items(): if name not in self._attributes.keys(): err_txt = "Style attribute incorrect. Should be one of {}" raise KeyError(err_txt.format(self._attributes.keys())) self.attributes[name] = value @property def logScale(self): """ A tuple of ``(x_axis_is_log10, y_axis_is_log10)`` booleans. If a value is ``True``, then that axis is plotted on a logarithmic base 10 scale. :getter: Returns the current value of logScale :setter: Sets the value of logScale :type: tuple of bool, length 2 :raises ValueError: when setting an invalid value """ return self._logscale @logScale.setter def logScale(self, logscale): if not isinstance(logscale, tuple) or len(logscale) != 2: raise ValueError("`logscale` must be a 2-tuple of bools") self._logscale = logscale def setLogScale(self, logscale): """ Set to change the axes to plot Log10(values) Value must be a tuple of booleans (x_axis_bool, y_axis_bool) .. deprecated:: Feb 27, 2016 Use the :attr:`~wx.lib.plot.polyobjects.PolyPoints.logScale` property instead. """ pendingDeprecation("self.logScale property") self._logscale = logscale @property def symLogScale(self): """ .. warning:: Not yet implemented. A tuple of ``(x_axis_is_SymLog10, y_axis_is_SymLog10)`` booleans. If a value is ``True``, then that axis is plotted on a symmetric logarithmic base 10 scale. A Symmetric Log10 scale means that values can be positive and negative. Any values less than :attr:`~wx.lig.plot.PolyPoints.symLogThresh` will be plotted on a linear scale to avoid the plot going to infinity near 0. :getter: Returns the current value of symLogScale :setter: Sets the value of symLogScale :type: tuple of bool, length 2 :raises ValueError: when setting an invalid value .. notes:: This is a simplified example of how SymLog works:: if x >= thresh: x = Log10(x) elif x =< thresh: x = -Log10(Abs(x)) else: x = x .. seealso:: + :attr:`~wx.lib.plot.PolyPoints.symLogThresh` + See http://matplotlib.org/examples/pylab_examples/symlog_demo.html for an example. """ return self._symlogscale # TODO: Implement symmetric log scale @symLogScale.setter def symLogScale(self, symlogscale, thresh): raise NotImplementedError("Symmetric Log Scale not yet implemented") if not isinstance(symlogscale, tuple) or len(symlogscale) != 2: raise ValueError("`symlogscale` must be a 2-tuple of bools") self._symlogscale = symlogscale @property def symLogThresh(self): """ .. warning:: Not yet implemented. A tuple of ``(x_thresh, y_thresh)`` floats that define where the plot changes to linear scale when using a symmetric log scale. :getter: Returns the current value of symLogThresh :setter: Sets the value of symLogThresh :type: tuple of float, length 2 :raises ValueError: when setting an invalid value .. notes:: This is a simplified example of how SymLog works:: if x >= thresh: x = Log10(x) elif x =< thresh: x = -Log10(Abs(x)) else: x = x .. seealso:: + :attr:`~wx.lib.plot.PolyPoints.symLogScale` + See http://matplotlib.org/examples/pylab_examples/symlog_demo.html for an example. """ return self._symlogscale # TODO: Implement symmetric log scale threshold @symLogThresh.setter def symLogThresh(self, symlogscale, thresh): raise NotImplementedError("Symmetric Log Scale not yet implemented") if not isinstance(symlogscale, tuple) or len(symlogscale) != 2: raise ValueError("`symlogscale` must be a 2-tuple of bools") self._symlogscale = symlogscale @property def absScale(self): """ A tuple of ``(x_axis_is_abs, y_axis_is_abs)`` booleans. If a value is ``True``, then that axis is plotted on an absolute value scale. :getter: Returns the current value of absScale :setter: Sets the value of absScale :type: tuple of bool, length 2 :raises ValueError: when setting an invalid value """ return self._absScale @absScale.setter def absScale(self, absscale): if not isinstance(absscale, tuple) and len(absscale) == 2: raise ValueError("`absscale` must be a 2-tuple of bools") self._absScale = absscale @property def points(self): """ Get or set the plotted points. :getter: Returns the current value of points, adjusting for the various scale options such as Log, Abs, or SymLog. :setter: Sets the value of points. :type: list of `(x, y)` pairs .. Note:: Only set unscaled points - do not perform the log, abs, or symlog adjustments yourself. """ data = np.array(self._points, copy=True) # need the copy # TODO: get rid of the # need for copy # work on X: if self.absScale[0]: data = self._abs(data, 0) if self.logScale[0]: data = self._log10(data, 0) if self.symLogScale[0]: # TODO: implement symLogScale # Should symLogScale override absScale? My vote is no. # Should symLogScale override logScale? My vote is yes. # - symLogScale could be a parameter passed to logScale... pass # work on Y: if self.absScale[1]: data = self._abs(data, 1) if self.logScale[1]: data = self._log10(data, 1) if self.symLogScale[1]: # TODO: implement symLogScale pass return data @points.setter def points(self, points): self._points = points def _log10(self, data, index): """ Take the Log10 of the data, dropping any negative values """ data = np.compress(data[:, index] > 0, data, 0) data[:, index] = np.log10(data[:, index]) return data def _abs(self, data, index): """ Take the Abs of the data """ data[:, index] = np.abs(data[:, index]) return data def boundingBox(self): """ Returns the bouding box for the entire dataset as a tuple with this format:: ((minX, minY), (maxX, maxY)) :returns: boundingbox :rtype: numpy array of ``[[minX, minY], [maxX, maxY]]`` """ if len(self.points) == 0: # no curves to draw # defaults to (-1,-1) and (1,1) but axis can be set in Draw minXY = np.array([-1.0, -1.0]) maxXY = np.array([1.0, 1.0]) else: minXY = np.minimum.reduce(self.points) maxXY = np.maximum.reduce(self.points) return minXY, maxXY def scaleAndShift(self, scale=(1, 1), shift=(0, 0)): """ Scales and shifts the data for plotting. :param scale: The values to scale the data by. :type scale: list of floats: ``[x_scale, y_scale]`` :param shift: The value to shift the data by. This should be in scaled units :type shift: list of floats: ``[x_shift, y_shift]`` :returns: None """ if len(self.points) == 0: # no curves to draw return # TODO: Can we remove the if statement alltogether? Does # scaleAndShift ever get called when the current value equals # the new value? # cast everything to list: some might be np.ndarray objects if (list(scale) != list(self.currentScale) or list(shift) != list(self.currentShift)): # update point scaling self.scaled = scale * self.points + shift self.currentScale = scale self.currentShift = shift # else unchanged use the current scaling def getLegend(self): return self.attributes['legend'] def getClosestPoint(self, pntXY, pointScaled=True): """ Returns the index of closest point on the curve, pointXY, scaledXY, distance x, y in user coords. if pointScaled == True, then based on screen coords if pointScaled == False, then based on user coords """ if pointScaled: # Using screen coords p = self.scaled pxy = self.currentScale * np.array(pntXY) + self.currentShift else: # Using user coords p = self.points pxy = np.array(pntXY) # determine distance for each point d = np.sqrt(np.add.reduce((p - pxy) ** 2, 1)) # sqrt(dx^2+dy^2) pntIndex = np.argmin(d) dist = d[pntIndex] return [pntIndex, self.points[pntIndex], self.scaled[pntIndex] / self._pointSize, dist] class PolyLine(PolyPoints): """ Creates PolyLine object :param points: The points that make up the line :type points: list of ``[x, y]`` values :param **attr: keyword attributes =========================== ============= ==================== Keyword and Default Description Type =========================== ============= ==================== ``colour='black'`` Line color :class:`wx.Colour` ``width=1`` Line width float ``style=wx.PENSTYLE_SOLID`` Line style :class:`wx.PenStyle` ``legend=''`` Legend string str ``drawstyle='line'`` see below str =========================== ============= ==================== ================== ================================================== Draw style Description ================== ================================================== ``'line'`` Draws an straight line between consecutive points ``'steps-pre'`` Draws a line down from point A and then right to point B ``'steps-post'`` Draws a line right from point A and then down to point B ``'steps-mid-x'`` Draws a line horizontally to half way between A and B, then draws a line vertically, then again horizontally to point B. ``'steps-mid-y'`` Draws a line vertically to half way between A and B, then draws a line horizonatally, then again vertically to point B. *Note: This typically does not look very good* ================== ================================================== .. warning:: All methods except ``__init__`` are private. """ _attributes = {'colour': 'black', 'width': 1, 'style': wx.PENSTYLE_SOLID, 'legend': '', 'drawstyle': 'line', } _drawstyles = ("line", "steps-pre", "steps-post", "steps-mid-x", "steps-mid-y") def __init__(self, points, **attr): PolyPoints.__init__(self, points, attr) def draw(self, dc, printerScale, coord=None): """ Draw the lines. :param dc: The DC to draw on. :type dc: :class:`wx.DC` :param printerScale: :type printerScale: float :param coord: The legend coordinate? :type coord: ??? """ colour = self.attributes['colour'] width = self.attributes['width'] * printerScale * self._pointSize[0] style = self.attributes['style'] drawstyle = self.attributes['drawstyle'] if not isinstance(colour, wx.Colour): colour = wx.Colour(colour) pen = wx.Pen(colour, width, style) pen.SetCap(wx.CAP_BUTT) dc.SetPen(pen) if coord is None: if len(self.scaled): # bugfix for Mac OS X for c1, c2 in zip(self.scaled, self.scaled[1:]): self._path(dc, c1, c2, drawstyle) else: dc.DrawLines(coord) # draw legend line def getSymExtent(self, printerScale): """ Get the Width and Height of the symbol. :param printerScale: :type printerScale: float """ h = self.attributes['width'] * printerScale * self._pointSize[0] w = 5 * h return (w, h) def _path(self, dc, coord1, coord2, drawstyle): """ Calculates the path from coord1 to coord 2 along X and Y :param dc: The DC to draw on. :type dc: :class:`wx.DC` :param coord1: The first coordinate in the coord pair :type coord1: list, length 2: ``[x, y]`` :param coord2: The second coordinate in the coord pair :type coord2: list, length 2: ``[x, y]`` :param drawstyle: The type of connector to use :type drawstyle: str """ if drawstyle == 'line': # Straight line between points. line = [coord1, coord2] elif drawstyle == 'steps-pre': # Up/down to next Y, then right to next X intermediate = [coord1[0], coord2[1]] line = [coord1, intermediate, coord2] elif drawstyle == 'steps-post': # Right to next X, then up/down to Y intermediate = [coord2[0], coord1[1]] line = [coord1, intermediate, coord2] elif drawstyle == 'steps-mid-x': # need 3 lines between points: right -> up/down -> right mid_x = ((coord2[0] - coord1[0]) / 2) + coord1[0] intermediate1 = [mid_x, coord1[1]] intermediate2 = [mid_x, coord2[1]] line = [coord1, intermediate1, intermediate2, coord2] elif drawstyle == 'steps-mid-y': # need 3 lines between points: up/down -> right -> up/down mid_y = ((coord2[1] - coord1[1]) / 2) + coord1[1] intermediate1 = [coord1[0], mid_y] intermediate2 = [coord2[0], mid_y] line = [coord1, intermediate1, intermediate2, coord2] else: err_txt = "Invalid drawstyle '{}'. Must be one of {}." raise ValueError(err_txt.format(drawstyle, self._drawstyles)) dc.DrawLines(line) class PolySpline(PolyLine): """ Creates PolySpline object :param points: The points that make up the spline :type points: list of ``[x, y]`` values :param **attr: keyword attributes =========================== ============= ==================== Keyword and Default Description Type =========================== ============= ==================== ``colour='black'`` Line color :class:`wx.Colour` ``width=1`` Line width float ``style=wx.PENSTYLE_SOLID`` Line style :class:`wx.PenStyle` ``legend=''`` Legend string str =========================== ============= ==================== .. warning:: All methods except ``__init__`` are private. """ _attributes = {'colour': 'black', 'width': 1, 'style': wx.PENSTYLE_SOLID, 'legend': ''} def __init__(self, points, **attr): PolyLine.__init__(self, points, **attr) def draw(self, dc, printerScale, coord=None): """ Draw the spline """ colour = self.attributes['colour'] width = self.attributes['width'] * printerScale * self._pointSize[0] style = self.attributes['style'] if not isinstance(colour, wx.Colour): colour = wx.Colour(colour) pen = wx.Pen(colour, width, style) pen.SetCap(wx.CAP_ROUND) dc.SetPen(pen) if coord is None: if len(self.scaled) >= 3: dc.DrawSpline(self.scaled) else: dc.DrawLines(coord) # draw legend line class PolyMarker(PolyPoints): """ Creates a PolyMarker object. :param points: The marker coordinates. :type points: list of ``[x, y]`` values :param **attr: keyword attributes ================================= ============= ==================== Keyword and Default Description Type ================================= ============= ==================== ``marker='circle'`` see below str ``size=2`` Marker size float ``colour='black'`` Outline color :class:`wx.Colour` ``width=1`` Outline width float ``style=wx.PENSTYLE_SOLID`` Outline style :class:`wx.PenStyle` ``fillcolour=colour`` fill color :class:`wx.Colour` ``fillstyle=wx.BRUSHSTYLE_SOLID`` fill style :class:`wx.BrushStyle` ``legend=''`` Legend string str ================================= ============= ==================== =================== ================================== Marker Description =================== ================================== ``'circle'`` A circle of diameter ``size`` ``'dot'`` A dot. Does not have a size. ``'square'`` A square with side length ``size`` ``'triangle'`` An upward-pointed triangle ``'triangle_down'`` A downward-pointed triangle ``'cross'`` An "X" shape ``'plus'`` A "+" shape =================== ================================== .. warning:: All methods except ``__init__`` are private. """ _attributes = {'colour': 'black', 'width': 1, 'size': 2, 'fillcolour': None, 'fillstyle': wx.BRUSHSTYLE_SOLID, 'marker': 'circle', 'legend': ''} def __init__(self, points, **attr): PolyPoints.__init__(self, points, attr) def draw(self, dc, printerScale, coord=None): """ Draw the points """ colour = self.attributes['colour'] width = self.attributes['width'] * printerScale * self._pointSize[0] size = self.attributes['size'] * printerScale * self._pointSize[0] fillcolour = self.attributes['fillcolour'] fillstyle = self.attributes['fillstyle'] marker = self.attributes['marker'] if colour and not isinstance(colour, wx.Colour): colour = wx.Colour(colour) if fillcolour and not isinstance(fillcolour, wx.Colour): fillcolour = wx.Colour(fillcolour) dc.SetPen(wx.Pen(colour, width)) if fillcolour: dc.SetBrush(wx.Brush(fillcolour, fillstyle)) else: dc.SetBrush(wx.Brush(colour, fillstyle)) if coord is None: if len(self.scaled): # bugfix for Mac OS X self._drawmarkers(dc, self.scaled, marker, size) else: self._drawmarkers(dc, coord, marker, size) # draw legend marker def getSymExtent(self, printerScale): """Width and Height of Marker""" s = 5 * self.attributes['size'] * printerScale * self._pointSize[0] return (s, s) def _drawmarkers(self, dc, coords, marker, size=1): f = getattr(self, "_{}".format(marker)) f(dc, coords, size) def _circle(self, dc, coords, size=1): fact = 2.5 * size wh = 5.0 * size rect = np.zeros((len(coords), 4), np.float) + [0.0, 0.0, wh, wh] rect[:, 0:2] = coords - [fact, fact] dc.DrawEllipseList(rect.astype(np.int32)) def _dot(self, dc, coords, size=1): dc.DrawPointList(coords) def _square(self, dc, coords, size=1): fact = 2.5 * size wh = 5.0 * size rect = np.zeros((len(coords), 4), np.float) + [0.0, 0.0, wh, wh] rect[:, 0:2] = coords - [fact, fact] dc.DrawRectangleList(rect.astype(np.int32)) def _triangle(self, dc, coords, size=1): shape = [(-2.5 * size, 1.44 * size), (2.5 * size, 1.44 * size), (0.0, -2.88 * size)] poly = np.repeat(coords, 3, 0) poly.shape = (len(coords), 3, 2) poly += shape dc.DrawPolygonList(poly.astype(np.int32)) def _triangle_down(self, dc, coords, size=1): shape = [(-2.5 * size, -1.44 * size), (2.5 * size, -1.44 * size), (0.0, 2.88 * size)] poly = np.repeat(coords, 3, 0) poly.shape = (len(coords), 3, 2) poly += shape dc.DrawPolygonList(poly.astype(np.int32)) def _cross(self, dc, coords, size=1): fact = 2.5 * size for f in [[-fact, -fact, fact, fact], [-fact, fact, fact, -fact]]: lines = np.concatenate((coords, coords), axis=1) + f dc.DrawLineList(lines.astype(np.int32)) def _plus(self, dc, coords, size=1): fact = 2.5 * size for f in [[-fact, 0, fact, 0], [0, -fact, 0, fact]]: lines = np.concatenate((coords, coords), axis=1) + f dc.DrawLineList(lines.astype(np.int32)) class PolyBarsBase(PolyPoints): """ Base class for PolyBars and PolyHistogram. .. warning:: All methods are private. """ _attributes = {'edgecolour': 'black', 'edgewidth': 2, 'edgestyle': wx.PENSTYLE_SOLID, 'legend': '', 'fillcolour': 'red', 'fillstyle': wx.BRUSHSTYLE_SOLID, 'barwidth': 1.0 } def __init__(self, points, attr): """ """ PolyPoints.__init__(self, points, attr) def _scaleAndShift(self, data, scale=(1, 1), shift=(0, 0)): """same as override method, but retuns a value.""" scaled = scale * data + shift return scaled def getSymExtent(self, printerScale): """Width and Height of Marker""" h = self.attributes['edgewidth'] * printerScale * self._pointSize[0] w = 5 * h return (w, h) def set_pen_and_brush(self, dc, printerScale): pencolour = self.attributes['edgecolour'] penwidth = (self.attributes['edgewidth'] * printerScale * self._pointSize[0]) penstyle = self.attributes['edgestyle'] fillcolour = self.attributes['fillcolour'] fillstyle = self.attributes['fillstyle'] if not isinstance(pencolour, wx.Colour): pencolour = wx.Colour(pencolour) pen = wx.Pen(pencolour, penwidth, penstyle) pen.SetCap(wx.CAP_BUTT) if not isinstance(fillcolour, wx.Colour): fillcolour = wx.Colour(fillcolour) brush = wx.Brush(fillcolour, fillstyle) dc.SetPen(pen) dc.SetBrush(brush) def scale_rect(self, rect): # Scale the points to the plot area scaled_rect = self._scaleAndShift(rect, self.currentScale, self.currentShift) # Convert to (left, top, width, height) for drawing wx_rect = [scaled_rect[0][0], # X (left) scaled_rect[0][1], # Y (top) scaled_rect[1][0] - scaled_rect[0][0], # Width scaled_rect[1][1] - scaled_rect[0][1]] # Height return wx_rect def draw(self, dc, printerScale, coord=None): pass class PolyBars(PolyBarsBase): """ Creates a PolyBars object. :param points: The data to plot. :type points: sequence of ``(center, height)`` points :param **attr: keyword attributes ================================= ============= ======================= Keyword and Default Description Type ================================= ============= ======================= ``barwidth=1.0`` bar width float or list of floats ``edgecolour='black'`` edge color :class:`wx.Colour` ``edgewidth=1`` edge width float ``edgestyle=wx.PENSTYLE_SOLID`` edge style :class:`wx.PenStyle` ``fillcolour='red'`` fill color :class:`wx.Colour` ``fillstyle=wx.BRUSHSTYLE_SOLID`` fill style :class:`wx.BrushStyle` ``legend=''`` legend string str ================================= ============= ======================= .. important:: If ``barwidth`` is a list of floats: + each bar will have a separate width + ``len(barwidth)`` must equal ``len(points)``. .. warning:: All methods except ``__init__`` are private. """ def __init__(self, points, **attr): PolyBarsBase.__init__(self, points, attr) def calc_rect(self, x, y, w): """ Calculate the rectangle for plotting. """ return self.scale_rect([[x - w / 2, y], # left, top [x + w / 2, 0]]) # right, bottom def draw(self, dc, printerScale, coord=None): """ Draw the bars """ self.set_pen_and_brush(dc, printerScale) barwidth = self.attributes['barwidth'] if coord is None: if isinstance(barwidth, (int, float)): # use a single width for all bars pts = ((x, y, barwidth) for x, y in self.points) elif isinstance(barwidth, (list, tuple)): # use a separate width for each bar if len(barwidth) != len(self.points): err_str = ("Barwidth ({} items) and Points ({} items) do " "not have the same length!") err_str = err_str.format(len(barwidth), len(self.points)) raise ValueError(err_str) pts = ((x, y, w) for (x, y), w in zip(self.points, barwidth)) else: # invalid attribute type err_str = ("Invalid type for 'barwidth'. Expected float, " "int, or list or tuple of (int or float). Got {}.") raise TypeError(err_str.format(type(barwidth))) rects = [self.calc_rect(x, y, w) for x, y, w in pts] dc.DrawRectangleList(rects) else: dc.DrawLines(coord) # draw legend line class PolyHistogram(PolyBarsBase): """ Creates a PolyHistogram object. :param hist: The histogram data. :type hist: sequence of ``y`` values that define the heights of the bars :param binspec: The bin specification. :type binspec: sequence of ``x`` values that define the edges of the bins :param **attr: keyword attributes ================================= ============= ======================= Keyword and Default Description Type ================================= ============= ======================= ``edgecolour='black'`` edge color :class:`wx.Colour` ``edgewidth=3`` edge width float ``edgestyle=wx.PENSTYLE_SOLID`` edge style :class:`wx.PenStyle` ``fillcolour='blue'`` fill color :class:`wx.Colour` ``fillstyle=wx.BRUSHSTYLE_SOLID`` fill style :class:`wx.BrushStyle` ``legend=''`` legend string str ================================= ============= ======================= .. tip:: Use ``np.histogram()`` to easily create your histogram parameters:: hist_data, binspec = np.histogram(data) hist_plot = PolyHistogram(hist_data, binspec) .. important:: ``len(binspec)`` must equal ``len(hist) + 1``. .. warning:: All methods except ``__init__`` are private. """ def __init__(self, hist, binspec, **attr): if len(binspec) != len(hist) + 1: raise ValueError("Len(binspec) must equal len(hist) + 1") self.hist = hist self.binspec = binspec # define the bins and center x locations self.bins = list(pairwise(self.binspec)) bar_center_x = (pair[0] + (pair[1] - pair[0])/2 for pair in self.bins) points = list(zip(bar_center_x, self.hist)) PolyBarsBase.__init__(self, points, attr) def calc_rect(self, y, low, high): """ Calculate the rectangle for plotting. """ return self.scale_rect([[low, y], # left, top [high, 0]]) # right, bottom def draw(self, dc, printerScale, coord=None): """ Draw the bars """ self.set_pen_and_brush(dc, printerScale) if coord is None: rects = [self.calc_rect(y, low, high) for y, (low, high) in zip(self.hist, self.bins)] dc.DrawRectangleList(rects) else: dc.DrawLines(coord) # draw legend line class PolyBoxPlot(PolyPoints): """ Creates a PolyBoxPlot object. :param data: Raw data to create a box plot from. :type data: sequence of int or float :param **attr: keyword attributes ================================= ============= ======================= Keyword and Default Description Type ================================= ============= ======================= ``colour='black'`` edge color :class:`wx.Colour` ``width=1`` edge width float ``style=wx.PENSTYLE_SOLID`` edge style :class:`wx.PenStyle` ``legend=''`` legend string str ================================= ============= ======================= .. note:: ``np.NaN`` and ``np.inf`` values are ignored. .. admonition:: TODO + [ ] Figure out a better way to get multiple box plots side-by-side (current method is a hack). + [ ] change the X axis to some labels. + [ ] Change getClosestPoint to only grab box plot items and outlers? Currently grabs every data point. + [ ] Add more customization such as Pens/Brushes, outlier shapes/size, and box width. + [ ] Figure out how I want to handle log-y: log data then calcBP? Or should I calc the BP first then the plot it on a log scale? """ _attributes = {'colour': 'black', 'width': 1, 'style': wx.PENSTYLE_SOLID, 'legend': '', } def __init__(self, points, **attr): # Set various attributes self.box_width = 0.5 # Determine the X position and create a 1d dataset. self.xpos = points[0, 0] points = points[:, 1] # Calculate the box plot points and the outliers self._bpdata = self.calcBpData(points) self._outliers = self.calcOutliers(points) points = np.concatenate((self._bpdata, self._outliers)) points = np.array([(self.xpos, x) for x in points]) # Create a jitter for the outliers self.jitter = (0.05 * np.random.random_sample(len(self._outliers)) + self.xpos - 0.025) # Init the parent class PolyPoints.__init__(self, points, attr) def _clean_data(self, data=None): """ Removes NaN and Inf from the data. """ if data is None: data = self.points # clean out NaN and infinity values. data = data[~np.isnan(data)] data = data[~np.isinf(data)] return data def boundingBox(self): """ Returns bounding box for the plot. Override method. """ xpos = self.xpos minXY = np.array([xpos - self.box_width / 2, self._bpdata.min * 0.95]) maxXY = np.array([xpos + self.box_width / 2, self._bpdata.max * 1.05]) return minXY, maxXY def getClosestPoint(self, pntXY, pointScaled=True): """ Returns the index of closest point on the curve, pointXY, scaledXY, distance x, y in user coords. Override method. if pointScaled == True, then based on screen coords if pointScaled == False, then based on user coords """ xpos = self.xpos # combine the outliers with the box plot data data_to_use = np.concatenate((self._bpdata, self._outliers)) data_to_use = np.array([(xpos, x) for x in data_to_use]) if pointScaled: # Use screen coords p = self.scaled pxy = self.currentScale * np.array(pntXY) + self.currentShift else: # Using user coords p = self._points pxy = np.array(pntXY) # determine distnace for each point d = np.sqrt(np.add.reduce((p - pxy) ** 2, 1)) # sqrt(dx^2+dy^2) pntIndex = np.argmin(d) dist = d[pntIndex] return [pntIndex, self.points[pntIndex], self.scaled[pntIndex] / self._pointSize, dist] def getSymExtent(self, printerScale): """Width and Height of Marker""" # TODO: does this need to be updated? h = self.attributes['width'] * printerScale * self._pointSize[0] w = 5 * h return (w, h) def calcBpData(self, data=None): """ Box plot points: Median (50%) 75% 25% low_whisker = lowest value that's >= (25% - (IQR * 1.5)) high_whisker = highest value that's <= 75% + (IQR * 1.5) outliers are outside of 1.5 * IQR Parameters ---------- data : array-like The data to plot Returns ------- bpdata : collections.namedtuple Descriptive statistics for data: (min_data, low_whisker, q25, median, q75, high_whisker, max_data) """ data = self._clean_data(data) min_data = float(np.min(data)) max_data = float(np.max(data)) q25 = float(np.percentile(data, 25)) q75 = float(np.percentile(data, 75)) iqr = q75 - q25 low_whisker = float(data[data >= q25 - 1.5 * iqr].min()) high_whisker = float(data[data <= q75 + 1.5 * iqr].max()) median = float(np.median(data)) BPData = namedtuple("bpdata", ("min", "low_whisker", "q25", "median", "q75", "high_whisker", "max")) bpdata = BPData(min_data, low_whisker, q25, median, q75, high_whisker, max_data) return bpdata def calcOutliers(self, data=None): """ Calculates the outliers. Must be called after calcBpData. """ data = self._clean_data(data) outliers = data outlier_bool = np.logical_or(outliers > self._bpdata.high_whisker, outliers < self._bpdata.low_whisker) outliers = outliers[outlier_bool] return outliers def _scaleAndShift(self, data, scale=(1, 1), shift=(0, 0)): """same as override method, but retuns a value.""" scaled = scale * data + shift return scaled @TempStyle('pen') def draw(self, dc, printerScale, coord=None): """ Draws a box plot on the DC. Notes ----- The following draw order is required: 1. First the whisker line 2. Then the IQR box 3. Lasly the median line. This is because + The whiskers are drawn as single line rather than two lines + The median line must be visable over the box if the box has a fill. Other than that, the draw order can be changed. """ self._draw_whisker(dc, printerScale) self._draw_iqr_box(dc, printerScale) self._draw_median(dc, printerScale) # median after box self._draw_whisker_ends(dc, printerScale) self._draw_outliers(dc, printerScale) @TempStyle('pen') def _draw_whisker(self, dc, printerScale): """Draws the whiskers as a single line""" xpos = self.xpos # We draw it as one line and then hide the middle part with # the IQR rectangle whisker_line = np.array([[xpos, self._bpdata.low_whisker], [xpos, self._bpdata.high_whisker]]) whisker_line = self._scaleAndShift(whisker_line, self.currentScale, self.currentShift) whisker_pen = wx.Pen(wx.BLACK, 2, wx.PENSTYLE_SOLID) whisker_pen.SetCap(wx.CAP_BUTT) dc.SetPen(whisker_pen) dc.DrawLines(whisker_line) @TempStyle('pen') def _draw_iqr_box(self, dc, printerScale): """Draws the Inner Quartile Range box""" xpos = self.xpos box_w = self.box_width iqr_box = [[xpos - box_w / 2, self._bpdata.q75], # left, top [xpos + box_w / 2, self._bpdata.q25]] # right, bottom # Scale it to the plot area iqr_box = self._scaleAndShift(iqr_box, self.currentScale, self.currentShift) # rectangles are drawn (left, top, width, height) so adjust iqr_box = [iqr_box[0][0], # X (left) iqr_box[0][1], # Y (top) iqr_box[1][0] - iqr_box[0][0], # Width iqr_box[1][1] - iqr_box[0][1]] # Height box_pen = wx.Pen(wx.BLACK, 3, wx.PENSTYLE_SOLID) box_brush = wx.Brush(wx.GREEN, wx.BRUSHSTYLE_SOLID) dc.SetPen(box_pen) dc.SetBrush(box_brush) dc.DrawRectangleList([iqr_box]) @TempStyle('pen') def _draw_median(self, dc, printerScale, coord=None): """Draws the median line""" xpos = self.xpos median_line = np.array( [[xpos - self.box_width / 2, self._bpdata.median], [xpos + self.box_width / 2, self._bpdata.median]] ) median_line = self._scaleAndShift(median_line, self.currentScale, self.currentShift) median_pen = wx.Pen(wx.BLACK, 4, wx.PENSTYLE_SOLID) median_pen.SetCap(wx.CAP_BUTT) dc.SetPen(median_pen) dc.DrawLines(median_line) @TempStyle('pen') def _draw_whisker_ends(self, dc, printerScale): """Draws the end caps of the whiskers""" xpos = self.xpos fence_top = np.array( [[xpos - self.box_width * 0.2, self._bpdata.high_whisker], [xpos + self.box_width * 0.2, self._bpdata.high_whisker]] ) fence_top = self._scaleAndShift(fence_top, self.currentScale, self.currentShift) fence_bottom = np.array( [[xpos - self.box_width * 0.2, self._bpdata.low_whisker], [xpos + self.box_width * 0.2, self._bpdata.low_whisker]] ) fence_bottom = self._scaleAndShift(fence_bottom, self.currentScale, self.currentShift) fence_pen = wx.Pen(wx.BLACK, 2, wx.PENSTYLE_SOLID) fence_pen.SetCap(wx.CAP_BUTT) dc.SetPen(fence_pen) dc.DrawLines(fence_top) dc.DrawLines(fence_bottom) @TempStyle('pen') def _draw_outliers(self, dc, printerScale): """Draws dots for the outliers""" # Set the pen outlier_pen = wx.Pen(wx.BLUE, 5, wx.PENSTYLE_SOLID) dc.SetPen(outlier_pen) outliers = self._outliers # Scale the data for plotting pt_data = np.array([self.jitter, outliers]).T pt_data = self._scaleAndShift(pt_data, self.currentScale, self.currentShift) # Draw the outliers size = 0.5 fact = 2.5 * size wh = 5.0 * size rect = np.zeros((len(pt_data), 4), np.float) + [0.0, 0.0, wh, wh] rect[:, 0:2] = pt_data - [fact, fact] dc.DrawRectangleList(rect.astype(np.int32)) class PlotGraphics(object): """ Creates a PlotGraphics object. :param objects: The Poly objects to plot. :type objects: list of :class:`~wx.lib.plot.PolyPoints` objects :param title: The title shown at the top of the graph. :type title: str :param xLabel: The x-axis label. :type xLabel: str :param yLabel: The y-axis label. :type yLabel: str .. warning:: All methods except ``__init__`` are private. """ def __init__(self, objects, title='', xLabel='', yLabel=''): if type(objects) not in [list, tuple]: raise TypeError("objects argument should be list or tuple") self.objects = objects self._title = title self._xLabel = xLabel self._yLabel = yLabel self._pointSize = (1.0, 1.0) @property def logScale(self): if len(self.objects) == 0: return return [obj.logScale for obj in self.objects] @logScale.setter def logScale(self, logscale): # XXX: error checking done by PolyPoints class # if not isinstance(logscale, tuple) and len(logscale) != 2: # raise TypeError("logscale must be a 2-tuple of bools") if len(self.objects) == 0: return for obj in self.objects: obj.logScale = logscale def setLogScale(self, logscale): """ Set the log scale boolean value. .. deprecated:: Feb 27, 2016 Use the :attr:`~wx.lib.plot.polyobjects.PlotGraphics.logScale` property instead. """ pendingDeprecation("self.logScale property") self.logScale = logscale @property def absScale(self): if len(self.objects) == 0: return return [obj.absScale for obj in self.objects] @absScale.setter def absScale(self, absscale): # XXX: error checking done by PolyPoints class # if not isinstance(absscale, tuple) and len(absscale) != 2: # raise TypeError("absscale must be a 2-tuple of bools") if len(self.objects) == 0: return for obj in self.objects: obj.absScale = absscale def boundingBox(self): p1, p2 = self.objects[0].boundingBox() for o in self.objects[1:]: p1o, p2o = o.boundingBox() p1 = np.minimum(p1, p1o) p2 = np.maximum(p2, p2o) return p1, p2 def scaleAndShift(self, scale=(1, 1), shift=(0, 0)): for o in self.objects: o.scaleAndShift(scale, shift) def setPrinterScale(self, scale): """ Thickens up lines and markers only for printing .. deprecated:: Feb 27, 2016 Use the :attr:`~wx.lib.plot.polyobjects.PlotGraphics.printerScale` property instead. """ pendingDeprecation("self.printerScale property") self.printerScale = scale def setXLabel(self, xLabel=''): """ Set the X axis label on the graph .. deprecated:: Feb 27, 2016 Use the :attr:`~wx.lib.plot.polyobjects.PlotGraphics.xLabel` property instead. """ pendingDeprecation("self.xLabel property") self.xLabel = xLabel def setYLabel(self, yLabel=''): """ Set the Y axis label on the graph .. deprecated:: Feb 27, 2016 Use the :attr:`~wx.lib.plot.polyobjects.PlotGraphics.yLabel` property instead. """ pendingDeprecation("self.yLabel property") self.yLabel = yLabel def setTitle(self, title=''): """ Set the title at the top of graph .. deprecated:: Feb 27, 2016 Use the :attr:`~wx.lib.plot.polyobjects.PlotGraphics.title` property instead. """ pendingDeprecation("self.title property") self.title = title def getXLabel(self): """ Get X axis label string .. deprecated:: Feb 27, 2016 Use the :attr:`~wx.lib.plot.polyobjects.PlotGraphics.xLabel` property instead. """ pendingDeprecation("self.xLabel property") return self.xLabel def getYLabel(self): """ Get Y axis label string .. deprecated:: Feb 27, 2016 Use the :attr:`~wx.lib.plot.polyobjects.PlotGraphics.yLabel` property instead. """ pendingDeprecation("self.yLabel property") return self.yLabel def getTitle(self, title=''): """ Get the title at the top of graph .. deprecated:: Feb 27, 2016 Use the :attr:`~wx.lib.plot.polyobjects.PlotGraphics.title` property instead. """ pendingDeprecation("self.title property") return self.title @property def printerScale(self): return self._printerScale @printerScale.setter def printerScale(self, scale): """Thickens up lines and markers only for printing""" self._printerScale = scale @property def xLabel(self): """Get the X axis label on the graph""" return self._xLabel @xLabel.setter def xLabel(self, text): self._xLabel = text @property def yLabel(self): """Get the Y axis label on the graph""" return self._yLabel @yLabel.setter def yLabel(self, text): self._yLabel = text @property def title(self): """Get the title at the top of graph""" return self._title @title.setter def title(self, text): self._title = text def draw(self, dc): for o in self.objects: # t=_time.perf_counter() # profile info o._pointSize = self._pointSize o.draw(dc, self._printerScale) # print(o, "time=", _time.perf_counter()-t) def getSymExtent(self, printerScale): """Get max width and height of lines and markers symbols for legend""" self.objects[0]._pointSize = self._pointSize symExt = self.objects[0].getSymExtent(printerScale) for o in self.objects[1:]: o._pointSize = self._pointSize oSymExt = o.getSymExtent(printerScale) symExt = np.maximum(symExt, oSymExt) return symExt def getLegendNames(self): """Returns list of legend names""" lst = [None] * len(self) for i in range(len(self)): lst[i] = self.objects[i].getLegend() return lst def __len__(self): return len(self.objects) def __getitem__(self, item): return self.objects[item] # ------------------------------------------------------------------------- # Used to layout the printer page class PlotPrintout(wx.Printout): """Controls how the plot is made in printing and previewing""" # Do not change method names in this class, # we have to override wx.Printout methods here! def __init__(self, graph): """graph is instance of plotCanvas to be printed or previewed""" wx.Printout.__init__(self) self.graph = graph def HasPage(self, page): if page == 1: return True else: return False def GetPageInfo(self): return (1, 1, 1, 1) # disable page numbers def OnPrintPage(self, page): dc = self.GetDC() # allows using floats for certain functions # print("PPI Printer",self.GetPPIPrinter()) # print("PPI Screen", self.GetPPIScreen()) # print("DC GetSize", dc.GetSize()) # print("GetPageSizePixels", self.GetPageSizePixels()) # Note PPIScreen does not give the correct number # Calulate everything for printer and then scale for preview PPIPrinter = self.GetPPIPrinter() # printer dots/inch (w,h) # PPIScreen= self.GetPPIScreen() # screen dots/inch (w,h) dcSize = dc.GetSize() # DC size if self.graph._antiAliasingEnabled and not isinstance(dc, wx.GCDC): try: dc = wx.GCDC(dc) except Exception: pass else: if self.graph._hiResEnabled: # high precision - each logical unit is 1/20 of a point dc.SetMapMode(wx.MM_TWIPS) pageSize = self.GetPageSizePixels() # page size in terms of pixcels clientDcSize = self.graph.GetClientSize() # find what the margins are (mm) pgSetupData = self.graph.pageSetupData margLeftSize, margTopSize = pgSetupData.GetMarginTopLeft() margRightSize, margBottomSize = pgSetupData.GetMarginBottomRight() # calculate offset and scale for dc pixLeft = margLeftSize * PPIPrinter[0] / 25.4 # mm*(dots/in)/(mm/in) pixRight = margRightSize * PPIPrinter[0] / 25.4 pixTop = margTopSize * PPIPrinter[1] / 25.4 pixBottom = margBottomSize * PPIPrinter[1] / 25.4 plotAreaW = pageSize[0] - (pixLeft + pixRight) plotAreaH = pageSize[1] - (pixTop + pixBottom) # ratio offset and scale to screen size if preview if self.IsPreview(): ratioW = float(dcSize[0]) / pageSize[0] ratioH = float(dcSize[1]) / pageSize[1] pixLeft *= ratioW pixTop *= ratioH plotAreaW *= ratioW plotAreaH *= ratioH # rescale plot to page or preview plot area self.graph._setSize(plotAreaW, plotAreaH) # Set offset and scale dc.SetDeviceOrigin(pixLeft, pixTop) # Thicken up pens and increase marker size for printing ratioW = float(plotAreaW) / clientDcSize[0] ratioH = float(plotAreaH) / clientDcSize[1] aveScale = (ratioW + ratioH) / 2 if self.graph._antiAliasingEnabled and not self.IsPreview(): scale = dc.GetUserScale() dc.SetUserScale(scale[0] / self.graph._pointSize[0], scale[1] / self.graph._pointSize[1]) self.graph._setPrinterScale(aveScale) # tickens up pens for printing self.graph._printDraw(dc) # rescale back to original self.graph._setSize() self.graph._setPrinterScale(1) self.graph.Redraw() # to get point label scale and shift correct return True