From 5976acbe76de05cb06f08e3f7e3dc407ba9acde6 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sun, 30 Oct 2022 21:11:14 -0400 Subject: [PATCH 1/8] `PyQt5` + `pyqtgraph` import updates (`QtGui -> `QtWidgets`) --- piker/ui/_annotate.py | 3 ++- piker/ui/_editors.py | 26 +++++++++++++++++++------- piker/ui/_exec.py | 18 ++++++++++++------ piker/ui/_ohlc.py | 3 ++- piker/ui/_window.py | 34 +++++++++++++++++++++------------- 5 files changed, 56 insertions(+), 28 deletions(-) diff --git a/piker/ui/_annotate.py b/piker/ui/_annotate.py index 32a67980..4bad2f66 100644 --- a/piker/ui/_annotate.py +++ b/piker/ui/_annotate.py @@ -111,7 +111,8 @@ class LevelMarker(QGraphicsPathItem): # get polygon and scale super().__init__() - self.scale(size, size) + # self.setScale(size, size) + self.setScale(size) # interally generates path self._style = None diff --git a/piker/ui/_editors.py b/piker/ui/_editors.py index 2633cf40..f025307e 100644 --- a/piker/ui/_editors.py +++ b/piker/ui/_editors.py @@ -26,7 +26,19 @@ from typing import ( ) import pyqtgraph as pg -from pyqtgraph import ViewBox, Point, QtCore, QtGui +from pyqtgraph import ( + ViewBox, + Point, + QtCore, + QtWidgets, +) +from PyQt5.QtGui import ( + QColor, +) +from PyQt5.QtWidgets import ( + QLabel, +) + from pyqtgraph import functions as fn from PyQt5.QtCore import QPointF import numpy as np @@ -240,7 +252,7 @@ class LineEditor(Struct): return lines -class SelectRect(QtGui.QGraphicsRectItem): +class SelectRect(QtWidgets.QGraphicsRectItem): def __init__( self, @@ -249,12 +261,12 @@ class SelectRect(QtGui.QGraphicsRectItem): ) -> None: super().__init__(0, 0, 1, 1) - # self.rbScaleBox = QtGui.QGraphicsRectItem(0, 0, 1, 1) + # self.rbScaleBox = QGraphicsRectItem(0, 0, 1, 1) self.vb = viewbox self._chart: 'ChartPlotWidget' = None # noqa # override selection box color - color = QtGui.QColor(hcolor(color)) + color = QColor(hcolor(color)) self.setPen(fn.mkPen(color, width=1)) color.setAlpha(66) self.setBrush(fn.mkBrush(color)) @@ -262,7 +274,7 @@ class SelectRect(QtGui.QGraphicsRectItem): self.hide() self._label = None - label = self._label = QtGui.QLabel() + label = self._label = QLabel() label.setTextFormat(0) # markdown label.setFont(_font.font) label.setMargin(0) @@ -299,8 +311,8 @@ class SelectRect(QtGui.QGraphicsRectItem): # TODO: get bg color working palette.setColor( self._label.backgroundRole(), - # QtGui.QColor(chart.backgroundBrush()), - QtGui.QColor(hcolor('papas_special')), + # QColor(chart.backgroundBrush()), + QColor(hcolor('papas_special')), ) def update_on_resize(self, vr, r): diff --git a/piker/ui/_exec.py b/piker/ui/_exec.py index 090b783a..b0fa6446 100644 --- a/piker/ui/_exec.py +++ b/piker/ui/_exec.py @@ -20,19 +20,24 @@ Trio - Qt integration Run ``trio`` in guest mode on top of the Qt event loop. All global Qt runtime settings are mostly defined here. """ +from __future__ import annotations from typing import ( Callable, Any, Type, + TYPE_CHECKING, ) import platform import traceback # Qt specific import PyQt5 # noqa -from pyqtgraph import QtGui +from PyQt5.QtWidgets import ( + QWidget, + QMainWindow, + QApplication, +) from PyQt5 import QtCore -# from PyQt5.QtGui import QLabel, QStatusBar from PyQt5.QtCore import ( pyqtRemoveInputHook, Qt, @@ -49,6 +54,7 @@ from ..log import get_logger from ._pg_overrides import _do_overrides from . import _style + log = get_logger(__name__) # pyqtgraph global config @@ -76,17 +82,17 @@ if platform.system() == "Windows": def run_qtractor( func: Callable, args: tuple, - main_widget_type: Type[QtGui.QWidget], + main_widget_type: Type[QWidget], tractor_kwargs: dict[str, Any] = {}, - window_type: QtGui.QMainWindow = None, + window_type: QMainWindow = None, ) -> None: # avoids annoying message when entering debugger from qt loop pyqtRemoveInputHook() - app = QtGui.QApplication.instance() + app = QApplication.instance() if app is None: - app = PyQt5.QtWidgets.QApplication([]) + app = QApplication([]) # TODO: we might not need this if it's desired # to cancel the tractor machinery on Qt loop diff --git a/piker/ui/_ohlc.py b/piker/ui/_ohlc.py index dbe4c18e..048861d0 100644 --- a/piker/ui/_ohlc.py +++ b/piker/ui/_ohlc.py @@ -32,6 +32,7 @@ from PyQt5.QtGui import QPainterPath from .._profile import pg_profile_enabled, ms_slower_then from ._style import hcolor from ..log import get_logger +from .._profile import Profiler if TYPE_CHECKING: from ._chart import LinkedSplits @@ -170,7 +171,7 @@ class BarItems(pg.GraphicsObject): ) -> None: - profiler = pg.debug.Profiler( + profiler = Profiler( disabled=not pg_profile_enabled(), ms_threshold=ms_slower_then, ) diff --git a/piker/ui/_window.py b/piker/ui/_window.py index e574da23..a2c43261 100644 --- a/piker/ui/_window.py +++ b/piker/ui/_window.py @@ -28,10 +28,19 @@ from typing import ( ) import uuid -from pyqtgraph import QtGui from PyQt5 import QtCore -from PyQt5.QtWidgets import QLabel, QStatusBar +from PyQt5.QtWidgets import ( + QWidget, + QMainWindow, + QApplication, + QLabel, + QStatusBar, +) +from PyQt5.QtGui import ( + QScreen, + QCloseEvent, +) from ..log import get_logger from ._style import _font_small, hcolor from ._chart import GodWidget @@ -153,7 +162,7 @@ class MultiStatus: self.bar.clearMessage() -class MainWindow(QtGui.QMainWindow): +class MainWindow(QMainWindow): # XXX: for tiling wms this should scale # with the alloted window size. @@ -176,12 +185,12 @@ class MainWindow(QtGui.QMainWindow): self._size: Optional[tuple[int, int]] = None @property - def mode_label(self) -> QtGui.QLabel: + def mode_label(self) -> QLabel: # init mode label if not self._status_label: - self._status_label = label = QtGui.QLabel() + self._status_label = label = QLabel() label.setStyleSheet( f"""QLabel {{ color : {hcolor('gunmetal')}; @@ -203,8 +212,7 @@ class MainWindow(QtGui.QMainWindow): def closeEvent( self, - - event: QtGui.QCloseEvent, + event: QCloseEvent, ) -> None: '''Cancel the root actor asap. @@ -244,8 +252,8 @@ class MainWindow(QtGui.QMainWindow): def on_focus_change( self, - last: QtGui.QWidget, - current: QtGui.QWidget, + last: QWidget, + current: QWidget, ) -> None: @@ -256,12 +264,12 @@ class MainWindow(QtGui.QMainWindow): name = getattr(current, 'mode_name', '') self.set_mode_name(name) - def current_screen(self) -> QtGui.QScreen: + def current_screen(self) -> QScreen: ''' Get a frickin screen (if we can, gawd). ''' - app = QtGui.QApplication.instance() + app = QApplication.instance() for _ in range(3): screen = app.screenAt(self.pos()) @@ -294,7 +302,7 @@ class MainWindow(QtGui.QMainWindow): ''' # https://stackoverflow.com/a/18975846 if not size and not self._size: - # app = QtGui.QApplication.instance() + # app = QApplication.instance() geo = self.current_screen().geometry() h, w = geo.height(), geo.width() # use approx 1/3 of the area of the screen by default @@ -331,7 +339,7 @@ class MainWindow(QtGui.QMainWindow): # singleton app per actor -_qt_win: QtGui.QMainWindow = None +_qt_win: QMainWindow = None def main_window() -> MainWindow: From 1d4fc6f327cd2339c26b7a3c83a61fcc6e405c55 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sun, 30 Oct 2022 21:11:27 -0400 Subject: [PATCH 2/8] Fork our latency tune-able profiler from `pyqtgraph.debug` Details of the original patch to upstream are in: https://github.com/pyqtgraph/pyqtgraph/pull/2281 Instead of trying to land this we've opted to just copy out that version of `.debug.Profiler` into our own internals (luckily the class is entirely self-contained) until such a time when we choose to find a better dependency as per https://github.com/pikers/piker/issues/337 --- piker/_profile.py | 184 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 184 insertions(+) diff --git a/piker/_profile.py b/piker/_profile.py index 697c3c3b..5262ffc6 100644 --- a/piker/_profile.py +++ b/piker/_profile.py @@ -18,7 +18,10 @@ Profiling wrappers for internal libs. """ +import os +import sys import time +from time import perf_counter from functools import wraps # NOTE: you can pass a flag to enable this: @@ -44,3 +47,184 @@ def timeit(fn): return res return wrapper + + +# Modified version of ``pyqtgraph.debug.Profiler`` that +# core seems hesitant to land in: +# https://github.com/pyqtgraph/pyqtgraph/pull/2281 +class Profiler(object): + ''' + Simple profiler allowing measurement of multiple time intervals. + + By default, profilers are disabled. To enable profiling, set the + environment variable `PYQTGRAPHPROFILE` to a comma-separated list of + fully-qualified names of profiled functions. + + Calling a profiler registers a message (defaulting to an increasing + counter) that contains the time elapsed since the last call. When the + profiler is about to be garbage-collected, the messages are passed to the + outer profiler if one is running, or printed to stdout otherwise. + + If `delayed` is set to False, messages are immediately printed instead. + + Example: + def function(...): + profiler = Profiler() + ... do stuff ... + profiler('did stuff') + ... do other stuff ... + profiler('did other stuff') + # profiler is garbage-collected and flushed at function end + + If this function is a method of class C, setting `PYQTGRAPHPROFILE` to + "C.function" (without the module name) will enable this profiler. + + For regular functions, use the qualified name of the function, stripping + only the initial "pyqtgraph." prefix from the module. + ''' + + _profilers = os.environ.get("PYQTGRAPHPROFILE", None) + _profilers = _profilers.split(",") if _profilers is not None else [] + + _depth = 0 + + # NOTE: without this defined at the class level + # you won't see apprpriately "nested" sub-profiler + # instance calls. + _msgs = [] + + # set this flag to disable all or individual profilers at runtime + disable = False + + class DisabledProfiler(object): + def __init__(self, *args, **kwds): + pass + + def __call__(self, *args): + pass + + def finish(self): + pass + + def mark(self, msg=None): + pass + + _disabledProfiler = DisabledProfiler() + + def __new__( + cls, + msg=None, + disabled='env', + delayed=True, + ms_threshold: float = 0.0, + ): + """Optionally create a new profiler based on caller's qualname. + + ``ms_threshold`` can be set to value in ms for which, if the + total measured time of the lifetime of this profiler is **less + than** this value, then no profiling messages will be printed. + Setting ``delayed=False`` disables this feature since messages + are emitted immediately. + + """ + if ( + disabled is True + or ( + disabled == 'env' + and len(cls._profilers) == 0 + ) + ): + return cls._disabledProfiler + + # determine the qualified name of the caller function + caller_frame = sys._getframe(1) + try: + caller_object_type = type(caller_frame.f_locals["self"]) + + except KeyError: # we are in a regular function + qualifier = caller_frame.f_globals["__name__"].split(".", 1)[-1] + + else: # we are in a method + qualifier = caller_object_type.__name__ + func_qualname = qualifier + "." + caller_frame.f_code.co_name + + if disabled == 'env' and func_qualname not in cls._profilers: + # don't do anything + return cls._disabledProfiler + + # create an actual profiling object + cls._depth += 1 + obj = super(Profiler, cls).__new__(cls) + obj._name = msg or func_qualname + obj._delayed = delayed + obj._markCount = 0 + obj._finished = False + obj._firstTime = obj._lastTime = perf_counter() + obj._mt = ms_threshold + obj._newMsg("> Entering " + obj._name) + return obj + + def __call__(self, msg=None): + """Register or print a new message with timing information. + """ + if self.disable: + return + if msg is None: + msg = str(self._markCount) + + self._markCount += 1 + newTime = perf_counter() + ms = (newTime - self._lastTime) * 1000 + self._newMsg(" %s: %0.4f ms", msg, ms) + self._lastTime = newTime + + def mark(self, msg=None): + self(msg) + + def _newMsg(self, msg, *args): + msg = " " * (self._depth - 1) + msg + if self._delayed: + self._msgs.append((msg, args)) + else: + print(msg % args) + + def __del__(self): + self.finish() + + def finish(self, msg=None): + """Add a final message; flush the message list if no parent profiler. + """ + if self._finished or self.disable: + return + + self._finished = True + if msg is not None: + self(msg) + + tot_ms = (perf_counter() - self._firstTime) * 1000 + self._newMsg( + "< Exiting %s, total time: %0.4f ms", + self._name, + tot_ms, + ) + + if tot_ms < self._mt: + # print(f'{tot_ms} < {self._mt}, clearing') + # NOTE: this list **must** be an instance var to avoid + # deleting common messages during GC I think? + self._msgs.clear() + # else: + # print(f'{tot_ms} > {self._mt}, not clearing') + + # XXX: why is this needed? + # don't we **want to show** nested profiler messages? + if self._msgs: # and self._depth < 1: + + # if self._msgs: + print("\n".join([m[0] % m[1] for m in self._msgs])) + + # clear all entries + self._msgs.clear() + # type(self)._msgs = [] + + type(self)._depth -= 1 From d46945cb097e4c761a8df577d72c4e32eba36b25 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 31 Oct 2022 09:26:36 -0400 Subject: [PATCH 3/8] Move profiler imports to internal version --- piker/data/marketstore.py | 3 ++- piker/fsp/_engine.py | 5 +++-- piker/ui/_curve.py | 5 +++-- piker/ui/_display.py | 3 ++- piker/ui/_flows.py | 9 +++++---- piker/ui/_interaction.py | 5 +++-- 6 files changed, 18 insertions(+), 12 deletions(-) diff --git a/piker/data/marketstore.py b/piker/data/marketstore.py index 3edc1718..09bfd680 100644 --- a/piker/data/marketstore.py +++ b/piker/data/marketstore.py @@ -56,6 +56,7 @@ if TYPE_CHECKING: from .feed import maybe_open_feed from ..log import get_logger, get_console_log +from .._profile import Profiler log = get_logger(__name__) @@ -645,7 +646,7 @@ async def tsdb_history_update( # * the original data feed arch blurb: # - https://github.com/pikers/piker/issues/98 # - profiler = pg.debug.Profiler( + profiler = Profiler( disabled=False, # not pg_profile_enabled(), delayed=False, ) diff --git a/piker/fsp/_engine.py b/piker/fsp/_engine.py index 5ba3d376..084ff510 100644 --- a/piker/fsp/_engine.py +++ b/piker/fsp/_engine.py @@ -44,6 +44,7 @@ from ._api import ( _load_builtins, _Token, ) +from .._profile import Profiler log = get_logger(__name__) @@ -91,7 +92,7 @@ async def fsp_compute( ) -> None: - profiler = pg.debug.Profiler( + profiler = Profiler( delayed=False, disabled=True ) @@ -262,7 +263,7 @@ async def cascade( destination shm array buffer. ''' - profiler = pg.debug.Profiler( + profiler = Profiler( delayed=False, disabled=False ) diff --git a/piker/ui/_curve.py b/piker/ui/_curve.py index ac5d12ca..548eaac5 100644 --- a/piker/ui/_curve.py +++ b/piker/ui/_curve.py @@ -44,6 +44,7 @@ from ._style import hcolor # ds_m4, # ) from ..log import get_logger +from .._profile import Profiler log = get_logger(__name__) @@ -331,7 +332,7 @@ class Curve(pg.GraphicsObject): ) -> None: - profiler = pg.debug.Profiler( + profiler = Profiler( msg=f'Curve.paint(): `{self._name}`', disabled=not pg_profile_enabled(), ms_threshold=ms_slower_then, @@ -466,7 +467,7 @@ class StepCurve(Curve): def sub_paint( self, p: QPainter, - profiler: pg.debug.Profiler, + profiler: Profiler, ) -> None: # p.drawLines(*tuple(filter(bool, self._last_step_lines))) diff --git a/piker/ui/_display.py b/piker/ui/_display.py index b20b99c0..0a98b4a8 100644 --- a/piker/ui/_display.py +++ b/piker/ui/_display.py @@ -66,6 +66,7 @@ from .._profile import ( ms_slower_then, ) from ..log import get_logger +from .._profile import Profiler log = get_logger(__name__) @@ -441,7 +442,7 @@ def graphics_update_cycle( # TODO: just pass this as a direct ref to avoid so many attr accesses? hist_chart = ds.godwidget.hist_linked.chart - profiler = pg.debug.Profiler( + profiler = Profiler( msg=f'Graphics loop cycle for: `{chart.name}`', delayed=True, disabled=not pg_profile_enabled(), diff --git a/piker/ui/_flows.py b/piker/ui/_flows.py index 48bd89d0..175afe4f 100644 --- a/piker/ui/_flows.py +++ b/piker/ui/_flows.py @@ -59,6 +59,7 @@ from ._curve import ( FlattenedOHLC, ) from ..log import get_logger +from .._profile import Profiler log = get_logger(__name__) @@ -130,7 +131,7 @@ def render_baritems( int, int, np.ndarray, int, int, np.ndarray, ], - profiler: pg.debug.Profiler, + profiler: Profiler, **kwargs, ) -> None: @@ -517,7 +518,7 @@ class Flow(msgspec.Struct): # , frozen=True): render: bool = True, array_key: Optional[str] = None, - profiler: Optional[pg.debug.Profiler] = None, + profiler: Optional[Profiler] = None, do_append: bool = True, **kwargs, @@ -528,7 +529,7 @@ class Flow(msgspec.Struct): # , frozen=True): render to graphics. ''' - profiler = pg.debug.Profiler( + profiler = Profiler( msg=f'Flow.update_graphics() for {self.name}', disabled=not pg_profile_enabled(), ms_threshold=4, @@ -948,7 +949,7 @@ class Renderer(msgspec.Struct): new_read, array_key: str, - profiler: pg.debug.Profiler, + profiler: Profiler, uppx: float = 1, # redraw and ds flags diff --git a/piker/ui/_interaction.py b/piker/ui/_interaction.py index b9ac32ea..d6899b60 100644 --- a/piker/ui/_interaction.py +++ b/piker/ui/_interaction.py @@ -33,6 +33,7 @@ import numpy as np import trio from ..log import get_logger +from .._profile import Profiler from .._profile import pg_profile_enabled, ms_slower_then # from ._style import _min_points_to_show from ._editors import SelectRect @@ -779,7 +780,7 @@ class ChartView(ViewBox): ''' name = self.name # print(f'YRANGE ON {name}') - profiler = pg.debug.Profiler( + profiler = Profiler( msg=f'`ChartView._set_yrange()`: `{name}`', disabled=not pg_profile_enabled(), ms_threshold=ms_slower_then, @@ -916,7 +917,7 @@ class ChartView(ViewBox): autoscale_overlays: bool = True, ): - profiler = pg.debug.Profiler( + profiler = Profiler( msg=f'ChartView.maybe_downsample_graphics() for {self.name}', disabled=not pg_profile_enabled(), From b524ea5c22a3ac902b7f81a7634e3c0d457853e0 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 31 Oct 2022 09:37:32 -0400 Subject: [PATCH 4/8] Extract and fork `pyqtgraph` upstream submissions Fork out our patch set submitted to upstream in multiple PRs (since they aren't moving and/or aren't a priority to core) which can be seen in full from the following diff: https://github.com/pyqtgraph/pyqtgraph/compare/master...pikers:pyqtgraph:graphics_pin Move these type extensions into the internal `.ui._pg_overrides` module. The changes are related to both `pyqtgraph.PlotItem` and `.AxisItem` and were driven for our need for multi-view overlays (overlaid charts with optionally synced axis and interaction controls) as documented in the PR to upstream: https://github.com/pyqtgraph/pyqtgraph/pull/2162 More specifically, - wrt to `AxisItem` we added lru caching of tick values as per: https://github.com/pyqtgraph/pyqtgraph/pull/2160. - wrt to `PlotItem` we adjusted some of the axis management code, namely adding a standalone `.removeAxis()` and modifying the `.setAxisItems()` logic to use it in: https://github.com/pyqtgraph/pyqtgraph/pull/2162 as well as some tweaks to `.updateGrid()` to loop through all possible axes when grid setting. --- piker/ui/_pg_overrides.py | 276 +++++++++++++++++++++++++++++++++++++- 1 file changed, 273 insertions(+), 3 deletions(-) diff --git a/piker/ui/_pg_overrides.py b/piker/ui/_pg_overrides.py index 71e5f40a..d7d7d3bc 100644 --- a/piker/ui/_pg_overrides.py +++ b/piker/ui/_pg_overrides.py @@ -15,11 +15,16 @@ # along with this program. If not, see . """ -Customization of ``pyqtgraph`` core routines to speed up our use mostly -based on not requiring "scentific precision" for pixel perfect view -transforms. +Customization of ``pyqtgraph`` core routines and various types normally +for speedups. + +Generally, our does not require "scentific precision" for pixel perfect +view transforms. """ +import functools +from typing import Optional + import pyqtgraph as pg @@ -46,3 +51,268 @@ def _do_overrides() -> None: """ # we don't care about potential fp issues inside Qt pg.functions.invertQTransform = invertQTransform + pg.PlotItem = PlotItem + pg.AxisItem = AxisItem + + +# NOTE: the below customized type contains all our changes on a method +# by method basis as per the diff: +# https://github.com/pyqtgraph/pyqtgraph/commit/8e60bc14234b6bec1369ff4192dbfb82f8682920#diff-a2b5865955d2ba703dbc4c35ff01aa761aa28d2aeaac5e68d24e338bc82fb5b1R500 + +class PlotItem(pg.PlotItem): + ''' + Overrides for the core plot object mostly pertaining to overlayed + multi-view management as it relates to multi-axis managment. + + ''' + def __init__( + self, + parent=None, + name=None, + labels=None, + title=None, + viewBox=None, + axisItems=None, + default_axes=['left', 'bottom'], + enableMenu=True, + **kargs + ): + super().__init__( + parent=parent, + name=name, + labels=labels, + title=title, + viewBox=viewBox, + axisItems=axisItems, + # default_axes=default_axes, + enableMenu=enableMenu, + kargs=kargs, + ) + # self.setAxisItems( + # axisItems, + # default_axes=default_axes, + # ) + + # NOTE: this is an entirely new method not in upstream. + def removeAxis( + self, + name: str, + unlink: bool = True, + + ) -> Optional[pg.AxisItem]: + """ + Remove an axis from the contained axis items + by ```name: str```. + + This means the axis graphics object will be removed + from the ``.layout: QGraphicsGridLayout`` as well as unlinked + from the underlying associated ``ViewBox``. + + If the ``unlink: bool`` is set to ``False`` then the axis will + stay linked to its view and will only be removed from the + layoutonly be removed from the layout. + + If no axis with ``name: str`` is found then this is a noop. + + Return the axis instance that was removed. + + """ + entry = self.axes.pop(name, None) + + if not entry: + return + + axis = entry['item'] + self.layout.removeItem(axis) + axis.scene().removeItem(axis) + if unlink: + axis.unlinkFromView() + + self.update() + + return axis + + # Why do we need to always have all axes created? + # + # I don't understand this at all. + # + # Everything seems to work if you just always apply the + # set passed to this method **EXCEPT** for some super weird reason + # the view box geometry still computes as though the space for the + # `'bottom'` axis is always there **UNLESS** you always add that + # axis but hide it? + # + # Why in tf would this be the case!?!? + def setAxisItems( + self, + # XXX: yeah yeah, i know we can't use type annots like this yet. + axisItems: Optional[dict[str, pg.AxisItem]] = None, + add_to_layout: bool = True, + default_axes: list[str] = ['left', 'bottom'], + ): + """ + Override axis item setting to only + + """ + axisItems = axisItems or {} + + # XXX: wth is is this even saying?!? + # Array containing visible axis items + # Also containing potentially hidden axes, but they are not + # touched so it does not matter + # visibleAxes = ['left', 'bottom'] + # Note that it does not matter that this adds + # some values to visibleAxes a second time + + # XXX: uhhh wat^ ..? + + visibleAxes = list(default_axes) + list(axisItems.keys()) + + # TODO: we should probably invert the loop here to not loop the + # predefined "axis name set" and instead loop the `axisItems` + # input and lookup indices from a predefined map. + for name, pos in ( + ('top', (1, 1)), + ('bottom', (3, 1)), + ('left', (2, 0)), + ('right', (2, 2)) + ): + if ( + name in self.axes and + name in axisItems + ): + # we already have an axis entry for this name + # so remove the existing entry. + self.removeAxis(name) + + # elif name not in axisItems: + # # this axis entry is not provided in this call + # # so remove any old/existing entry. + # self.removeAxis(name) + + # Create new axis + if name in axisItems: + axis = axisItems[name] + if axis.scene() is not None: + if ( + name not in self.axes + or axis != self.axes[name]["item"] + ): + raise RuntimeError( + "Can't add an axis to multiple plots. Shared axes" + " can be achieved with multiple AxisItem instances" + " and set[X/Y]Link.") + + else: + # Set up new axis + + # XXX: ok but why do we want to add axes for all entries + # if not desired by the user? The only reason I can see + # adding this is without it there's some weird + # ``ViewBox`` geometry bug.. where a gap for the + # 'bottom' axis is somehow left in? + axis = AxisItem(orientation=name, parent=self) + + axis.linkToView(self.vb) + + # XXX: shouldn't you already know the ``pos`` from the name? + # Oh right instead of a global map that would let you + # reasily look that up it's redefined over and over and over + # again in methods.. + self.axes[name] = {'item': axis, 'pos': pos} + + # NOTE: in the overlay case the axis may be added to some + # other layout and should not be added here. + if add_to_layout: + self.layout.addItem(axis, *pos) + + # place axis above images at z=0, items that want to draw + # over the axes should be placed at z>=1: + axis.setZValue(0.5) + axis.setFlag( + axis.GraphicsItemFlag.ItemNegativeZStacksBehindParent + ) + if name in visibleAxes: + self.showAxis(name, True) + else: + # why do we need to insert all axes to ``.axes`` and + # only hide the ones the user doesn't specify? It all + # seems to work fine without doing this except for this + # weird gap for the 'bottom' axis that always shows up + # in the view box geometry?? + self.hideAxis(name) + + def updateGrid( + self, + *args, + ): + alpha = self.ctrl.gridAlphaSlider.value() + x = alpha if self.ctrl.xGridCheck.isChecked() else False + y = alpha if self.ctrl.yGridCheck.isChecked() else False + for name, dim in ( + ('top', x), + ('bottom', x), + ('left', y), + ('right', y) + ): + if name in self.axes: + self.getAxis(name).setGrid(dim) + # self.getAxis('bottom').setGrid(x) + # self.getAxis('left').setGrid(y) + # self.getAxis('right').setGrid(y) + + +# NOTE: overrides to lru_cache the ``.tickStrings()`` output. +class AxisItem(pg.AxisItem): + + def __init__( + self, + orientation, + pen=None, + textPen=None, + linkView=None, + parent=None, + maxTickLength=-5, + showValues=True, + text='', + units='', + unitPrefix='', + lru_cache_tick_strings: bool = True, + **args, + ): + super().__init__( + orientation=orientation, + pen=pen, + textPen=textPen, + linkView=linkView, + maxTickLength=maxTickLength, + showValues=showValues, + text=text, + units=units, + unitPrefix=unitPrefix, + parent=parent, + ) + if lru_cache_tick_strings: + self.tickStrings = functools.lru_cache( + maxsize=2**20 + )(self.tickStrings) + + def tickValues( + self, + minVal: float, + maxVal: float, + size: int, + + ) -> list[tuple[float, tuple[str]]]: + ''' + Repack tick values into tuples for lru caching. + + ''' + ticks = [] + for scalar, values in super().tickValues(minVal, maxVal, size): + ticks.append(( + scalar, + tuple(values), # this + )) + + return ticks From be24473fb402c70e2130297668310f82c12d4204 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 31 Oct 2022 14:13:02 -0400 Subject: [PATCH 5/8] Adjust remaining chart internals to pg extensions Mainly this involves instantiating our overriden `PlotItem` in a few places and tweaking type annots. A further detail is that inside the fsp sub-chart creation code we hide some axes for overlays in the flows subchart; these were previously somehow hidden implicitly? --- piker/ui/_app.py | 2 ++ piker/ui/_chart.py | 15 +++++++++++---- piker/ui/_fsp.py | 10 +++++++--- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/piker/ui/_app.py b/piker/ui/_app.py index c99e2866..a31fd2da 100644 --- a/piker/ui/_app.py +++ b/piker/ui/_app.py @@ -78,6 +78,8 @@ async def _async_main( """ from . import _display + from ._pg_overrides import _do_overrides + _do_overrides() godwidget = main_widget diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index e0b92b56..f9aa4bec 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -73,6 +73,8 @@ from .._profile import pg_profile_enabled, ms_slower_then from ._overlay import PlotItemOverlay from ._flows import Flow from ._search import SearchWidget +from . import _pg_overrides as pgo +from .._profile import Profiler if TYPE_CHECKING: from ._display import DisplayState @@ -831,6 +833,7 @@ class ChartPlotWidget(pg.PlotWidget): static_yrange: Optional[tuple[float, float]] = None, + parent=None, **kwargs, ): ''' @@ -848,12 +851,15 @@ class ChartPlotWidget(pg.PlotWidget): # source of our custom interactions self.cv = cv = self.mk_vb(name) + pi = pgo.PlotItem(viewBox=cv, **kwargs) super().__init__( background=hcolor(view_color), viewBox=cv, # parent=None, # plotItem=None, # antialias=True, + parent=parent, + plotItem=pi, **kwargs ) # give viewbox as reference to chart @@ -1144,7 +1150,7 @@ class ChartPlotWidget(pg.PlotWidget): axis_side: str = 'right', axis_kwargs: dict = {}, - ) -> pg.PlotItem: + ) -> pgo.PlotItem: # Custom viewbox impl cv = self.mk_vb(name) @@ -1153,13 +1159,14 @@ class ChartPlotWidget(pg.PlotWidget): allowed_sides = {'left', 'right'} if axis_side not in allowed_sides: raise ValueError(f'``axis_side``` must be in {allowed_sides}') + yaxis = PriceAxis( orientation=axis_side, linkedsplits=self.linked, **axis_kwargs, ) - pi = pg.PlotItem( + pi = pgo.PlotItem( parent=self.plotItem, name=name, enableMenu=False, @@ -1246,7 +1253,7 @@ class ChartPlotWidget(pg.PlotWidget): # TODO: this probably needs its own method? if overlay: - if isinstance(overlay, pg.PlotItem): + if isinstance(overlay, pgo.PlotItem): if overlay not in self.pi_overlay.overlays: raise RuntimeError( f'{overlay} must be from `.plotitem_overlay()`' @@ -1405,7 +1412,7 @@ class ChartPlotWidget(pg.PlotWidget): If ``bars_range`` is provided use that range. ''' - profiler = pg.debug.Profiler( + profiler = Profiler( msg=f'`{str(self)}.maxmin(name={name})`: `{self.name}`', disabled=not pg_profile_enabled(), ms_threshold=ms_slower_then, diff --git a/piker/ui/_fsp.py b/piker/ui/_fsp.py index 3fa73504..94859efa 100644 --- a/piker/ui/_fsp.py +++ b/piker/ui/_fsp.py @@ -59,6 +59,7 @@ from ..fsp._volume import ( flow_rates, ) from ..log import get_logger +from .._profile import Profiler log = get_logger(__name__) @@ -190,7 +191,7 @@ async def open_fsp_actor_cluster( from tractor._clustering import open_actor_cluster - # profiler = pg.debug.Profiler( + # profiler = Profiler( # delayed=False, # disabled=False # ) @@ -212,7 +213,7 @@ async def run_fsp_ui( target: Fsp, conf: dict[str, dict], loglevel: str, - # profiler: pg.debug.Profiler, + # profiler: Profiler, # _quote_throttle_rate: int = 58, ) -> None: @@ -746,6 +747,8 @@ async def open_vlm_displays( }, ) + dvlm_pi.hideAxis('left') + dvlm_pi.hideAxis('bottom') # all to be overlayed curve names fields = [ 'dolla_vlm', @@ -878,6 +881,7 @@ async def open_vlm_displays( # keep both regular and dark vlm in view names=trade_rate_fields, ) + tr_pi.hideAxis('bottom') chart_curves( trade_rate_fields, @@ -951,7 +955,7 @@ async def start_fsp_displays( # }, # }, } - profiler = pg.debug.Profiler( + profiler = Profiler( delayed=False, disabled=False ) From e71bd2cb1e40e404f4e9a787a7552fce22395ba0 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 31 Oct 2022 14:23:29 -0400 Subject: [PATCH 6/8] Move axis-tick-values lru caching into our existing `Axis` --- piker/ui/_axes.py | 33 +++++++++++++++++++++ piker/ui/_pg_overrides.py | 60 +-------------------------------------- 2 files changed, 34 insertions(+), 59 deletions(-) diff --git a/piker/ui/_axes.py b/piker/ui/_axes.py index 7ba52055..3ed5b420 100644 --- a/piker/ui/_axes.py +++ b/piker/ui/_axes.py @@ -39,12 +39,17 @@ class Axis(pg.AxisItem): ''' A better axis that sizes tick contents considering font size. + Also includes tick values lru caching originally proposed in but never + accepted upstream: + https://github.com/pyqtgraph/pyqtgraph/pull/2160 + ''' def __init__( self, linkedsplits, typical_max_str: str = '100 000.000', text_color: str = 'bracket', + lru_cache_tick_strings: bool = True, **kwargs ) -> None: @@ -91,6 +96,34 @@ class Axis(pg.AxisItem): # size the pertinent axis dimension to a "typical value" self.size_to_values() + # NOTE: requires override ``.tickValues()`` method seen below. + if lru_cache_tick_strings: + self.tickStrings = lru_cache( + maxsize=2**20 + )(self.tickStrings) + + # NOTE: only overriden to cast tick values entries into tuples + # for use with the lru caching. + def tickValues( + self, + minVal: float, + maxVal: float, + size: int, + + ) -> list[tuple[float, tuple[str]]]: + ''' + Repack tick values into tuples for lru caching. + + ''' + ticks = [] + for scalar, values in super().tickValues(minVal, maxVal, size): + ticks.append(( + scalar, + tuple(values), # this + )) + + return ticks + @property def text_color(self) -> str: return self._text_color diff --git a/piker/ui/_pg_overrides.py b/piker/ui/_pg_overrides.py index d7d7d3bc..a961e567 100644 --- a/piker/ui/_pg_overrides.py +++ b/piker/ui/_pg_overrides.py @@ -22,7 +22,6 @@ Generally, our does not require "scentific precision" for pixel perfect view transforms. """ -import functools from typing import Optional import pyqtgraph as pg @@ -52,7 +51,6 @@ def _do_overrides() -> None: # we don't care about potential fp issues inside Qt pg.functions.invertQTransform = invertQTransform pg.PlotItem = PlotItem - pg.AxisItem = AxisItem # NOTE: the below customized type contains all our changes on a method @@ -211,7 +209,7 @@ class PlotItem(pg.PlotItem): # adding this is without it there's some weird # ``ViewBox`` geometry bug.. where a gap for the # 'bottom' axis is somehow left in? - axis = AxisItem(orientation=name, parent=self) + axis = pg.AxisItem(orientation=name, parent=self) axis.linkToView(self.vb) @@ -260,59 +258,3 @@ class PlotItem(pg.PlotItem): # self.getAxis('bottom').setGrid(x) # self.getAxis('left').setGrid(y) # self.getAxis('right').setGrid(y) - - -# NOTE: overrides to lru_cache the ``.tickStrings()`` output. -class AxisItem(pg.AxisItem): - - def __init__( - self, - orientation, - pen=None, - textPen=None, - linkView=None, - parent=None, - maxTickLength=-5, - showValues=True, - text='', - units='', - unitPrefix='', - lru_cache_tick_strings: bool = True, - **args, - ): - super().__init__( - orientation=orientation, - pen=pen, - textPen=textPen, - linkView=linkView, - maxTickLength=maxTickLength, - showValues=showValues, - text=text, - units=units, - unitPrefix=unitPrefix, - parent=parent, - ) - if lru_cache_tick_strings: - self.tickStrings = functools.lru_cache( - maxsize=2**20 - )(self.tickStrings) - - def tickValues( - self, - minVal: float, - maxVal: float, - size: int, - - ) -> list[tuple[float, tuple[str]]]: - ''' - Repack tick values into tuples for lru caching. - - ''' - ticks = [] - for scalar, values in super().tickValues(minVal, maxVal, size): - ticks.append(( - scalar, - tuple(values), # this - )) - - return ticks From c41400ae187becec86094894bbd8c67751888152 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 31 Oct 2022 14:58:35 -0400 Subject: [PATCH 7/8] Use `.setRect()`; not sure how this was ever working? --- piker/ui/_editors.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/piker/ui/_editors.py b/piker/ui/_editors.py index f025307e..3703558a 100644 --- a/piker/ui/_editors.py +++ b/piker/ui/_editors.py @@ -360,7 +360,7 @@ class SelectRect(QtWidgets.QGraphicsRectItem): self.setPos(r.topLeft()) self.resetTransform() - self.scale(r.width(), r.height()) + self.setRect(r) self.show() y1, y2 = start_pos.y(), end_pos.y() From 5d4929db9c6edb59c77c6b3e6869dab9820c24f3 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 31 Oct 2022 15:00:38 -0400 Subject: [PATCH 8/8] Pin to our `pyqtgraph` fork's master branch --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 91bb8918..8f573625 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,7 @@ # `pyqtgraph` peeps keep breaking, fixing, improving so might as well # pin this to a dev branch that we have more control over especially # as more graphics stuff gets hashed out. --e git+https://github.com/pikers/pyqtgraph.git@piker_pin#egg=pyqtgraph +-e git+https://github.com/pikers/pyqtgraph.git@master#egg=pyqtgraph # our async client for ``marketstore`` (the tsdb) -e git+https://github.com/pikers/anyio-marketstore.git@master#egg=anyio-marketstore