Skip to content

Commit

Permalink
refactor from pyqtgraph to matplotlib (#214)
Browse files Browse the repository at this point in the history
* MNT #213 first steps

* DOC #213

* MNT #213 finish the refactor

* STY #213 per 'black'

* TST #213 refactor unit tests

* STY #213 isort

* TST #213 local passed, CI failed

* TST #213 defensive version-based test

* MNT #213 title & subtitle

* MNT #213 an edge case that happens during unit testing

* MNT #213 re-arrange that code

* MNT #213 per review

* MNT #213 add one color: more color&symbol permutations
  • Loading branch information
prjemian authored Mar 22, 2024
1 parent e59de23 commit c2c2f98
Show file tree
Hide file tree
Showing 6 changed files with 229 additions and 67 deletions.
2 changes: 1 addition & 1 deletion env.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@ channels:
- defaults

dependencies:
- matplotlib
- python >=3.8,<3.12
- pip
- pyqt =5
- pyqtgraph
- pyRestTable
- qt =5

Expand Down
195 changes: 164 additions & 31 deletions gemviz/chartview.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,27 @@
~auto_color
~auto_symbol
~ChartView
.. seealso:: https://matplotlib.org/stable/users/index.html
Plot Symbols
# https://matplotlib.org/stable/gallery/lines_bars_and_markers/marker_reference.html
# from matplotlib.lines import Line2D
# print(Line2D.markers)
"""

import datetime
from itertools import cycle

import pyqtgraph as pg
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT as NavigationToolbar
from matplotlib.figure import Figure
from PyQt5 import QtWidgets

TIMESTAMP_LIMIT = datetime.datetime.fromisoformat("1990-01-01").timestamp()

# https://pyqtgraph.readthedocs.io/en/latest/api_reference/graphicsItems/scatterplotitem.html#pyqtgraph.ScatterPlotItem.setSymbol
# https://developer.mozilla.org/en-US/docs/Web/CSS/named-color
# Do NOT sort these colors alphabetically! There should be obvious
# contrast between adjacent colors.
PLOT_COLORS = """
r g b c m
goldenrod
Expand All @@ -30,15 +37,32 @@
teal
olive
lightcoral
gold
cornflowerblue
forestgreen
salmon
""".split()
PLOT_SYMBOLS = """o + x star s d t t2 t3""".split()
"""
Select subset of the MatPlotLib named colors.
Do **NOT** sort these colors alphabetically! There should be obvious
contrast between adjacent colors.
.. seealso:: https://matplotlib.org/stable/gallery/color/named_colors.html
.. seealso:: https://developer.mozilla.org/en-US/docs/Web/CSS/named-color
"""

PLOT_SYMBOLS = """o + x * s d ^ v""".split()
"""
Select subset of the MatPlotLib marker symbols.
pg.setConfigOption("background", "w")
pg.setConfigOption("foreground", "k")
GRID_OPACITY = 0.1
To print the full dictionary of symbols available::
from matplotlib.lines import Line2D
print(Line2D.markers)
.. seealso:: https://matplotlib.org/stable/gallery/lines_bars_and_markers/marker_reference.html
"""

_AUTO_COLOR_CYCLE = cycle(PLOT_COLORS)
_AUTO_SYMBOL_CYCLE = cycle(PLOT_SYMBOLS)
Expand All @@ -56,12 +80,11 @@ def auto_symbol():

class ChartView(QtWidgets.QWidget):
"""
PyqtGraph PlotWidget
MatPlotLib Figure
.. autosummary::
~plot
~setAxisDateTime
~setAxisLabel
~setAxisUnits
~setBottomAxisText
Expand All @@ -79,19 +102,18 @@ def __init__(self, parent, **kwargs):
QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Preferred
)

self.plot_widget = pg.PlotWidget()
self.plot_widget.addLegend()
self.plot_widget.plotItem.showAxes(True)
self.plot_widget.plotItem.showGrid(x=True, y=True, alpha=GRID_OPACITY)
# see: https://stackoverflow.com/a/70200326
label = pg.LabelItem(
f"plot: {datetime.datetime.now()}", color="lightgrey", size="8pt"
)
label.setParentItem(self.plot_widget.plotItem)
label.anchor(itemPos=(0, 1), parentPos=(0, 1))
# Remember these Matplotlib figure, canvas, and axes objects.
self.figure = Figure()
self.canvas = FigureCanvas(self.figure)
self.main_axes = self.figure.add_subplot(111)

# Adjust margins
self.figure.subplots_adjust(bottom=0.1, top=0.9, right=0.92)
self.setOptions()

config = {
"title": self.setPlotTitle,
"subtitle": self.setPlotSubtitle,
"y": self.setLeftAxisText,
"x": self.setBottomAxisText,
"x_units": self.setBottomAxisUnits,
Expand All @@ -102,42 +124,153 @@ def __init__(self, parent, **kwargs):
func(kwargs.get(k))

# QWidget Layout
layout = QtWidgets.QHBoxLayout()
layout = QtWidgets.QVBoxLayout()
self.setLayout(layout)
# Add directly unless we plan to use the toolbar later.
layout.addWidget(NavigationToolbar(self.canvas, self))
layout.addWidget(self.canvas)

# plot
size.setHorizontalStretch(4)
self.plot_widget.setSizePolicy(size)
layout.addWidget(self.plot_widget)

self.curves = {} # all the curves on the graph, key = label

def addCurve(self, *args, **kwargs):
"""Add to graph."""
plot_obj = self.main_axes.plot(*args, **kwargs)
self.updatePlot()
# Add to the dictionary
label = kwargs.get("label")
if label is None:
raise KeyError("This curve has no label.")
self.curves[label] = plot_obj[0], *args

def option(self, key, default=None):
return self.plotOptions().get(key, default)

def plot(self, *args, **kwargs):
return self.plot_widget.plot(*args, **kwargs)
"""
Plot from the supplied (x, y) or (y) data.
PARAMETERS
- args tuple: x & y xarray.DataArrays. When only y is supplied, x will
be the index.
- kwargs (dict): dict(str, obj)
"""
self.setOptions(**kwargs.get("plot_options", {}))
ds_options = kwargs.get("ds_options", kwargs)
self.main_axes.axis("on")

label = ds_options.get("label")
if label is None:
raise KeyError("This curve has no label.")
if label not in self.curves:
self.addCurve(*args, **ds_options)

def plotOptions(self):
return self._plot_options

def setAxisDateTime(self, choice):
if choice:
item = pg.DateAxisItem(orientation="bottom")
self.plot_widget.setAxisItems({"bottom": item})
pass # data provided in datetime objects

def setAxisLabel(self, axis, text):
self.plot_widget.plotItem.setLabel(axis, text)
set_axis_label_method = {
"bottom": self.main_axes.set_xlabel,
"left": self.main_axes.set_ylabel,
}[axis]
set_axis_label_method(text)

def setAxisUnits(self, axis, text):
self.plot_widget.plotItem.axes[axis]["item"].labelUnits = text
pass # TODO: not implemented yet

def setBottomAxisText(self, text):
self.setAxisLabel("bottom", text)

def setBottomAxisUnits(self, text):
self.setAxisUnits("bottom", text)

def setConfigPlot(self, grid=True):
self.setLeftAxisText(self.ylabel())
self.setBottomAxisText(self.xlabel())
self.setPlotTitle(self.title())
if grid:
self.main_axes.grid(True, color="#cccccc", linestyle="-", linewidth=0.5)
else:
self.main_axes.grid(False)
self.canvas.draw()

def setLeftAxisText(self, text):
self.setAxisLabel("left", text)

def setLeftAxisUnits(self, text):
self.setAxisUnits("left", text)

def setOption(self, key, value):
self._plot_options[key] = value

def setOptions(self, **options):
self._plot_options = options

def setPlotTitle(self, text):
self.plot_widget.plotItem.setTitle(text)
if text is not None:
self.figure.suptitle(text)

def setPlotSubtitle(self, text):
if text is not None:
self.main_axes.set_title(text, size=7, x=1, ha="right", color="lightgrey")

def setSubtitle(self, text):
self.setOption("subtitle", text)

def setTitle(self, text):
self.setOption("title", text)

def setXLabel(self, text):
self.setOption("xlabel", text)

def setYLabel(self, text):
self.setOption("ylabel", text)

def subtitle(self):
return self.option("subtitle")

def title(self):
return self.option("title")

def updateLegend(self):
labels = self.main_axes.get_legend_handles_labels()[1]
valid_labels = [label for label in labels if not label.startswith("_")]
if valid_labels:
self.main_axes.legend()

def updatePlot(self):
"""Update annotations (titles & axis labels)."""
# TODO: title -- first and last start dates of all curves
self.setPlotTitle("data from ... (TODO)")

iso8601 = datetime.datetime.now().isoformat(sep=" ", timespec="seconds")
subtitle = f"plotted: {iso8601}"
if self.parent is not None:
cat_name = self.parent.catalogName() or ""
subtitle = f"catalog={cat_name!r} {subtitle}"
self.setPlotSubtitle(subtitle)

self.setBottomAxisText(self.xlabel())
self.setLeftAxisText(self.ylabel())

# Recompute the axes limits and autoscale:
self.main_axes.relim()
self.main_axes.autoscale_view()
self.updateLegend()
self.setConfigPlot()
self.canvas.draw()

def xlabel(self):
return self.option("xlabel")

def ylabel(self):
return self.option("ylabel")


# -----------------------------------------------------------------------------
Expand Down
20 changes: 13 additions & 7 deletions gemviz/select_stream_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@
~to_datasets
"""

import datetime
import logging

import xarray
from PyQt5 import QtCore
from PyQt5 import QtWidgets

Expand Down Expand Up @@ -139,6 +141,12 @@ def to_datasets(run, stream_name, selections, scan_id=None):
if x_axis == "time" and min(x_data) > chartview.TIMESTAMP_LIMIT:
x_units = ""
x_datetime = True
x_data = xarray.DataArray(
data=list(map(datetime.datetime.fromtimestamp, x_data[x_axis].data)),
name=x_axis,
# dims=x_axis,
# coords=?,
)

datasets = []
y_selections = selections.get("Y", [])
Expand All @@ -160,13 +168,11 @@ def to_datasets(run, stream_name, selections, scan_id=None):
)

run_uid = run.get_run_md("start", "uid")
ds_options["name"] = f"{y_axis} ({run.summary()} {run_uid[:7]})"
ds_options["pen"] = color # line color
ds_options["symbol"] = symbol
ds_options["symbolBrush"] = color # fill color
ds_options["symbolPen"] = color # outline color
# size in pixels (if pxMode==True, then data coordinates.)
ds_options["symbolSize"] = 10 # default: 10
# keys used here must match the plotting back-end (matplotlib)
ds_options["label"] = f"{y_axis} ({run.summary()} {run_uid[:7]})"
ds_options["color"] = color # line color
ds_options["marker"] = symbol
ds_options["markersize"] = 5 # default: 10

if x_data is None:
ds = [y_data] # , title=f"{y_axis} v index"
Expand Down
4 changes: 2 additions & 2 deletions gemviz/tapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -250,8 +250,8 @@ def stream_metadata(self, stream_name=None):
def summary(self):
"""Summary (text) of this run."""
return (
f"{self.get_run_md('start', 'plan_name', '')}"
f" {self.get_run_md('start', 'scan_id', '?')}"
f"{self.get_run_md('start', 'scan_id', '?')}"
f" {self.get_run_md('start', 'plan_name', '')}"
f" {self.get_run_md('start', 'title', '')}"
).strip()

Expand Down
Loading

0 comments on commit c2c2f98

Please sign in to comment.