"""
.. module: FSRSPlot
:platform: Windows
.. moduleauthor:: Daniel R. Dietze <daniel.dietze@berkeley.edu>
FSRSPlot is a lightweight plotting widget for wxPython for displaying pyFSRS measurement results.
It supports multiple line and scatter plots, contour plots and image plots.
While contour plots work on non-equidistantly sampled data and produce nice interpolated plots,
image plots operate on 2d arrays and provide an enourmous advantage in speed.
Further features include lin / log scale for x, y, and z axes (individually) as well as
reading data coordinates with mouse cursor, two independent data cursors for exploring line/scatter
data values and displaying the difference value.
Example usage::
import wx
import numpy as np
from FSRSPlot import *
# setup a wxPython app
app = wx.App()
# get an instance of a plot frame
frame = PlotFrame(None, title="pyFSRS - Test plot panel", size=(640, 480))
frame.plotCanvas.setColormap("blue")
# get some nice data
x = np.linspace(-10, 10, 64)
y = np.linspace(-10, 10, 64)
X, Y = np.meshgrid(x, y)
z = np.exp(-(X**2 +Y**2) / 5.0**2) * np.sin(X)
frame.plotCanvas.addImage(x, y, z)
frame.plotCanvas.addLine(x, -5 + 10.0 * np.exp(-x**2 / 5.0**2))
# show frame and start app
frame.Show()
app.MainLoop()
..
This file is part of the pyFSRS app.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
Copyright 2014-2016 Daniel Dietze <daniel.dietze@berkeley.edu>.
"""
import wx
import numpy as np
import itertools
# plot panel main class
[docs]class FSRSPlot(wx.Panel):
"""The FSRSPlot class is derived from wxPanel and provides a lightweight plotting interface
for wxPython that not only supports line/scatter plots but also image and contour plots.
I have kept the number of functions to control the layout to a minimum. Rather, the user is
encouraged to directly mess with the class member variables. A list of variables and their default values are given in the following::
# display the cross cursor when mouse is present in plot area
self.showCross = True
# display a grid
self.showGrid = True
# linewidth, markerstyle and colors for scatter / line plot
self.markersize = 2
self.linewidth = 2
# background color
self.bgcolor = wx.Colour(255, 255, 255)
# margin of plotting area to boundary of panel in pixels
self.leftmargin = 60
self.bottommargin = 30
# axis and ticks; font and colors
self.ticklength = 5
self.axisPen = wx.Pen(wx.Colour(0, 0, 0), width=2, style=wx.PENSTYLE_SOLID)
self.font = wx.Font(12, wx.FONTFAMILY_SWISS, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_BOLD)
# display Delta-value, i.e. difference between cursors A and B
self.deltafont = wx.Font(20, wx.FONTFAMILY_SWISS, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_BOLD)
self.deltafontPen = wx.Pen(wx.Colour(30, 0, 250), width=1, style=wx.PENSTYLE_SOLID)
# grid
self.gridPen = wx.Pen(wx.Colour(128, 128, 128), width=1, style=wx.PENSTYLE_DOT)
# mouse crosshair
self.crossPenDark = wx.Pen(wx.Colour(0, 0, 0), width=1, style=wx.PENSTYLE_SOLID)
self.crossPenLight = wx.Pen(wx.Colour(255, 255, 255), width=1, style=wx.PENSTYLE_SOLID)
# list of line colors; the color is cycled through the array for each new line or point
self.linescolor = [wx.Colour(255, 0, 0), wx.Colour(255, 128, 128), wx.Colour(0, 0, 255),
wx.Colour(128, 128, 255) , wx.Colour(0, 255, 0), wx.Colour(128, 255, 128)]
"""
def __init__(self, parent, id=-1, *args, **kwargs):
wx.Panel.__init__(self, parent, id, *args, **kwargs)
self.SetWindowStyle(self.GetWindowStyle() | wx.WANTS_CHARS)
self.SetBackgroundStyle(wx.BG_STYLE_PAINT)
# setup widget
self.initVariables()
# Bind the events related to our control: first of all, we use a
# combination of wx.BufferedPaintDC and an empty handler for
# wx.EVT_ERASE_BACKGROUND (see later) to reduce flicker
self.Bind(wx.EVT_PAINT, self.OnPaint)
self.Bind(wx.EVT_ERASE_BACKGROUND, self.OnEraseBackground)
self.Bind(wx.EVT_SIZE, self.OnResize)
# mouse events
self.Bind(wx.EVT_LEFT_DOWN, self.OnLMouseDown)
self.Bind(wx.EVT_RIGHT_DOWN, self.OnRMouseDown)
self.Bind(wx.EVT_MOUSEWHEEL, self.OnMouseWheel)
self.Bind(wx.EVT_MOTION, self.OnMouseMove)
# keyboard events
self.Bind(wx.EVT_KEY_DOWN, self.OnKeyDown)
[docs] def initVariables(self):
"""Init internally used variables and parameters.
"""
# data
# data contains a list of plot elements where
# data = [ [[x1, y1], [x2, y2], ...[xN, yN]], [..] ] for line/scatter,
# data = [ [[x1, x2, ...xN], [y1, y2, ..., yM], [[z11, z12, ...z1N], [z21, ..], ..]] ] for contour and
# data = [ [[x0, xN], [y0, yN], index] ] for image; x0, xN are min/max values
# and type one of "line", "scatter", "contour", and "image"
self.data = [] # plot data
self.data_types = [] # type of data stored in self.data
self.data_extent = [] # list of data ranges [xmin, xmax, ymin, ymax]
self.images = [] # list of wx.Bitmap objects
# -------------------------
# internally used variables
# memory dc for storing contour plots, which are time intensive to replot
self.contourDC = wx.MemoryDC() # this stores the created image
self.contourDCValid = False # False -> redraw the contour plot, True -> copy from memoryDC
# data min/max and ticks for axis
self.plotXMin = 0
self.plotXMax = 1
self.plotYMin = 0
self.plotYMax = 1
self.plotZMin = 0
self.plotZMax = 1
self.XTicks = []
self.YTicks = []
self.lblx = []
self.lbly = []
# mapping from data to screen
self.map_ax = 1
self.map_bx = 0
self.map_ay = 1
self.map_by = 0
# size of actual plot area
self.dcleft = 0
self.dctop = 0
self.dcright = 0
self.dcbottom = 0
# mouse control variables
self.mousePos = wx.Point()
self.mouseIn = False
self.mouseX1 = None # cursor positions in data coordinates!
self.mouseX2 = None
self.mouseN = 0 # index of line the cursors are assigned to
self.cursor1 = [0, 0] # data value at cursor position
self.cursor2 = [0, 0]
# -------------------------
# layout parameters
# display the cross cursor when mouse is present in plot area
self.showCross = True
# display a grid
self.showGrid = True
# background color
self.bgcolor = wx.Colour(255, 255, 255)
# margin of plotting area to boundary of panel in pixels
self.leftmargin = 60
self.bottommargin = 30
# axis and ticks; font and colors
self.ticklength = 5
self.axiswidth = 2
self.font = wx.Font(12, wx.FONTFAMILY_SWISS, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_BOLD)
self.axiscolor = wx.Colour(0, 0, 0)
self.gridcolor = wx.Colour(128, 128, 128)
self.deltafont = wx.Font(20, wx.FONTFAMILY_SWISS, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_BOLD)
self.deltafontcolor = wx.Colour(30, 0, 250)
# set pen
self.axisPen = wx.Pen(self.axiscolor, width=self.axiswidth, style=wx.PENSTYLE_SOLID)
self.gridPen = wx.Pen(self.gridcolor, width=1, style=wx.PENSTYLE_DOT)
self.crossPenDark = wx.Pen(wx.Colour(0, 0, 0), width=1, style=wx.PENSTYLE_SOLID)
self.crossPenLight = wx.Pen(wx.Colour(255, 255, 255), width=1, style=wx.PENSTYLE_SOLID)
self.deltafontPen = wx.Pen(self.deltafontcolor, width=1, style=wx.PENSTYLE_SOLID)
# linewidth, markerstyle and colors for scatter / line plot
self.markersize = 2
self.linewidth = 2
# list of line colors; the color is cycled through the array for each new line or point
self.linescolor = [wx.Colour(255, 0, 0), wx.Colour(255, 128, 128), wx.Colour(0, 0, 255),
wx.Colour(128, 128, 255), wx.Colour(0, 255, 0), wx.Colour(128, 255, 128)]
# colormap for contour plot
# each entry consists of a value between 0 (min) and 1 (max) and a color
# the actual color is generated by linear interpolation between neighboring entries
# make sure that you sort the entries ascending!
self.colormap = []
self.imgcolormap = [] # version for images with reduced color depth (256)
self.setColormap("rgb") # initialize colormaps
# logscale
self.logx = False
self.logy = False
self.logz = False
# make a tight axis, i.e., use the data's extent for axes
self.tightx = False
self.tighty = False
# axis labels
self.xlabel = ""
self.ylabel = ""
# extensions used for label formatting
self.ext = ['y', 'z', 'a', 'f', 'p', 'n', 'u', 'm', '', 'k', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y']
# EVENTS
[docs] def OnEraseBackground(self, event):
"""Event handler for background erase.
"""
event.Skip()
[docs] def OnPaint(self, event):
"""Event handler for paint event.
"""
dc = wx.BufferedPaintDC(self)
self.Draw(dc)
[docs] def OnResize(self, event):
"""Event handler for resize event.
"""
self.Refresh()
[docs] def OnLMouseDown(self, event):
"""Event handler for left mouse button down. Positions cursor A on the data if the plot is not empty.
"""
event.Skip()
# get mouse position in window
self.mousePos = self.ScreenToClient(wx.GetMousePosition())
x, y = self.mousePos.Get()
if len(self.data) == 0:
return
# if mouse is in data area, set cursor
if x > self.dcleft and x < self.dcright and y > self.dctop and y < self.dcbottom:
self.mouseX1, _ = self.screen2data(x, y)
# find next line in this direction if the selected trace is not a line
if self.data_types[self.mouseN] not in ["line", "scatter"]:
mouseN0 = self.mouseN
while(1):
self.mouseN += 1
if self.mouseN < 0:
self.mouseN += len(self.data)
if self.mouseN >= len(self.data):
self.mouseN -= len(self.data)
if self.data_types[self.mouseN] in ["line", "scatter"] or self.mouseN == mouseN0:
break
[docs] def OnRMouseDown(self, event):
"""Event hanlder for right mouse button down. Positions cursor B on the data if the plot is not empty.
"""
event.Skip()
# get mouse position in window
self.mousePos = self.ScreenToClient(wx.GetMousePosition())
x, y = self.mousePos.Get()
if len(self.data) == 0:
return
# if mouse is in data area, set cursor
if x > self.dcleft and x < self.dcright and y > self.dctop and y < self.dcbottom:
self.mouseX2, _ = self.screen2data(x, y)
# find next line in this direction if the selected trace is not a line
if self.data_types[self.mouseN] not in ["line", "scatter"]:
mouseN0 = self.mouseN
while(1):
self.mouseN += 1
if self.mouseN < 0:
self.mouseN += len(self.data)
if self.mouseN >= len(self.data):
self.mouseN -= len(self.data)
if self.data_types[self.mouseN] in ["line", "scatter"] or self.mouseN == mouseN0:
break
[docs] def OnMouseMove(self, event):
"""Event handler for mouse move event. Updates current position of cursor in data coordinates.
"""
event.Skip()
# get mouse position in window
self.mousePos = self.ScreenToClient(wx.GetMousePosition())
x, y = self.mousePos.Get()
# if mouse is in data area, refresh the screen to display the new coordinates
if x > self.dcleft and x < self.dcright and y > self.dctop and y < self.dcbottom:
self.mouseIn = True
self.Refresh()
else:
if self.mouseIn:
self.mouseIn = False
self.Refresh()
[docs] def OnMouseWheel(self, event):
"""Event handler for mouse wheel event. This event is used to switch the active line / scatter plot for the cursors.
"""
event.Skip()
delta = event.GetWheelRotation()
if len(self.data) == 0:
return
# get stepping direction
d = np.where(delta < 0, -1, 1)
# find next line in this direction
mouseN0 = self.mouseN
while(1):
self.mouseN += d
if self.mouseN < 0:
self.mouseN += len(self.data)
if self.mouseN >= len(self.data):
self.mouseN -= len(self.data)
if self.data_types[self.mouseN] in ["line", "scatter"] or self.mouseN == mouseN0:
break
if self.mouseX1 is not None or self.mouseX2 is not None:
self.Refresh()
[docs] def OnKeyDown(self, event):
"""Event handler for key-press event. Handles the following key strokes:
- **C**: Clear plot.
- **X**: Switch between linear and logarithmic scaling on x-axis.
- **Y**: Switch between linear and logarithmic scaling on y-axis.
- **Z**: Switch between linear and logarithmic scaling on z-axis. Affects only contour and image plots.
- **M**: Center cursor A on maxium of active line/scatter plot.
"""
event.Skip()
c = chr(min(max(0, event.GetKeyCode()), 255)) # select only ascii range, no special keys
if c == 'C': # clear cursors
self.mouseX1 = None
self.mouseX2 = None
self.Refresh()
elif c == 'X':
self.logx = not self.logx
self.contourDCValid = False
self.Refresh()
elif c == 'Y':
self.logy = not self.logy
self.contourDCValid = False
self.Refresh()
elif c == 'Z':
self.logz = not self.logz
self.contourDCValid = False
self.Refresh()
elif c == 'M':
if len(self.data) == 0:
return
if self.data_types[self.mouseN] in ["line", "scatter"]:
self.mouseX1 = self.data[self.mouseN][np.argmax(self.data[self.mouseN][:, 1]), 0]
self.Refresh()
# DATA HANDLING
[docs] def addImage(self, x, y, z):
"""Add an image plot.
:param array x: x-axis. Only the min/max values are used and the axis is linearly interpolated.
:param array y: y-axis. Only the min/max values are used and the axis is linearly interpolated.
:param array z: Image data, 2d-array.
:returns: Plot id.
"""
# get image width and height
h, w = z.shape
min, max = np.amin(z), np.amax(z)
# these are color indices
c = (((z.flatten() - min) / (max - min)) * 255).astype(int)
# append to image list
id = len(self.images)
self.images.append(wx.BitmapFromBuffer(w, h, self.imgcolormap[c]))
# append to data list
id = len(self.data)
self.data.append(((np.nanmin(x), np.nanmax(x)), (np.nanmin(y), np.nanmax(y)), id))
self.data_types.append('image')
self.data_extent.append((np.nanmin(x), np.nanmax(x), np.nanmin(y), np.nanmax(y)))
self.Refresh()
return id
[docs] def addContour(self, x, y, z):
"""Add contour plot.
:param array x: x-axis of length N. Does not have to be uniform.
:param array y: y-axis of length M. Does not have to be uniform.
:param array z: z-data, MxN array.
:returns: Plot id.
"""
if(len(x) != z.shape[1]):
raise ValueError("Length of x and z arrays is not compatible!")
if(len(y) != z.shape[0]):
raise ValueError("Length of y and z arrays is not compatible!")
id = len(self.data)
self.data.append((x, y, z))
self.data_types.append('contour')
self.data_extent.append((np.nanmin(x), np.nanmax(x), np.nanmin(y), np.nanmax(y)))
self.contourDCValid = False
self.Refresh()
return id
[docs] def addLine(self, x, y):
"""Add a line plot or single point.
:param array x: x-axis. Can also be a single integer or floating point number.
:param array y: y-axis data with same shape as x.
:returns: Plot id.
"""
if isinstance(x, list) or isinstance(x, np.ndarray):
if(len(x) != len(y)):
raise ValueError("Length of x and y arrays is different!")
nd = np.transpose(np.array([x, y]))
else:
nd = np.array([[float(x), float(y)], ])
id = len(self.data)
self.data.append(nd)
self.data_types.append('line')
self.data_extent.append((np.nanmin(x), np.nanmax(x), np.nanmin(y), np.nanmax(y)))
self.Refresh()
return id
[docs] def addScatter(self, x, y):
"""Add a scatter plot.
:param array x: x-axis. Can also be a single integer or floating point number.
:param array y: y-axis data with same shape as x.
:returns: Plot id.
"""
id = self.addLine(x, y)
self.data_types[id] = 'scatter'
return id
[docs] def setImage(self, id, x, y, z):
"""Overwrite an existing image plot.
:param int id: Id or index of image plot to overwrite. The plot at the given id must already be an image plot.
:param array x: x-axis. Only the min/max values are used and the axis is linearly interpolated.
:param array y: y-axis. Only the min/max values are used and the axis is linearly interpolated.
:param array z: Image data, 2d-array.
"""
id = abs(int(id))
if(id >= len(self.data)):
raise ValueError("ID of plot element out of range!")
if(self.data_types[id] != "image"):
raise ValueError("Element with given ID is not an image plot!")
# get image width and height
h, w = z.shape
min, max = np.amin(z), np.amax(z)
# these are color indices
c = (((z.flatten() - min) / (max - min)) * 255).astype(int)
# overwrite image
self.images[self.data[id][2]] = wx.BitmapFromBuffer(w, h, self.imgcolormap[c])
self.data[id] = ((np.nanmin(x), np.nanmax(x)), (np.nanmin(y), np.nanmax(y)), id)
self.data_extent[id] = (np.nanmin(x), np.nanmax(x), np.nanmin(y), np.nanmax(y))
self.Refresh()
[docs] def setContour(self, id, x, y, z):
"""Overwrite an existing contour plot.
:param int id: Id or index of contour plot to overwrite. The plot at the given id must already be a contour plot.
:param array x: x-axis of length N. Does not have to be uniform.
:param array y: y-axis of length M. Does not have to be uniform.
:param array z: z-data, MxN array.
"""
if(len(x) != z.shape[1]):
raise ValueError("Length of x and z arrays is not compatible!")
if(len(y) != z.shape[0]):
raise ValueError("Length of y and z arrays is not compatible!")
id = abs(int(id))
if(id >= len(self.data)):
raise ValueError("ID of plot element out of range!")
if(self.data_types[id] != "contour"):
raise ValueError("Element with given ID is not a contour plot!")
nd = (x, y, z)
self.data[id] = nd
self.data_extent[id] = (np.nanmin(x), np.nanmax(x), np.nanmin(y), np.nanmax(y))
self.contourDCValid = False
self.Refresh()
[docs] def setLine(self, id, x, y):
"""Overwrite an existing line / scatter plot.
:param int id: Id or index of line / scatter plot to overwrite. The plot at the given id must already be a line / scatter plot.
:param array x: x-axis. Can also be a single integer or floating point number.
:param array y: y-axis data with same shape as x.
"""
if(len(x) != len(y)):
raise ValueError("Length of x and y arrays is different!")
id = abs(int(id))
if(id >= len(self.data)):
raise ValueError("ID of plot element out of range!")
if(self.data_types[id] not in ["line", "scatter"]):
raise ValueError("Element with given ID is not a line / scatter plot!")
if isinstance(x, list) or isinstance(x, np.ndarray):
if(len(x) != len(y)):
raise ValueError("Length of x and y arrays is different!")
nd = np.transpose(np.array([x, y]))
else:
nd = np.array([[float(x), float(y)], ])
self.data[id] = nd
self.data_extent[id] = (np.nanmin(x), np.nanmax(x), np.nanmin(y), np.nanmax(y))
self.Refresh()
[docs] def setScatter(self, id, x, y):
"""Same as `setLine`.
"""
self.setLine(id, x, y)
[docs] def getNumberOfPlots(self):
"""Returns total number of plots in this plot window.
"""
return len(self.data)
[docs] def removeLastPlot(self):
"""Remove last added plot from the stack.
"""
self.data.pop()
self.data_extent.pop()
self.data_types.pop()
self.Refresh()
# returns the colormap with given label
# a colormap consists of two lists, the first containing the supporting points and the second containing the respective r,g,b values
[docs] def setColormap(self, cmap='rainbow'):
"""Activates the colormap with the given label for plotting.
A colormap consists of a tuple of lists, the first containing the supporting points in the interval 0 to 1 and the second containing the respective r,g,b values as lists.
For example, the 'rgb' colormap is defined as::
cmap = ([0, 0.5, 1],
[[0, 0, 255], [0, 255, 0], [255, 0, 0]])
:param mixed cmap: Either name of the colormap or a colormap-defining tuple (default='rgb'). Valid colormap names are 'hot', 'rainbow', 'rgb', 'blue', 'red', 'green', 'rwb' (red-white-blue).
"""
if isinstance(cmap, str):
if cmap == "hot":
self.colormap = ([0, 0.33, 0.66, 1], [[0, 0, 0], [255, 0, 0], [255, 255, 0], [255, 255, 255]])
elif cmap == "rainbow":
self.colormap = ([0, 0.2, 0.35, 0.5, 0.65, 0.8, 1], [[150, 0, 200], [0, 0, 255], [0, 255, 255], [0, 255, 0], [255, 255, 0], [255, 128, 0], [255, 0, 0]])
elif cmap == "rgb":
self.colormap = ([0, 0.5, 1], [[0, 0, 255], [0, 255, 0], [255, 0, 0]])
elif cmap == "blue":
self.colormap = ([0, 1], [[0, 0, 255], [255, 255, 255]])
elif cmap == "red":
self.colormap = ([0, 1], [[255, 0, 0], [255, 255, 255]])
elif cmap == "green":
self.colormap = ([0, 1], [[0, 255, 0], [255, 255, 255]])
else: # red-white-blue
self.colormap = ([0, 0.5, 1], [[0, 0, 255], [255, 255, 255], [255, 0, 0]])
elif isinstance(cmap, tuple) or isinstance(cmap, list):
if len(cmap) != 2 or len(cmap[0]) != len(cmap[1]) or len(cmap[1][0]) != 3:
print "ERROR: cmap is not a valid colormap."
return
self.colormap = cmap
else:
print "ERROR: Wrong datatype in setColormap! Has to be string or tuple."
return
# prepare colormap for image
self.imgcolormap = self.getZColor(np.arange(256), min=0, max=256)
# replot
self.contourDCValid = False
self.Refresh()
[docs] def setXLabel(self, label):
"""Set x-axis label.
"""
self.xlabel = label
self.Refresh()
[docs] def setYLabel(self, label):
"""Set y-axis label.
"""
self.ylabel = label
self.Refresh()
# adjust min, max of data to get nice looking axes ticks
# returns new min, max and a list of tickmark positions in data coordinates (real! data coordinates)
# if logscale = True, min and max are already log10() of data
[docs] def adjustAxis(self, _min, _max, logscale=False):
"""Internally used to adjust min, max and number of ticks to get nice looking axes.
:param float _min: Minimum data value.
:param float _max: Maximum data value.
:param bool logscale: Set to True when axis is using logscale, to adapt the number of ticks (default=False).
:returns: Suggested min, max and number of ticks.
"""
ticks = []
if _min == _max:
_min, _max = _min - 1, _max + 1
grossStep = (_max - _min) / 4.0
step = np.power(10, np.floor(np.log10(grossStep)))
if step == 0:
step = 1
if 5.0 * step < grossStep:
step = step * 5.0
elif 2.0 * step < grossStep:
step = step * 2.0
_min = np.floor(_min / step) * step
_max = np.ceil(_max / step) * step
if logscale:
# if less than 3 order of magnitude are shown, include sub-ticks
useSubTicks = (step < 1.0)
for i in range(int(_min), int(_max + 1), int(np.ceil(step))):
ticks.append(np.power(10, float(i)))
if useSubTicks and i < int(_max):
for j in range(2, 10):
ticks.append(np.float(j) * np.power(10, float(i)))
else:
nticks = int(np.ceil(_max / step) - np.floor(_min / step)) + 1
for i in range(nticks):
ticks.append(_min + float(i) * step)
return _min, _max, ticks
# parse all plot objects and determine the extent of the data x, y axes
[docs] def getDataMinMax(self):
"""Internally used to get the extent of data along x, y and z-axes.
"""
self.plotXMin = 0
self.plotXMax = 1
self.plotYMin = 0
self.plotYMax = 1
self.plotZMin = 0
self.plotZMax = 1
# there is some data, get min / max
if self.data:
self.plotXMin = np.amin(np.array(self.data_extent)[:, 0])
self.plotXMax = np.amax(np.array(self.data_extent)[:, 1])
self.plotYMin = np.amin(np.array(self.data_extent)[:, 2])
self.plotYMax = np.amax(np.array(self.data_extent)[:, 3])
if self.logx:
self.plotXMin = np.nan_to_num(np.log10(self.plotXMin))
self.plotXMax = np.nan_to_num(np.log10(self.plotXMax))
if self.logy:
self.plotYMin = np.nan_to_num(np.log10(self.plotYMin))
self.plotYMax = np.nan_to_num(np.log10(self.plotYMax))
# check for contour plot for z-range
if 'contour' in self.data_types:
zmin = []
zmax = []
for i, pl in enumerate(self.data):
if self.data_types[i] == "contour":
zmin.append(np.nanmin(pl[2]))
zmax.append(np.nanmax(pl[2]))
self.plotZMin = np.nanmin(zmin)
self.plotZMax = np.nanmax(zmax)
if self.logz:
self.plotZMin = np.nan_to_num(np.log10(self.plotZMin))
self.plotZMax = np.nan_to_num(np.log10(self.plotZMax))
if self.plotZMin == self.plotZMax:
self.plotZMin, self.plotZMax = self.plotZMin - 1, self.plotZMax + 1
if self.plotXMin == self.plotXMax:
self.plotXMin, self.plotXMax = self.plotXMin - 1, self.plotXMax + 1
if self.plotYMin == self.plotYMax:
self.plotYMin, self.plotYMax = self.plotYMin - 1, self.plotYMax + 1
# get a nice, consistent formatting of the labels
[docs] def interpolateColor(self, alpha):
"""Used internally to calculate the rgb-values for a scalar alpha (0..1) and the currently set colormap.
:param float alpha: Scalar between 0 and 1.
:returns: RGB-tuple.
"""
x1 = np.array(self.colormap[0])
colors1 = np.array(self.colormap[1], dtype=float)
if alpha <= x1[0]:
return colors1[0]
elif alpha >= x1[-1]:
return colors1[-1]
else:
x2 = np.roll(x1, -1, axis=0)
colors2 = np.roll(colors1, -1, axis=0)
A = np.where(alpha >= x1, np.where(alpha <= x2, 1, 0), 0)
B = ((alpha - x1) / (x2 - x1) * colors2.T + (x2 - alpha) / (x2 - x1) * colors1.T).T
C = (B.T * A).T
return np.sum(C, axis=0)
# get color for z value from colormap; returns r, g, b tuple
# if min = max, use the data range stored in self.plotZMin / Max
[docs] def getZColor(self, z, min=-1, max=-1):
"""Get RGB-tuples for z-values according to current colormap.
When `min == max`, the full z-extent of the data is used for mapping.
:param mixed z: Single z-value, 1d or 2d-array of z-values.
:param float min: Minimum z value that gets mapped onto 0.
:param float max: Maximum z value that gets mapped onto 1.
:returns: RGB-tuples with same shape as z.
"""
if min == max:
min = self.plotZMin
max = self.plotZMax
# get position in colormap
a = (z.astype(float) - min) / (max - min)
if isinstance(a, float):
return self.interpolateColor(a).astype(np.uint8)
else:
col = np.array(map(self.interpolateColor, a.flatten())).astype(np.uint8)
try:
return col.reshape(a.shape[0], a.shape[1], 3)
except:
return col.reshape(a.shape[0], 3)
# get mapping prefactors from data to dc space and vice versa
# min/max values are already in log10 when logscale is used
[docs] def prepareMapping(self):
"""Internally used to prepare the mapping from data to screen coordinates and vice-versa.
"""
self.map_ax = float(self.dcright - self.dcleft) / float(self.plotXMax - self.plotXMin)
self.map_bx = float(self.dcleft - self.map_ax * self.plotXMin)
self.map_ay = float(self.dctop - self.dcbottom) / float(self.plotYMax - self.plotYMin)
self.map_by = float(self.dcbottom - self.map_ay * self.plotYMin)
# convert data coordinates to screen coordinates and back
# screen coordinates are int while data coordinates are float
# accepts single numbers as well as arrays
[docs] def data2screen(self, x, y):
"""Convert data coordinates x, y to screen coordinates.
:param mixed x: Single x value or x-axis.
:param mixed y: Single y value or y-axis with same shape as x.
:returns: i, j pixel coordinates (integers) with same shapes as x, y.
"""
if isinstance(x, list):
x = np.array(x)
y = np.array(y)
if self.logx:
x = np.nan_to_num(np.log10(x))
if self.logy:
y = np.nan_to_num(np.log10(y))
if isinstance(x, np.ndarray):
return (self.map_ax * x + self.map_bx).astype(int), (self.map_ay * y + self.map_by).astype(int)
return int(self.map_ax * x + self.map_bx), int(self.map_ay * y + self.map_by)
[docs] def screen2data(self, i, j):
"""Convert screen coordinates i, j to data coordinates.
:param mixed i: Single i value or i-axis.
:param mixed j: Single j value or j-axis with same shape as i.
:returns: x, y data coordinates (float) with same shapes as i, j.
"""
if isinstance(i, list):
i = np.array(i)
j = np.array(j)
if isinstance(i, np.ndarray):
x = (i.astype(float) - self.map_bx) / self.map_ax
y = (j.astype(float) - self.map_by) / self.map_ay
else:
x = (float(i) - self.map_bx) / self.map_ax
y = (float(j) - self.map_by) / self.map_ay
if self.logx:
x = np.power(10, x)
if self.logy:
y = np.power(10, y)
return x, y
# DRAWING FUNCTIONS
[docs] def Draw(self, dc):
"""Internally used to draw the plots to a device context (dc).
Plotting order is contour plots first, followed by image plots and line / scatter plots and axes, labels and cursors.
"""
# Get the actual client size of ourselves
self.dcwidth, self.dcheight = self.GetClientSize()
if not self.dcwidth or not self.dcheight:
return
# set text properties
dc.SetFont(self.font)
dc.SetTextBackground(self.bgcolor)
dc.SetTextForeground(self.axiscolor)
# get extent of data and adjust ticks to get nice axes
self.getDataMinMax()
if self.tightx:
_, _, self.XTicks = self.adjustAxis(self.plotXMin, self.plotXMax, self.logx)
else:
self.plotXMin, self.plotXMax, self.XTicks = self.adjustAxis(self.plotXMin, self.plotXMax, self.logx)
if self.tighty:
_, _, self.YTicks = self.adjustAxis(self.plotYMin, self.plotYMax, self.logy)
else:
self.plotYMin, self.plotYMax, self.YTicks = self.adjustAxis(self.plotYMin, self.plotYMax, self.logy)
# format labels
self.lblx = self.formatLabels(self.XTicks, self.logx)
self.lbly = self.formatLabels(self.YTicks, self.logy)
# get label sizes
lblwidth = 0
for l in self.lbly:
tmp, _ = dc.GetTextExtent(l)
if tmp > lblwidth:
lblwidth = tmp
_, lblheight = dc.GetTextExtent("Text")
# determine optimal margins
self.bottommargin = 2 * lblheight
if(self.xlabel != ""):
self.bottommargin += lblheight
self.leftmargin = lblheight + lblwidth
if(self.ylabel != ""):
self.leftmargin += lblheight
# get coordinates of plot area in dc
self.dcleft = self.leftmargin
self.dctop = 2 * lblheight
self.dcright = self.dcwidth - lblheight
self.dcbottom = self.dcheight - self.bottommargin
# now prepare mapping prefactors between data and screen
self.prepareMapping()
# erase dc by filling with background color
dc.SetBackground(wx.Brush(self.bgcolor, wx.SOLID))
dc.Clear()
# plot all contour plots FIRST
# this is important as this is the image that gets stored in the memoryDC
if 'contour' in self.data_types:
if not self.contourDCValid:
for i, d in enumerate(self.data):
if self.data_types[i] == "contour":
self.drawContour(dc, d)
# clear old bitmap
self.contourDC.SelectObject(wx.NullBitmap)
# create new bitmap
bmp = wx.EmptyBitmap(self.dcright - self.dcleft + 1, self.dcbottom - self.dctop + 1)
self.contourDC.SelectObject(bmp)
# copy dc data
self.contourDC.Blit(0, 0, self.dcright - self.dcleft + 1, self.dcbottom - self.dctop + 1, dc, self.dcleft, self.dctop)
self.contourDCValid = True
else:
wsrc, hsrc = self.contourDC.GetSize()
dc.StretchBlit(self.dcleft, self.dctop, self.dcright - self.dcleft + 1, self.dcbottom - self.dctop + 1, self.contourDC, 0, 0, wsrc, hsrc)
# now plot all images
if 'image' in self.data_types:
for i, d in enumerate(self.data):
if self.data_types[i] == "image":
self.drawImage(dc, d)
# now plot all line plots
if 'line' in self.data_types or 'scatter' in self.data_types:
self.lineColorIter = itertools.cycle(self.linescolor) # reset the iterator
for i, d in enumerate(self.data):
if self.data_types[i] == "line" or self.data_types[i] == "scatter":
self.drawLine(dc, d, self.data_types[i], (self.mouseN == i))
# finally plot the axes
self.drawAxes(dc)
[docs] def drawImage(self, dc, img):
"""Internally used to draw an image (img) to a device context (dc).
"""
# set up a dcclipper to constrain the lineart to the plot area
wx.DCClipper(dc, self.dcleft, self.dctop, self.dcright - self.dcleft + 1, self.dcbottom - self.dctop + 1)
# create a memory DC from the stored bitmap
memdc = wx.MemoryDC()
memdc.SelectObject(self.images[img[2]])
wsrc, hsrc = memdc.GetSize()
# get destination coordinates on screen
x0, y0 = self.data2screen(img[0][0], img[1][0])
x1, y1 = self.data2screen(img[0][1], img[1][1])
# copy
dc.StretchBlit(x0, y0, x1 - x0, y1 - y0, memdc, 0, 0, wsrc, hsrc)
# destroy memoryDC
memdc.SelectObject(wx.NullBitmap)
[docs] def drawAxes(self, dc):
"""Internally used to draw the axes plus labels. Takes also care of crosshair, mouse coordinates and delta.
"""
# make ticks along x axes
x = []
for t in self.XTicks:
xs, _ = self.data2screen(t, 1)
x.append(xs)
y = []
for t in self.YTicks:
_, ys = self.data2screen(1, t)
y.append(ys)
# draw grid
if self.showGrid:
dc.SetPen(self.gridPen)
for i in range(len(x)):
if x[i] >= self.dcleft and x[i] <= self.dcright:
dc.DrawLine(x[i], self.dctop, x[i], self.dcbottom)
for i in range(len(y)):
if y[i] >= self.dctop and y[i] <= self.dcbottom:
dc.DrawLine(self.dcleft, y[i], self.dcright, y[i])
# finish tickmarks
dc.SetPen(self.axisPen)
for i in range(len(x)):
if x[i] >= self.dcleft and x[i] <= self.dcright:
dc.DrawLine(x[i], self.dcbottom, x[i], self.dcbottom - self.ticklength)
dc.DrawLine(x[i], self.dctop, x[i], self.dctop + self.ticklength)
txtW, txtH = dc.GetTextExtent(self.lblx[i])
dc.DrawText(self.lblx[i], x[i] - txtW / 2, self.dcbottom + txtH / 2)
for i in range(len(y)):
if y[i] >= self.dctop and y[i] <= self.dcbottom:
dc.DrawLine(self.dcleft, y[i], self.dcleft + self.ticklength, y[i])
dc.DrawLine(self.dcright, y[i], self.dcright - self.ticklength, y[i])
txtW, txtH = dc.GetTextExtent(self.lbly[i])
dc.DrawText(self.lbly[i], self.dcleft - txtW - txtH / 2, y[i] - txtH / 2)
# make a nice box around
dc.DrawLine(self.dcleft, self.dctop, self.dcleft, self.dcbottom)
dc.DrawLine(self.dcleft, self.dcbottom, self.dcright, self.dcbottom)
dc.DrawLine(self.dcright, self.dcbottom, self.dcright, self.dctop)
dc.DrawLine(self.dcright, self.dctop, self.dcleft, self.dctop)
# write axis labels
if self.xlabel != "":
txtW, txtH = dc.GetTextExtent(self.xlabel)
dc.DrawText(self.xlabel, (self.dcleft + self.dcright) / 2 - txtW / 2, self.dcheight - 3 * txtH / 2)
if self.ylabel != "":
txtW, txtH = dc.GetTextExtent(self.ylabel)
dc.DrawRotatedText(self.ylabel, txtH / 2, (self.dctop + self.dcbottom) / 2 + txtW / 2, 90)
# display mouse coordinates
mx, my = self.mousePos.Get()
ix, iy = self.screen2data(mx, my)
txt = "(" + self.formatNumber(ix) + ", " + self.formatNumber(iy) + ")"
txtW, txtH = dc.GetTextExtent(txt)
dc.DrawText(txt, self.dcleft, self.dctop - txtH - 3)
# if show cross, draw cross through mouse position
if self.showCross and self.mouseIn:
dc.SetPen(self.crossPenDark)
dc.DrawLine(self.dcleft, my, self.dcright, my)
dc.DrawLine(mx, self.dctop, mx, self.dcbottom)
dc.SetPen(self.crossPenLight)
dc.DrawLine(mx + 1, self.dctop, mx + 1, self.dcbottom)
dc.DrawLine(self.dcleft, my + 1, self.dcright, my + 1)
# write delta of cursors?
# this is the last part of the drawing routine as it changes the default font
if self.mouseX1 is not None and self.mouseX2 is not None:
dc.SetTextForeground(self.deltafontcolor)
dc.SetFont(self.deltafont)
txt = "A-B = " + self.formatNumber(self.cursor1[1] - self.cursor2[1])
txtW, txtH = dc.GetTextExtent(txt)
dc.DrawText(txt, self.dcright - txtW - 5, self.dctop - txtH - 5)
[docs] def drawLine(self, dc, data, type='line', cursors=False):
"""Used internally to draw a line/scatter plot (data, type) to a device context (dc).
If cursors is True, draw cursors accordingly.
"""
# create pen
color = next(self.lineColorIter)
dc.SetPen(wx.Pen(color, width=self.linewidth, style=wx.PENSTYLE_SOLID))
dc.SetBrush(wx.Brush(color))
if cursors:
if self.mouseX1 is not None:
self.cursor1 = self.drawCursor(dc, data, self.mouseX1, "A")
if self.mouseX2 is not None:
self.cursor2 = self.drawCursor(dc, data, self.mouseX2, "B")
# set up a dcclipper to constrain the lineart to the plot area
wx.DCClipper(dc, self.dcleft, self.dctop, self.dcright - self.dcleft, self.dcbottom - self.dctop)
x, y = self.data2screen(*(np.array(data).T))
N = len(x)
# if length = 1 or type is scatter, draw a point
if type == 'scatter' or N == 1:
for i in range(N):
dc.DrawCircle(x[i], y[i], self.markersize)
else: # draw line
for i in range(N - 1):
dc.DrawLine(x[i], y[i], x[i + 1], y[i + 1])
[docs] def drawCursor(self, dc, data, x, label):
"""Used internally to draw cursors.
"""
# get data coordinates - limit cursor to valid data range
if x <= np.amin(data[:, 0]):
x = np.amin(data[:, 0])
y = data[np.argmin(data[:, 0]), 1]
elif x >= np.amax(data[:, 0]):
x = np.amax(data[:, 0])
y = data[np.argmax(data[:, 0]), 1]
else:
i0 = np.argmin(np.absolute(data[:, 0] - x))
y = (data[i0 + 1, 1] - data[i0, 1]) / (data[i0 + 1, 0] - data[i0, 0]) * (x - data[i0, 0]) + data[i0, 1]
# go back to screen coordinates
i, j = self.data2screen(x, y)
# draw cursor
oldpen = dc.GetPen()
dc.DrawLine(i, self.dcbottom, i, self.dctop)
dc.DrawLine(i - 5, j - 5, i + 5, j + 5)
dc.DrawLine(i - 5, j + 5, i + 5, j - 5)
dc.SetPen(wx.Pen(wx.Colour(0, 0, 0), width=self.linewidth, style=wx.PENSTYLE_DOT))
dc.DrawLine(i, self.dcbottom, i, self.dctop)
dc.DrawLine(i - 5, j - 5, i + 5, j + 5)
dc.DrawLine(i - 5, j + 5, i + 5, j - 5)
dc.SetPen(oldpen)
txtW, txtH = dc.GetTextExtent(label)
dc.DrawText(label, i - txtW - 5, self.dctop + 10)
txt = self.formatNumber(y) + " @ " + self.formatNumber(x)
dc.DrawText(txt, i + 5, self.dctop + 10)
return x, y
[docs] def drawContour(self, dc, cont):
"""Used internally to draw a contour plot (cont) to a device context (dc).
`cont` is a tuple consisting of x-axis, y-axis and z-data (2d).
"""
Nx, Ny = len(cont[0]), len(cont[1])
x, y, z = cont
x, y = self.data2screen(x, y)
# set up a dcclipper to constrain the plot to the plot area
wx.DCClipper(dc, self.dcleft, self.dctop, self.dcright - self.dcleft, self.dcbottom - self.dctop)
zavg = 0.25 * (z + np.roll(z, -1, axis=0) + np.roll(z, -1, axis=1) + np.roll(np.roll(z, -1, axis=0), -1, axis=0))
if self.logz:
zavg = np.nan_to_num(np.log10(zavg))
cols = self.getZColor(zavg)
for i in range(Nx - 1):
for j in range(Ny - 1):
x0, y0 = x[i], y[j]
x1, y1 = x[i + 1], y[j + 1]
if x0 > x1:
x0, x1 = x1, x0
if y0 > y1:
y0, y1 = y1, y0
color = wx.Colour(*cols[j, i])
dc.SetPen(wx.Pen(color, width=self.linewidth, style=wx.PENSTYLE_SOLID))
if x1 - x0 < 2:
if y1 - y0 < 2:
dc.DrawPoint(x0, y0)
else:
dc.DrawLine(x0, y0, x0, y1)
else:
if y1 - y0 < 2:
dc.DrawLine(x0, y0, x1, y0)
else:
dc.SetBrush(wx.Brush(color))
dc.DrawRectangle(x0, y0, x1 - x0 + 1, y1 - y0 + 1)
# simple plot window with a single plot canvas
[docs]class PlotFrame(wx.Frame):
"""A simple plot window containing a single plot canvas which can be accessed as `plotCanvas`.
"""
def __init__(self, *args, **kwargs):
wx.Frame.__init__(self, *args, **kwargs)
self.plotCanvas = FSRSPlot(self)
# plot window with two canvases in a vertical splitter window
[docs]class DualPlotFrame(wx.Frame):
"""A simple plot window containing a two vertically stacked plot canvases.
The upper / lower one can be accessed as `upperPlotCanvas` and `lowerPlotCanvas`.
"""
def __init__(self, *args, **kwargs):
wx.Frame.__init__(self, *args, **kwargs)
size = self.GetClientSize()
self.splitter = wx.SplitterWindow(self, style=wx.SP_3DSASH | wx.SP_NOBORDER)
self.upperPlotCanvas = FSRSPlot(self.splitter)
self.lowerPlotCanvas = FSRSPlot(self.splitter)
self.splitter.SplitHorizontally(self.upperPlotCanvas, self.lowerPlotCanvas, sashPosition=size[1] / 2)
# ----------------------------------------------------------------------------------------------------------------------------
# for development
if __name__ == '__main__':
# setup a wxPython app
app = wx.App()
# get an instance of a plot frame
frame = PlotFrame(None, title="pyFSRS - Test plot panel", size=(640, 480))
frame.Show()
frame.plotCanvas.setColormap("blue")
# get some nice data
x = np.linspace(-10, 10, 64)
y = np.linspace(-10, 10, 64)
X, Y = np.meshgrid(x, y)
z = np.exp(-(X**2 +Y**2) / 5.0**2) * np.sin(X)
frame.plotCanvas.addImage(x, y, z)
# frame.plotCanvas.addLine(x, -5 + 10.0 * np.exp(-x**2 / 5.0**2))
# show frame and start app
app.MainLoop()