From 0cc35806872c1b658a239046de34f9b18c8189dd Mon Sep 17 00:00:00 2001 From: Nils Nemitz Date: Thu, 7 Oct 2021 14:40:38 +0900 Subject: [PATCH] Traditional log10 mode for PlotDataItem (by adding "mapped data" stage) (#1992) * make PlotDataItem aware of mapped data * inf suppression, metadata storage and refactor of data structures * cleanup, test, and documentation pass * re-added prepareForPaint, added PlotDataset to sphinx index * strip more print statements * indicate (internal) PlotDataset documentation as orphaned to avoid sphinx error * Do not export PlotDataset * replacement example * example comments --- examples/logAxis.py | 44 +- pyqtgraph/functions.py | 7 +- pyqtgraph/graphicsItems/PlotDataItem.py | 850 +++++++++++---------- pyqtgraph/graphicsItems/ViewBox/ViewBox.py | 4 + tests/graphicsItems/test_PlotDataItem.py | 26 + 5 files changed, 504 insertions(+), 427 deletions(-) diff --git a/examples/logAxis.py b/examples/logAxis.py index 99945f4b..170d17a6 100644 --- a/examples/logAxis.py +++ b/examples/logAxis.py @@ -1,38 +1,44 @@ # -*- coding: utf-8 -*- """ -Test programmatically setting log transformation modes. +Demonstrate programmatic setting of log transformation modes. """ import initExample ## Add path to library (just for examples; you do not need this) import numpy as np -from pyqtgraph.Qt import QtGui, QtCore import pyqtgraph as pg - app = pg.mkQApp("Log Axis Example") w = pg.GraphicsLayoutWidget(show=True) -w.setWindowTitle('pyqtgraph example: logAxis') -p1 = w.addPlot(0,0, title="X Semilog") +w.resize(800,800) +w.setWindowTitle('pyqtgraph example: Log Axis, or How to Recognise Different Types of Curves from Quite a Long Way Away') + +p0 = w.addPlot(0,0, title="Linear") +p1 = w.addPlot(0,1, title="X Semilog") p2 = w.addPlot(1,0, title="Y Semilog") -p3 = w.addPlot(2,0, title="XY Log") -p1.showGrid(True, True) -p2.showGrid(True, True) -p3.showGrid(True, True) +p3 = w.addPlot(1,1, title="XY Log") +# configure logarithmic axis scaling: p1.setLogMode(True, False) p2.setLogMode(False, True) p3.setLogMode(True, True) + +# 1000 points from 0.1 to 10, chosen to give a compatible range of values across curves: +x = np.logspace(-1, 1, 1000) +plotdata = ( # legend entry, color, and plotted equation: + ('1 / 3x' , '#ff9d47', 1./(3*x) ), + ('sqrt x' , '#b3cf00', 1/np.sqrt(x) ), + ('exp. decay', '#00a0b5', 5 * np.exp(-x/1) ), + ('-log x' , '#a54dff', - np.log10(x) ) +) +p0.addLegend(offset=(-20,20)) # include legend only in top left plot +for p in (p0, p1, p2, p3): # draw identical numerical data in all four plots + p.showGrid(True, True) # turn on grid for all four plots + p.showAxes(True, size=(40,None)) # show a full frame, and reserve identical room for y labels + for name, color, y in plotdata: # draw all four curves as defined in plotdata + pen = pg.mkPen(color, width=2) + p.plot( x,y, pen=pen, name=name ) + w.show() -y = np.random.normal(size=1000) -x = np.linspace(0, 1, 1000) -p1.plot(x, y) -p2.plot(x, y) -p3.plot(x, y) - - - -#p.getAxis('bottom').setLogMode(True) - if __name__ == '__main__': pg.exec() diff --git a/pyqtgraph/functions.py b/pyqtgraph/functions.py index 29992352..65deaee8 100644 --- a/pyqtgraph/functions.py +++ b/pyqtgraph/functions.py @@ -1123,7 +1123,10 @@ def transformCoordinates(tr, coords, transpose=False): m = m[:, :-1] ## map coordinates and return - mapped = (m*coords).sum(axis=1) ## apply scale/rotate + # nan or inf points will not plot, but should not generate warnings + with warnings.catch_warnings(): + warnings.simplefilter("ignore", RuntimeWarning) + mapped = (m*coords).sum(axis=1) ## apply scale/rotate mapped += translate if transpose: @@ -2042,7 +2045,7 @@ def arrayToQPath(x, y, connect='all', finiteCheck=True): only values with 1 will connect to the previous point. Def finiteCheck : bool, default Ture When false, the check for finite values will be skipped, which can - improve performance. If finite values are present in `x` or `y`, + improve performance. If nonfinite values are present in `x` or `y`, an empty QPainterPath will be generated. Returns diff --git a/pyqtgraph/graphicsItems/PlotDataItem.py b/pyqtgraph/graphicsItems/PlotDataItem.py index 08cf55ad..eef2a231 100644 --- a/pyqtgraph/graphicsItems/PlotDataItem.py +++ b/pyqtgraph/graphicsItems/PlotDataItem.py @@ -1,6 +1,5 @@ # -*- coding: utf-8 -*- import warnings -import math import numpy as np from ..Qt import QtCore from .GraphicsObject import GraphicsObject @@ -12,16 +11,126 @@ from .. import getConfigOption __all__ = ['PlotDataItem'] +class PlotDataset(object): + """ + :orphan: + .. warning:: This class is intended for internal use. The interface may change without warning. + + Holds collected information for a plotable dataset. + Numpy arrays containing x and y coordinates are available as ``dataset.x`` and ``dataset.y``. + + After a search has been performed, typically during a call to :func:`dataRect() `, + ``dataset.containsNonfinite`` is `True` if any coordinate values are nonfinite (e.g. NaN or inf) or `False` if all + values are finite. If no search has been performed yet, ``dataset.containsNonfinite`` is `None`. + + For internal use in :class:`PlotDataItem `, this class should not be instantiated when no data is available. + """ + def __init__(self, x, y): + """ + Parameters + ---------- + x: array + x coordinates of data points. + y: array + y coordinates of data points. + """ + super().__init__() + self.x = x + self.y = y + self._dataRect = None + self.containsNonfinite = None + + def _updateDataRect(self): + """ + Finds bounds of plotable data and stores them as ``dataset._dataRect``, + stores information about the presence of nonfinite data points. + """ + if self.y is None or self.x is None: + return None + if self.containsNonfinite is False: # all points are directly usable. + ymin = np.min( self.y ) # find minimum of all values + ymax = np.max( self.y ) # find maximum of all values + xmin = np.min( self.x ) # find minimum of all values + xmax = np.max( self.x ) # find maximum of all values + else: # This may contain NaN values and infinites. + selection = np.isfinite(self.y) # We are looking for the bounds of the plottable data set. Infinite and Nan are ignored. + all_y_are_finite = selection.all() # False if all values are finite, True if there are any non-finites + try: + ymin = np.min( self.y[selection] ) # find minimum of all finite values + ymax = np.max( self.y[selection] ) # find maximum of all finite values + except ValueError: # is raised when there are no finite values + ymin = np.nan + ymax = np.nan + selection = np.isfinite(self.x) # We are looking for the bounds of the plottable data set. Infinite and Nan are ignored. + all_x_are_finite = selection.all() # False if all values are finite, True if there are any non-finites + try: + xmin = np.min( self.x[selection] ) # find minimum of all finite values + xmax = np.max( self.x[selection] ) # find maximum of all finite values + except ValueError: # is raised when there are no finite values + xmin = np.nan + xmax = np.nan + self.containsNonfinite = not (all_x_are_finite and all_y_are_finite) # This always yields a definite True/False answer + self._dataRect = QtCore.QRectF( QtCore.QPointF(xmin,ymin), QtCore.QPointF(xmax,ymax) ) + + def dataRect(self): + """ + Returns a bounding rectangle (as :class:`QtCore.QRectF`) for the finite subset of data. + If there is an active mapping function, such as logarithmic scaling, then bounds represent the mapped data. + Will return `None` if there is no data or if all values (`x` or `y`) are NaN. + """ + if self._dataRect is None: + self._updateDataRect() + return self._dataRect + + def applyLogMapping(self, logMode): + """ + Applies a logarithmic mapping transformation (base 10) if requested for the respective axis. + This replaces the internal data. Values of `-inf` resulting from zeros in the original dataset are + replaced by ``np.NaN``. + + Parameters + ---------- + logmode: tuple or list of two bool + A `True` value requests log-scale mapping for the x and y axis (in this order). + """ + all_x_finite = False + if logMode[0]: + with warnings.catch_warnings(): + warnings.simplefilter("ignore", RuntimeWarning) + self.x = np.log10(self.x) + nonfinites = ~np.isfinite( self.x ) + if nonfinites.any(): + self.x[nonfinites] = np.nan # set all non-finite values to NaN + self.containsNonfinite = True + else: + all_x_finite = True + all_y_finite = False + if logMode[1]: + with warnings.catch_warnings(): + warnings.simplefilter("ignore", RuntimeWarning) + self.y = np.log10(self.y) + nonfinites = ~np.isfinite( self.y ) + if nonfinites.any(): + self.y[nonfinites] = np.nan # set all non-finite values to NaN + self.containsNonfinite = True + else: + all_y_finite = True + if all_x_finite and all_y_finite: + self.containsNonfinite = False # mark as False only if both axes were checked. + class PlotDataItem(GraphicsObject): """ **Bases:** :class:`GraphicsObject ` - GraphicsItem for displaying plot curves, scatter plots, or both. + :class:`PlotDataItem` provides a unified interface for displaying plot curves, scatter plots, or both. + It also contains methods to transform or decimate the original data before it is displayed. + + As pyqtgraph's standard plotting object, ``plot()`` methods such as :func:`pyqtgraph.plot` and + :func:`PlotItem.plot() ` create instances of :class:`PlotDataItem`. + While it is possible to use :class:`PlotCurveItem ` or - :class:`ScatterPlotItem ` individually, this class - provides a unified interface to both. Instances of :class:`PlotDataItem` are - usually created by plot() methods such as :func:`pyqtgraph.plot` and - :func:`PlotItem.plot() `. + :class:`ScatterPlotItem ` individually, this is recommended only + where performance is critical and the limited functionality of these classes is sufficient. ================================== ============================================== **Signals:** @@ -39,127 +148,137 @@ class PlotDataItem(GraphicsObject): sigPointsClicked = QtCore.Signal(object, object, object) sigPointsHovered = QtCore.Signal(object, object, object) +# **(x,y data only)** + def __init__(self, *args, **kargs): """ - There are many different ways to create a PlotDataItem: + There are many different ways to create a PlotDataItem. **Data initialization arguments:** (x,y data only) - =================================== ====================================== - PlotDataItem(xValues, yValues) x and y values may be any sequence - (including ndarray) of real numbers - PlotDataItem(yValues) y values only -- x will be - automatically set to range(len(y)) - PlotDataItem(x=xValues, y=yValues) x and y given by keyword arguments - PlotDataItem(ndarray(Nx2)) numpy array with shape (N, 2) where - ``x=data[:,0]`` and ``y=data[:,1]`` - =================================== ====================================== + ========================== ========================================= + PlotDataItem(x, y) x, y: array_like coordinate values + PlotDataItem(y) y values only -- x will be + automatically set to ``range(len(y))`` + PlotDataItem(x=x, y=y) x and y given by keyword arguments + PlotDataItem(ndarray(N,2)) single numpy array with shape (N, 2), + where ``x=data[:,0]`` and ``y=data[:,1]`` + ========================== ========================================= **Data initialization arguments:** (x,y data AND may include spot style) - - ============================ ========================================= - PlotDataItem(recarray) numpy array with ``dtype=[('x', float), + ============================ =============================================== + PlotDataItem(recarray) numpy record array with ``dtype=[('x', float), ('y', float), ...]`` PlotDataItem(list-of-dicts) ``[{'x': x, 'y': y, ...}, ...]`` PlotDataItem(dict-of-lists) ``{'x': [...], 'y': [...], ...}`` - PlotDataItem(MetaArray) 1D array of Y values with X sepecified as - axis values OR 2D array with a column 'y' - and extra columns as needed. - ============================ ========================================= + ============================ =============================================== **Line style keyword arguments:** ============ ============================================================================== - connect Specifies how / whether vertexes should be connected. See - :func:`arrayToQPath() ` - pen Pen to use for drawing line between points. + connect Specifies how / whether vertexes should be connected. + pen Pen to use for drawing the lines between points. Default is solid grey, 1px width. Use None to disable line drawing. May be any single argument accepted by :func:`mkPen() ` - shadowPen Pen for secondary line to draw behind the primary line. disabled by default. + shadowPen Pen for secondary line to draw behind the primary line. Disabled by default. May be any single argument accepted by :func:`mkPen() ` - fillLevel Fill the area between the curve and fillLevel - + fillLevel If specified, the area between the curve and fillLevel is filled. fillOutline (bool) If True, an outline surrounding the *fillLevel* area is drawn. - fillBrush Fill to use when fillLevel is specified. - May be any single argument accepted by :func:`mkBrush() ` - stepMode (str or None) If "center", a step is drawn using the x - values as boundaries and the given y values are - associated to the mid-points between the boundaries of - each step. This is commonly used when drawing - histograms. Note that in this case, len(x) == len(y) + 1 - If "left" or "right", the step is drawn assuming that - the y value is associated to the left or right boundary, - respectively. In this case len(x) == len(y) - If not passed or an empty string or None is passed, the - step mode is not enabled. - Passing True is a deprecated equivalent to "center". - (added in version 0.9.9) + fillBrush Fill to use in the *fillLevel* area. May be any single argument accepted by + :func:`mkBrush() ` + stepMode (str or None) If specified and not None, a stepped curve is drawn. + For 'left' the specified points each describe the left edge of a step. + For 'right', they describe the right edge. + For 'center', the x coordinates specify the location of the step boundaries. + This mode is commonly used for histograms. Note that it requires an additional + x value, such that len(x) = len(y) + 1 . ============ ============================================================================== + `connect` supports the follwoing arguments: + + - 'all' connects all points. + - 'pairs' generates lines between every other point. + - 'finite' creates a break when a nonfinite points is encountered. + - If an ndarray is passed, it should contain `N` int32 values of 0 or 1. + Values of 1 indicate that the respective point will be connected to the next. + - In the default 'auto' mode, PlotDataItem will normally use 'all', but if any + nonfinite data points are detected, it will automatically switch to 'finite'. + + See :func:`arrayToQPath() ` for more details. + **Point style keyword arguments:** (see :func:`ScatterPlotItem.setData() ` for more information) - ============ ===================================================== - symbol Symbol to use for drawing points OR list of symbols, - one per point. Default is no symbol. - Options are o, s, t, d, +, or any QPainterPath - symbolPen Outline pen for drawing points OR list of pens, one - per point. May be any single argument accepted by - :func:`mkPen() ` - symbolBrush Brush for filling points OR list of brushes, one per - point. May be any single argument accepted by - :func:`mkBrush() ` - symbolSize Diameter of symbols OR list of diameters. - pxMode (bool) If True, then symbolSize is specified in - pixels. If False, then symbolSize is - specified in data coordinates. - ============ ===================================================== + ============ ====================================================== + symbol Symbol to use for drawing points, or a list of symbols + for each. The default is no symbol. + symbolPen Outline pen for drawing points, or a list of pens, one + per point. May be any single argument accepted by + :func:`mkPen() `. + symbolBrush Brush for filling points, or a list of brushes, one + per point. May be any single argument accepted by + :func:`mkBrush() `. + symbolSize Diameter of symbols, or list of diameters. + pxMode (bool) If True, then symbolSize is specified in + pixels. If False, then symbolSize is + specified in data coordinates. + ============ ====================================================== + + Any symbol recognized by :class:`PlotCurveItem ` can be specified, + including 'o' (circle), 's' (square), 't', 't1', 't2', 't3' (triangles of different orientation), + 'd' (diamond), '+' (plus sign), 'x' (x mark), 'p' (pentagon), 'h' (hexagon) and 'star'. + + Symbols can also be directly given in the form of a :class:`QtGui.QPainterPath` instance. **Optimization keyword arguments:** - ================= ===================================================================== + ================= ======================================================================= antialias (bool) By default, antialiasing is disabled to improve performance. - Note that in some cases (in particluar, when pxMode=True), points - will be rendered antialiased even if this is set to False. - decimate deprecated. - downsample (int) Reduce the number of samples displayed by this value + Note that in some cases (in particular, when `pxMode=True`), points + will be rendered antialiased even if this is set to `False`. + downsample (int) Reduce the number of samples displayed by the given factor. downsampleMethod 'subsample': Downsample by taking the first of N samples. This method is fastest and least accurate. 'mean': Downsample by taking the mean of N samples. 'peak': Downsample by drawing a saw wave that follows the min and max of the original data. This method produces the best visual representation of the data but is slower. - autoDownsample (bool) If True, resample the data before plotting to avoid plotting + autoDownsample (bool) If `True`, resample the data before plotting to avoid plotting multiple line segments per pixel. This can improve performance when viewing very high-density data, but increases the initial overhead and memory usage. - clipToView (bool) If True, only plot data that is visible within the X range of - the containing ViewBox. This can improve performance when plotting + clipToView (bool) If `True`, only data visible within the X range ofthe containing + :class:`ViewBox` is plotted. This can improve performance when plotting very large data sets where only a fraction of the data is visible at any time. - dynamicRangeLimit (float or None) Limit off-screen positions of data points at large - magnification to avoids display errors. Disabled if None. - skipFiniteCheck (bool) Optimization parameter that can speed up plot time by - telling the painter to not check and compensate for NaN - values. If set to True, and NaN values exist, the data - may not be displayed or your plot will take a - significant performance hit. Defaults to False. - identical *deprecated* - ================= ===================================================================== + dynamicRangeLimit (float or `None`) Limit off-screen y positions of data points. + `None` disables the limiting. This can increase performance but may + cause plots to disappear at high levels of magnification. + The default of 1e6 limits data to approximately 1,000,000 times the + :class:`ViewBox` height. + dynamicRangeHyst (float) Permits changes in vertical zoom up to the given hysteresis + factor (the default is 3.0) before the limit calculation is repeated. + skipFiniteCheck (bool) Optimization parameter that can speed up plotting by not + checking and compensating for NaN values. + If set to `True`, and NaN values exist, the data + may not be displayed or plot will take a + significant performance hit. Defaults to `False`. + ================= ======================================================================= **Meta-info keyword arguments:** ========== ================================================ - name name of dataset. This would appear in a legend + name (string) Name of item for use in the plot legend ========== ================================================ """ GraphicsObject.__init__(self) self.setFlag(self.GraphicsItemFlag.ItemHasNoContents) - self.xData = None - self.yData = None - self.xDisp = None - self.yDisp = None + # Original data, mapped data, and data processed for display is now all held in PlotDataset objects. + # The convention throughout PlotDataItem is that a PlotDataset is only instantiated if valid data is available. + self._dataset = None # will hold a PlotDataset for the original data + self._datasetMapped = None # will hold a PlotDataset for data after mapping transforms (e.g. log scale) + self._datasetDisplay = None # will hold a PlotDataset for data downsampled and limited for display self.curve = PlotCurveItem() self.scatter = ScatterPlotItem() self.curve.setParentItem(self) @@ -168,7 +287,7 @@ class PlotDataItem(GraphicsObject): self.curve.sigClicked.connect(self.curveClicked) self.scatter.sigClicked.connect(self.scatterClicked) self.scatter.sigHovered.connect(self.scatterHovered) - + # self._xViewRangeWasChanged = False # self._yViewRangeWasChanged = False # self._styleWasChanged = True # force initial update @@ -179,12 +298,10 @@ class PlotDataItem(GraphicsObject): self.setProperty('yViewRangeWasChanged', False) self.setProperty('styleWasChanged', True) # force initial update - self._dataRect = None self._drlLastClip = (0.0, 0.0) # holds last clipping points of dynamic range limiter #self.clear() self.opts = { - 'connect': 'all', - + 'connect': 'auto', # defaults to 'all', unless overridden to 'finite' for log-scaling 'fftMode': False, 'logMode': [False, False], 'derivativeMode': False, @@ -220,6 +337,17 @@ class PlotDataItem(GraphicsObject): } self.setCurveClickable(kargs.get('clickable', False)) self.setData(*args, **kargs) + + # Compatibility with direct property access to previous xData and yData structures: + @property + def xData(self): + if self._dataset is None: return None + return self._dataset.x + + @property + def yData(self): + if self._dataset is None: return None + return self._dataset.y def implements(self, interface=None): ints = ['plotData'] @@ -228,12 +356,15 @@ class PlotDataItem(GraphicsObject): return interface in ints def name(self): + """ Returns the name that represents this item in the legend. """ return self.opts.get('name', None) - def setCurveClickable(self, s, width=None): - self.curve.setClickable(s, width) + def setCurveClickable(self, state, width=None): + """ ``state=True`` sets the curve to be clickable, with a tolerance margin represented by `width`. """ + self.curve.setClickable(state, width) def curveClickable(self): + """ Returns `True` if the curve is set to be clickable. """ return self.curve.clickable def boundingRect(self): @@ -254,59 +385,80 @@ class PlotDataItem(GraphicsObject): self.setOpacity(alpha) #self.update() - def setFftMode(self, mode): - if self.opts['fftMode'] == mode: - return - self.opts['fftMode'] = mode - self.xDisp = self.yDisp = None - self.updateItems(styleUpdate=False) - self.informViewBoundsChanged() - - def setLogMode(self, xMode, yMode): + def setFftMode(self, state): """ - To enable log scaling for y<0 and y>0, the following formula is used: - - scaled = sign(y) * log10(abs(y) + eps) - - where eps is the smallest unit of y.dtype. - This allows for handling of 0. values, scaling of large values, - as well as the typical log scaling of values in the range -1 < x < 1. - Note that for values within this range, the signs are inverted. + ``state = True`` enables mapping the data by a fast Fourier transform. + If the `x` values are not equidistant, the data set is resampled at + equal intervals. """ - if self.opts['logMode'] == [xMode, yMode]: + if self.opts['fftMode'] == state: return - self.opts['logMode'] = [xMode, yMode] - self.xDisp = self.yDisp = None + self.opts['fftMode'] = state + self._datasetMapped = None + self._datasetDisplay = None self.updateItems(styleUpdate=False) self.informViewBoundsChanged() - - def setDerivativeMode(self, mode): - if self.opts['derivativeMode'] == mode: + def setLogMode(self, xState, yState): + """ + When log mode is enabled for the respective axis by setting `xState` or + `yState` to `True, a mapping according to ``mapped = np.log10( value )`` + is applied to the data. For negative or zero values, this results in a + NaN value. + """ + if self.opts['logMode'] == [xState, yState]: return - self.opts['derivativeMode'] = mode - self.xDisp = self.yDisp = None + self.opts['logMode'] = [xState, yState] + self._datasetMapped = None # invalidata mapped data + self._datasetDisplay = None # invalidate display data self.updateItems(styleUpdate=False) self.informViewBoundsChanged() - def setPhasemapMode(self, mode): - if self.opts['phasemapMode'] == mode: + def setDerivativeMode(self, state): + """ + ``state = True`` enables derivative mode, where a mapping according to + ``y_mapped = dy / dx`` is applied, with `dx` and `dy` representing the + differences between adjacent `x` and `y` values. + """ + if self.opts['derivativeMode'] == state: return - self.opts['phasemapMode'] = mode - self.xDisp = self.yDisp = None + self.opts['derivativeMode'] = state + self._datasetMapped = None # invalidata mapped data + self._datasetDisplay = None # invalidate display data self.updateItems(styleUpdate=False) self.informViewBoundsChanged() - def setPointMode(self, mode): - if self.opts['pointMode'] == mode: + def setPhasemapMode(self, state): + """ + ``state = True`` enables phase map mode, where a mapping + according to ``x_mappped = y`` and ``y_mapped = dy / dx`` + is applied, plotting the numerical derivative of the data over the + original `y` values. + """ + if self.opts['phasemapMode'] == state: return - self.opts['pointMode'] = mode + self.opts['phasemapMode'] = state + self._datasetMapped = None # invalidata mapped data + self._datasetDisplay = None # invalidate display data + self.updateItems(styleUpdate=False) + self.informViewBoundsChanged() + + def setPointMode(self, state): + # This does not seem to do anything, but PlotItem still seems to call it. + # warnings.warn( + # 'setPointMode has been deprecated, and has no effect. It will be removed from the library in the first release following April, 2022.', + # DeprecationWarning, stacklevel=2 + # ) + if self.opts['pointMode'] == state: + return + self.opts['pointMode'] = state self.update() def setPen(self, *args, **kargs): """ - | Sets the pen used to draw lines between points. - | *pen* can be a QPen or any argument accepted by :func:`pyqtgraph.mkPen() ` + Sets the pen used to draw lines between points. + The argument can be a :class:`QtGui.QPen` or any combination of arguments accepted by + :func:`pyqtgraph.mkPen() `. """ pen = fn.mkPen(*args, **kargs) self.opts['pen'] = pen @@ -318,11 +470,11 @@ class PlotDataItem(GraphicsObject): def setShadowPen(self, *args, **kargs): """ - | Sets the shadow pen used to draw lines between points (this is for enhancing contrast or - emphasizing data). - | This line is drawn behind the primary pen (see :func:`setPen() `) - and should generally be assigned greater width than the primary pen. - | *pen* can be a QPen or any argument accepted by :func:`pyqtgraph.mkPen() ` + Sets the shadow pen used to draw lines between points (this is for enhancing contrast or + emphasizing data). This line is drawn behind the primary pen and should generally be assigned + greater width than the primary pen. + The argument can be a :class:`QtGui.QPen` or any combination of arguments accepted by + :func:`pyqtgraph.mkPen() `. """ pen = fn.mkPen(*args, **kargs) self.opts['shadowPen'] = pen @@ -332,6 +484,10 @@ class PlotDataItem(GraphicsObject): self.updateItems(styleUpdate=True) def setFillBrush(self, *args, **kargs): + """ + Sets the :class:`QtGui.QBrush` used to fill the area under the curve. + See :func:`mkBrush() `) for arguments. + """ brush = fn.mkBrush(*args, **kargs) if self.opts['fillBrush'] == brush: return @@ -339,15 +495,26 @@ class PlotDataItem(GraphicsObject): self.updateItems(styleUpdate=True) def setBrush(self, *args, **kargs): + """ + See :func:`setFillBrush() ` or a list that + specifies a symbol for each point. + """ if self.opts['symbol'] == symbol: return self.opts['symbol'] = symbol @@ -355,6 +522,10 @@ class PlotDataItem(GraphicsObject): self.updateItems(styleUpdate=True) def setSymbolPen(self, *args, **kargs): + """ + Sets the :class:`QtGui.QPen` used to draw symbol outlines. + See :func:`mkPen() `) for arguments. + """ pen = fn.mkPen(*args, **kargs) if self.opts['symbolPen'] == pen: return @@ -363,6 +534,10 @@ class PlotDataItem(GraphicsObject): self.updateItems(styleUpdate=True) def setSymbolBrush(self, *args, **kargs): + """ + Sets the :class:`QtGui.QBrush` used to fill symbols. + See :func:`mkBrush() `) for arguments. + """ brush = fn.mkBrush(*args, **kargs) if self.opts['symbolBrush'] == brush: return @@ -370,8 +545,10 @@ class PlotDataItem(GraphicsObject): #self.scatter.setSymbolBrush(brush) self.updateItems(styleUpdate=True) - def setSymbolSize(self, size): + """ + Sets the symbol size. + """ if self.opts['symbolSize'] == size: return self.opts['symbolSize'] = size @@ -380,7 +557,7 @@ class PlotDataItem(GraphicsObject): def setDownsampling(self, ds=None, auto=None, method=None): """ - Set the downsampling mode of this item. Downsampling reduces the number + Sets the downsampling mode of this item. Downsampling reduces the number of samples drawn to increase performance. ============== ================================================================= @@ -412,20 +589,26 @@ class PlotDataItem(GraphicsObject): self.opts['downsampleMethod'] = method if changed: - self.xDisp = self.yDisp = None + self._datasetMapped = None # invalidata mapped data + self._datasetDisplay = None # invalidate display data self.updateItems(styleUpdate=False) - def setClipToView(self, clip): - if self.opts['clipToView'] == clip: + def setClipToView(self, state): + """ + ``state=True`` enables clipping the displayed data set to the + visible x-axis range. + """ + if self.opts['clipToView'] == state: return - self.opts['clipToView'] = clip - self.xDisp = self.yDisp = None + self.opts['clipToView'] = state + self._datasetDisplay = None # invalidate display data self.updateItems(styleUpdate=False) def setDynamicRangeLimit(self, limit=1e06, hysteresis=3.): """ Limit the off-screen positions of data points at large magnification - This avoids errors with plots not displaying because their visibility is incorrectly determined. The default setting repositions far-off points to be within +-1E+06 times the viewport height. + This avoids errors with plots not displaying because their visibility is incorrectly determined. + The default setting repositions far-off points to be within ±10^6 times the viewport height. =============== ================================================================ **Arguments:** @@ -447,7 +630,8 @@ class PlotDataItem(GraphicsObject): if limit == self.opts['dynamicRangeLimit']: return # avoid update if there is no change self.opts['dynamicRangeLimit'] = limit # can be None - self.xDisp = self.yDisp = None + self._datasetDisplay = None # invalidate display data + self.updateItems(styleUpdate=False) def setData(self, *args, **kargs): @@ -572,23 +756,28 @@ class PlotDataItem(GraphicsObject): #scatterArgs[v] = self.opts[k] if y is None or len(y) == 0: # empty data is represented as None - self.yData = None + yData = None else: # actual data is represented by ndarray if not isinstance(y, np.ndarray): y = np.array(y) - self.yData = y.view(np.ndarray) + yData = y.view(np.ndarray) if x is None: x = np.arange(len(y)) if x is None or len(x)==0: # empty data is represented as None - self.xData = None + xData = None else: # actual data is represented by ndarray if not isinstance(x, np.ndarray): x = np.array(x) - self.xData = x.view(np.ndarray) # one last check to make sure there are no MetaArrays getting by - self._dataRect = None - self.xDisp = None - self.yDisp = None + xData = x.view(np.ndarray) # one last check to make sure there are no MetaArrays getting by + + if xData is None or yData is None: + self._dataset = None + else: + self._dataset = PlotDataset( xData, yData ) + self._datasetMapped = None # invalidata mapped data , will be generated in getData() / getDisplayDataset() + self._datasetDisplay = None # invalidate display data, will be generated in getData() / getDisplayDataset() + profiler('set data') self.updateItems( styleUpdate = self.property('styleWasChanged') ) @@ -603,7 +792,7 @@ class PlotDataItem(GraphicsObject): def updateItems(self, styleUpdate=True): # override styleUpdate request and always enforce update until we have a better solution for # - ScatterPlotItem losing per-point style information - # - PlotDataItem performing multiple unnecessary setData call on initialization + # - PlotDataItem performing multiple unnecessary setData calls on initialization styleUpdate=True curveArgs = {} @@ -636,10 +825,29 @@ class PlotDataItem(GraphicsObject): if k in self.opts: scatterArgs[v] = self.opts[k] - x,y = self.getData() - #scatterArgs['mask'] = self.dataMask + dataset = self.getDisplayDataset() + if dataset is None: # then we have nothing to show + self.curve.hide() + self.scatter.hide() + return - if self.opts['pen'] is not None or (self.opts['fillBrush'] is not None and self.opts['fillLevel'] is not None): # draw if visible... + x = dataset.x + y = dataset.y + #scatterArgs['mask'] = self.dataMask + if( + self.opts['pen'] is not None + or (self.opts['fillBrush'] is not None and self.opts['fillLevel'] is not None) + ): # draw if visible... + # print('connect is', curveArgs['connect'], 'expect nonfinites:', dataset.containsNonfinite) + if curveArgs['connect'] == 'auto': # auto-switch to indicate non-finite values as interruptions in the curve + if dataset.containsNonfinite is None: + curveArgs['connect'] = 'all' # this is faster, but silently connects the curve across any non-finite values + else: + if dataset.containsNonfinite: + curveArgs['connect'] = 'finite' + else: + curveArgs['connect'] = 'all' # all points can be connected, and no further check is needed. + curveArgs['skipFiniteCheck'] = True self.curve.setData(x=x, y=y, **curveArgs) self.curve.show() else: # ...hide if not. @@ -655,54 +863,66 @@ class PlotDataItem(GraphicsObject): self.scatter.hide() - def getData(self): - if self.xData is None: - return (None, None) - - if( self.xDisp is not None and + def getDisplayDataset(self): + """ + Returns a :class:`PlotDataset ` object that contains data suitable for display + (after mapping and data reduction) as `dataset.x` and `dataset.y`. + Intended for internal use. + """ + if self._dataset is None: + return None + # Return cached processed dataset if available and still valid: + if( self._datasetDisplay is not None and not (self.property('xViewRangeWasChanged') and self.opts['clipToView']) and not (self.property('xViewRangeWasChanged') and self.opts['autoDownsample']) and not (self.property('yViewRangeWasChanged') and self.opts['dynamicRangeLimit'] is not None) ): - return self.xDisp, self.yDisp - x = self.xData - y = self.yData - if y.dtype == bool: - y = y.astype(np.uint8) - if x.dtype == bool: - x = x.astype(np.uint8) + return self._datasetDisplay + + # Apply data mapping functions if mapped dataset is not yet available: + if self._datasetMapped is None: + x = self._dataset.x + y = self._dataset.y + if y.dtype == bool: + y = y.astype(np.uint8) + if x.dtype == bool: + x = x.astype(np.uint8) + + if self.opts['fftMode']: + x,y = self._fourierTransform(x, y) + # Ignore the first bin for fft data if we have a logx scale + if self.opts['logMode'][0]: + x=x[1:] + y=y[1:] + + if self.opts['derivativeMode']: # plot dV/dt + y = np.diff(self._dataset.y)/np.diff(self._dataset.x) + x = x[:-1] + if self.opts['phasemapMode']: # plot dV/dt vs V + x = self._dataset.y[:-1] + y = np.diff(self._dataset.y)/np.diff(self._dataset.x) + + dataset = PlotDataset(x,y) + dataset.containsNonfinite = self._dataset.containsNonfinite + + if True in self.opts['logMode']: + dataset.applyLogMapping( self.opts['logMode'] ) # Apply log scaling for x and/or y axis + + self._datasetMapped = dataset + + # apply processing that affects the on-screen display of data: + x = self._datasetMapped.x + y = self._datasetMapped.y + containsNonfinite = self._datasetMapped.containsNonfinite + view = self.getViewBox() if view is None: view_range = None else: - view_range = self.getViewBox().viewRect() # this is always up-to-date + view_range = view.viewRect() # this is always up-to-date if view_range is None: view_range = self.viewRect() - if self.opts['fftMode']: - x,y = self._fourierTransform(x, y) - # Ignore the first bin for fft data if we have a logx scale - if self.opts['logMode'][0]: - x=x[1:] - y=y[1:] - - if self.opts['derivativeMode']: # plot dV/dt - y = np.diff(self.yData)/np.diff(self.xData) - x = x[:-1] - if self.opts['phasemapMode']: # plot dV/dt vs V - x = self.yData[:-1] - y = np.diff(self.yData)/np.diff(self.xData) - - with np.errstate(divide='ignore'): - if self.opts['logMode'][0]: - x = np.log10(x) - if self.opts['logMode'][1]: - if np.issubdtype(y.dtype, np.floating): - eps = np.finfo(y.dtype).eps - else: - eps = 1 - y = np.sign(y) * np.log10(np.abs(y)+eps) - ds = self.opts['downsample'] if not isinstance(ds, int): ds = 1 @@ -725,7 +945,6 @@ class PlotDataItem(GraphicsObject): else: # clip-to-view always presumes that x-values are in increasing order if view_range is not None and len(x) > 1: - # print('search:', view_range.left(),'-',view_range.right() ) # find first in-view value (left edge) and first out-of-view value (right edge) # since we want the curve to go to the edge of the screen, we need to preserve # one down-sampled point on the left and one of the right, so we extend the interval @@ -745,7 +964,6 @@ class PlotDataItem(GraphicsObject): y = y[::ds] elif self.opts['downsampleMethod'] == 'mean': n = len(x) // ds - # x = x[:n*ds:ds] stx = ds//2 # start of x-values; try to select a somewhat centered point x = x[stx:stx+n*ds:ds] y = y[:n*ds].reshape(n,ds).mean(axis=1) @@ -763,20 +981,21 @@ class PlotDataItem(GraphicsObject): if self.opts['dynamicRangeLimit'] is not None: if view_range is not None: - data_range = self.dataRect() + data_range = self._datasetMapped.dataRect() if data_range is not None: view_height = view_range.height() limit = self.opts['dynamicRangeLimit'] hyst = self.opts['dynamicRangeHyst'] # never clip data if it fits into +/- (extended) limit * view height if ( # note that "bottom" is the larger number, and "top" is the smaller one. - not data_range.bottom() < view_range.top() # never clip if all data is too small to see + view_height > 0 # never clip if the view does not show anything and would cause division by zero + and not data_range.bottom() < view_range.top() # never clip if all data is too small to see and not data_range.top() > view_range.bottom() # never clip if all data is too large to see and data_range.height() > 2 * hyst * limit * view_height ): cache_is_good = False # check if cached display data can be reused: - if self.yDisp is not None: # top is minimum value, bottom is maximum value + if self._datasetDisplay is not None: # top is minimum value, bottom is maximum value # how many multiples of the current view height does the clipped plot extend to the top and bottom? top_exc =-(self._drlLastClip[0]-view_range.bottom()) / view_height bot_exc = (self._drlLastClip[1]-view_range.top() ) / view_height @@ -784,85 +1003,62 @@ class PlotDataItem(GraphicsObject): if ( top_exc >= limit / hyst and top_exc <= limit * hyst and bot_exc >= limit / hyst and bot_exc <= limit * hyst ): # restore cached values - x = self.xDisp - y = self.yDisp + x = self._datasetDisplay.x + y = self._datasetDisplay.y cache_is_good = True if not cache_is_good: min_val = view_range.bottom() - limit * view_height max_val = view_range.top() + limit * view_height - if( self.yDisp is not None # Do we have an existing cache? - and min_val >= self._drlLastClip[0] # Are we reducing it further? - and max_val <= self._drlLastClip[1] ): - # if we need to clip further, we can work in-place on the output buffer - # print('in-place:', end='') - # workaround for slowdown from numpy deprecation issues in 1.17 to 1.20+ : - # np.clip(self.yDisp, out=self.yDisp, a_min=min_val, a_max=max_val) - fn.clip_array(self.yDisp, min_val, max_val, out=self.yDisp) - self._drlLastClip = (min_val, max_val) - # print('{:.1e}<->{:.1e}'.format( min_val, max_val )) - x = self.xDisp - y = self.yDisp - else: - # if none of the shortcuts worked, we need to recopy from the full data - # print('alloc:', end='') - # workaround for slowdown from numpy deprecation issues in 1.17 to 1.20+ : - # y = np.clip(y, a_min=min_val, a_max=max_val) - y = fn.clip_array(y, min_val, max_val) - self._drlLastClip = (min_val, max_val) - # print('{:.1e}<->{:.1e}'.format( min_val, max_val )) - self.xDisp = x - self.yDisp = y + # print('alloc:', end='') + # workaround for slowdown from numpy deprecation issues in 1.17 to 1.20+ : + # y = np.clip(y, a_min=min_val, a_max=max_val) + y = fn.clip_array(y, min_val, max_val) + self._drlLastClip = (min_val, max_val) + self._datasetDisplay = PlotDataset( x,y ) + self._datasetDisplay.containsNonfinite = containsNonfinite self.setProperty('xViewRangeWasChanged', False) self.setProperty('yViewRangeWasChanged', False) - return self.xDisp, self.yDisp + return self._datasetDisplay + + def getData(self): + """ + Returns the displayed data as the tuple (`xData`, `yData`) after mapping and data reduction. + """ + dataset = self.getDisplayDataset() + if dataset is None: + return (None, None) + return dataset.x, dataset.y + + # compatbility method for access to dataRect for full dataset: def dataRect(self): """ - Returns a bounding rectangle (as QRectF) for the full set of data. - Will return None if there is no data or if all values (x or y) are NaN. + Returns a bounding rectangle (as :class:`QtGui.QRectF`) for the full set of data. + Will return `None` if there is no data or if all values (x or y) are NaN. """ - if self._dataRect is not None: - return self._dataRect - if self.xData is None or self.yData is None: + if self._dataset is None: return None - if len(self.xData) == 0: # avoid crash if empty data is represented by [] instead of None - return None - with warnings.catch_warnings(): - # All-NaN data is handled by returning None; Explicit numpy warning is not needed. - warnings.simplefilter("ignore") - ymin = np.nanmin(self.yData) - if math.isnan( ymin ): - return None # most likely case for all-NaN data - xmin = np.nanmin(self.xData) - if math.isnan( xmin ): - return None # less likely case for all-NaN data - ymax = np.nanmax(self.yData) - xmax = np.nanmax(self.xData) - self._dataRect = QtCore.QRectF( - QtCore.QPointF(xmin,ymin), - QtCore.QPointF(xmax,ymax) ) - return self._dataRect + return self._dataset.dataRect() def dataBounds(self, ax, frac=1.0, orthoRange=None): """ Returns the range occupied by the data (along a specific axis) in this item. - This method is called by ViewBox when auto-scaling. + This method is called by :class:`ViewBox` when auto-scaling. - =============== ============================================================= + =============== ==================================================================== **Arguments:** ax (0 or 1) the axis for which to return this item's data range frac (float 0.0-1.0) Specifies what fraction of the total data range to return. By default, the entire range is returned. - This allows the ViewBox to ignore large spikes in the data + This allows the :class:`ViewBox` to ignore large spikes in the data when auto-scaling. orthoRange ([min,max] or None) Specifies that only the data within the given range (orthogonal to *ax*) should me measured when returning the data range. (For example, a ViewBox might ask what is the y-range of all data with x-values between min and max) - =============== ============================================================= + =============== ==================================================================== """ - range = [None, None] if self.curve.isVisible(): range = self.curve.dataBounds(ax, frac, orthoRange) @@ -876,8 +1072,8 @@ class PlotDataItem(GraphicsObject): def pixelPadding(self): """ - Return the size in pixels that this item may draw beyond the values returned by dataBounds(). - This method is called by ViewBox when auto-scaling. + Returns the size in pixels that this item may draw beyond the values returned by dataBounds(). + This method is called by :class:`ViewBox` when auto-scaling. """ pad = 0 if self.curve.isVisible(): @@ -886,13 +1082,10 @@ class PlotDataItem(GraphicsObject): pad = max(pad, self.scatter.pixelPadding()) return pad - def clear(self): - self.xData = None - self.yData = None - self.xDisp = None - self.yDisp = None - self._dataRect = None + self._dataset = None + self._datasetMapped = None + self._datasetDisplay = None self.curve.clear() self.scatter.clear() @@ -917,7 +1110,7 @@ class PlotDataItem(GraphicsObject): # super().viewTransformChanged() # this invalidates the viewRect() cache! def viewRangeChanged(self, vb=None, ranges=None, changed=None): - """ view range has changed; re-plot if needed """ + # view range has changed; re-plot if needed update_needed = False if changed is None or changed[0]: # if ranges is not None: @@ -926,7 +1119,7 @@ class PlotDataItem(GraphicsObject): if( self.opts['clipToView'] or self.opts['autoDownsample'] ): - self.xDisp = self.yDisp = None + self._datasetDisplay = None update_needed = True if changed is None or changed[1]: # if ranges is not None: @@ -953,7 +1146,8 @@ class PlotDataItem(GraphicsObject): x = np.fft.rfftfreq(n, d) y = np.abs(f) return x, y - + +# helper functions: def dataType(obj): if hasattr(obj, '__len__') and len(obj) == 0: return 'empty' @@ -982,159 +1176,3 @@ def dataType(obj): def isSequence(obj): return hasattr(obj, '__iter__') or isinstance(obj, np.ndarray) or (hasattr(obj, 'implements') and obj.implements('MetaArray')) - - - -#class TableData: - #""" - #Class for presenting multiple forms of tabular data through a consistent interface. - #May contain: - #- numpy record array - #- list-of-dicts (all dicts are _not_ required to have the same keys) - #- dict-of-lists - #- dict (single record) - #Note: if all the values in this record are lists, it will be interpreted as multiple records - - #Data can be accessed and modified by column, by row, or by value - #data[columnName] - #data[rowId] - #data[columnName, rowId] = value - #data[columnName] = [value, value, ...] - #data[rowId] = {columnName: value, ...} - #""" - - #def __init__(self, data): - #self.data = data - #if isinstance(data, np.ndarray): - #self.mode = 'array' - #elif isinstance(data, list): - #self.mode = 'list' - #elif isinstance(data, dict): - #types = set(map(type, data.values())) - ### dict may be a dict-of-lists or a single record - #types -= set([list, np.ndarray]) ## if dict contains any non-sequence values, it is probably a single record. - #if len(types) != 0: - #self.data = [self.data] - #self.mode = 'list' - #else: - #self.mode = 'dict' - #elif isinstance(data, TableData): - #self.data = data.data - #self.mode = data.mode - #else: - #raise TypeError(type(data)) - - #for fn in ['__getitem__', '__setitem__']: - #setattr(self, fn, getattr(self, '_TableData'+fn+self.mode)) - - #def originalData(self): - #return self.data - - #def toArray(self): - #if self.mode == 'array': - #return self.data - #if len(self) < 1: - ##return np.array([]) ## need to return empty array *with correct columns*, but this is very difficult, so just return None - #return None - #rec1 = self[0] - #dtype = functions.suggestRecordDType(rec1) - ##print rec1, dtype - #arr = np.empty(len(self), dtype=dtype) - #arr[0] = tuple(rec1.values()) - #for i in range(1, len(self)): - #arr[i] = tuple(self[i].values()) - #return arr - - #def __getitem__array(self, arg): - #if isinstance(arg, tuple): - #return self.data[arg[0]][arg[1]] - #else: - #return self.data[arg] - - #def __getitem__list(self, arg): - #if isinstance(arg, str): - #return [d.get(arg, None) for d in self.data] - #elif isinstance(arg, int): - #return self.data[arg] - #elif isinstance(arg, tuple): - #arg = self._orderArgs(arg) - #return self.data[arg[0]][arg[1]] - #else: - #raise TypeError(type(arg)) - - #def __getitem__dict(self, arg): - #if isinstance(arg, str): - #return self.data[arg] - #elif isinstance(arg, int): - #return dict([(k, v[arg]) for k, v in self.data.items()]) - #elif isinstance(arg, tuple): - #arg = self._orderArgs(arg) - #return self.data[arg[1]][arg[0]] - #else: - #raise TypeError(type(arg)) - - #def __setitem__array(self, arg, val): - #if isinstance(arg, tuple): - #self.data[arg[0]][arg[1]] = val - #else: - #self.data[arg] = val - - #def __setitem__list(self, arg, val): - #if isinstance(arg, str): - #if len(val) != len(self.data): - #raise Exception("Values (%d) and data set (%d) are not the same length." % (len(val), len(self.data))) - #for i, rec in enumerate(self.data): - #rec[arg] = val[i] - #elif isinstance(arg, int): - #self.data[arg] = val - #elif isinstance(arg, tuple): - #arg = self._orderArgs(arg) - #self.data[arg[0]][arg[1]] = val - #else: - #raise TypeError(type(arg)) - - #def __setitem__dict(self, arg, val): - #if isinstance(arg, str): - #if len(val) != len(self.data[arg]): - #raise Exception("Values (%d) and data set (%d) are not the same length." % (len(val), len(self.data[arg]))) - #self.data[arg] = val - #elif isinstance(arg, int): - #for k in self.data: - #self.data[k][arg] = val[k] - #elif isinstance(arg, tuple): - #arg = self._orderArgs(arg) - #self.data[arg[1]][arg[0]] = val - #else: - #raise TypeError(type(arg)) - - #def _orderArgs(self, args): - ### return args in (int, str) order - #if isinstance(args[0], str): - #return (args[1], args[0]) - #else: - #return args - - #def __iter__(self): - #for i in range(len(self)): - #yield self[i] - - #def __len__(self): - #if self.mode == 'array' or self.mode == 'list': - #return len(self.data) - #else: - #return max(map(len, self.data.values())) - - #def columnNames(self): - #"""returns column names in no particular order""" - #if self.mode == 'array': - #return self.data.dtype.names - #elif self.mode == 'list': - #names = set() - #for row in self.data: - #names.update(row.keys()) - #return list(names) - #elif self.mode == 'dict': - #return self.data.keys() - - #def keys(self): - #return self.columnNames() diff --git a/pyqtgraph/graphicsItems/ViewBox/ViewBox.py b/pyqtgraph/graphicsItems/ViewBox/ViewBox.py index 7feedbf9..7c14ca7d 100644 --- a/pyqtgraph/graphicsItems/ViewBox/ViewBox.py +++ b/pyqtgraph/graphicsItems/ViewBox/ViewBox.py @@ -1468,6 +1468,10 @@ class ViewBox(GraphicsWidget): bounds = QtCore.QRectF(range[0][0], range[1][0], range[0][1]-range[0][0], range[1][1]-range[1][0]) return bounds + + def update(self, *args, **kwargs): + self.prepareForPaint() + GraphicsWidget.update(self, *args, **kwargs) def updateViewRange(self, forceX=False, forceY=False): ## Update viewRange to match targetRange as closely as possible, given diff --git a/tests/graphicsItems/test_PlotDataItem.py b/tests/graphicsItems/test_PlotDataItem.py index f5e88708..25e8b53b 100644 --- a/tests/graphicsItems/test_PlotDataItem.py +++ b/tests/graphicsItems/test_PlotDataItem.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- import numpy as np +import warnings import pyqtgraph as pg from pyqtgraph.Qt import QtGui @@ -71,6 +72,31 @@ def test_setData(): pdi.setData([],[]) assert pdi.xData is None assert pdi.yData is None + +def test_nonfinite(): + def _assert_equal_arrays(a1, a2): + assert a1.shape == a2.shape + for ( xtest, xgood ) in zip( a1, a2 ): + assert( (xtest == xgood) or (np.isnan(xtest) and np.isnan(xgood) ) ) + + x = np.array([-np.inf, 0.0, 1.0, 2.0 , np.nan, 4.0 , np.inf]) + y = np.array([ 1.0, 0.0,-1.0, np.inf, 2.0 , np.nan, 0.0 ]) + pdi = pg.PlotDataItem(x, y) + dataset = pdi.getDisplayDataset() + _assert_equal_arrays( dataset.x, x ) + _assert_equal_arrays( dataset.y, y ) + + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + x_log = np.log10(x) + y_log = np.log10(y) + x_log[ ~np.isfinite(x_log) ] = np.nan + y_log[ ~np.isfinite(y_log) ] = np.nan + + pdi.setLogMode(True, True) + dataset = pdi.getDisplayDataset() + _assert_equal_arrays( dataset.x, x_log ) + _assert_equal_arrays( dataset.y, y_log ) def test_opts(): # test that curve and scatter plot properties get updated from PlotDataItem methods