diff --git a/bliss/common/plot.py b/bliss/common/plot.py index 84eb9b66338d6c054d0e5cec3386407f452ba016..4732e7d1a0a6af7ca44c5b22b55a1dcdbfc8ef3a 100755 --- a/bliss/common/plot.py +++ b/bliss/common/plot.py @@ -39,13 +39,6 @@ This interface supports several types of plot: * two histograms along the X and Y dimensions are displayed * the plot is created using ``plot_image_with_histogram`` -- **curve list plot**: - - * plot a single list of 1D data as curves - * a slider and an envelop view are provided - * the plot is created using ``plot_curve_list`` - * this widget is not integrated yet! - - **image stack plot**: * plot a single stack of image @@ -162,7 +155,6 @@ from bliss.flint.client.proxy import FLINT_OUTPUT_LOGGER # noqa: F401 __all__ = [ "plot", "plot_curve", - "plot_curve_list", "plot_image", "plot_scatter", "plot_image_with_histogram", @@ -202,7 +194,6 @@ def _create_plot( plot_curve = functools.partial(_create_plot, flint_plots.CurvePlot) -plot_curve_list = functools.partial(_create_plot, flint_plots.CurveListPlot) plot_scatter = functools.partial(_create_plot, flint_plots.ScatterPlot) plot_image = functools.partial(_create_plot, flint_plots.ImagePlot) plot_image_with_histogram = functools.partial( diff --git a/bliss/flint/client/plots.py b/bliss/flint/client/plots.py index acc394df78d2cbb0fe39c7a8cb9beb9132e9a2ab..d524682ffdd55ceecaf5afc042641d4779e2580c 100644 --- a/bliss/flint/client/plots.py +++ b/bliss/flint/client/plots.py @@ -14,6 +14,8 @@ from typing import Optional import numpy import gevent +import marshal +import inspect from . import proxy from bliss.common import event @@ -24,6 +26,9 @@ class BasePlot(object): # Name of the corresponding silx widget WIDGET = NotImplemented + # Available name to identify this plot + ALIASES = [] + # Name of the method to add data to the plot METHOD = NotImplemented @@ -36,14 +41,66 @@ class BasePlot(object): # Data input number for a single representation DATA_INPUT_NUMBER = NotImplemented - def __init__(self, flint, plot_id): + def __init__(self, flint, plot_id, register=False): """Describe a custom plot handled by Flint. """ self._plot_id = plot_id self._flint = flint self._xlabel = None self._ylabel = None + self._init() if flint is not None: + self._register(flint, plot_id, register) + + def _init(self): + """Allow to initialize extra attributes in a derived class, without + redefining the constructor""" + pass + + class RemotePlot: + """This class is serialized method by method and executed inside the Flint context""" + + def select_data(self, method, *names, **kwargs): + if "legend" not in kwargs and method.startswith("add"): + kwargs["legend"] = " -> ".join(names) + # Get the data to plot + data_dict = self.data() + widget = self.widget() + args = tuple(data_dict[name] for name in names) + widget_method = getattr(widget, method) + # Plot + widget_method(*args, **kwargs) + + def update_data(self, field, data): + data_dict = self.data() + data_dict[field] = data + + def remove_data(self, field): + data_dict = self.data() + data_dict[field] + + def get_data(self, field=None): + data_dict = self.data() + if field is None: + return data_dict + else: + return data_dict.get(field, []) + + def deselect_data(self, *names): + widget = self.widget() + legend = " -> ".join(names) + widget.remove(legend) + + def clear_data(self): + data_dict = self.data() + widget = self.widget() + data_dict.clear() + widget.clear() + + def _register(self, flint, plot_id, register): + """Register everything needed remotly""" + self.__remote = self._remotifyClass(self.RemotePlot, register=register) + if register: self._init_plot() def _init_plot(self): @@ -116,7 +173,7 @@ class BasePlot(object): self.DATA_DIMENSIONS, data.ndim ) ) - return self._flint.update_data(self._plot_id, field, data) + return self.__remote.update_data(field, data) def add_data(self, data, field="default"): # Get fields @@ -137,19 +194,19 @@ class BasePlot(object): return data_dict def remove_data(self, field): - return self._flint.remove_data(self._plot_id, field) + self.__remote.remove_data(field) def select_data(self, *names, **kwargs): - return self._flint.select_data(self._plot_id, self.METHOD, names, kwargs) + self.__remote.select_data(self.METHOD, *names, **kwargs) def deselect_data(self, *names): - return self._flint.deselect_data(self._plot_id, names) + self.__remote.deselect_data(*names) def clear_data(self): - return self._flint.clear_data(self._plot_id) + self.__remote.clear_data() - def get_data(self): - return self._flint.get_data(self._plot_id) + def get_data(self, field=None): + return self.__remote.get_data(field=field) # Plotting @@ -242,6 +299,33 @@ class BasePlot(object): request_id = flint.request_select_shape(self._plot_id, shape) return self._wait_for_user_selection(request_id) + def _remotifyClass(self, remoteClass, register=True): + class RemoteProxy: + pass + + proxy = RemoteProxy() + methods = inspect.getmembers(remoteClass, predicate=inspect.isfunction) + for name, func in methods: + handle = self._remotifyFunc(func, register=register) + setattr(proxy, name, handle) + + return proxy + + def _remotifyFunc(self, func, register=True): + """Make a function callable remotely""" + method_id = func.__qualname__ + plot_id = self._plot_id + if register: + if func.__closure__: + raise TypeError("Only function without closure are supported.") + serialized_func = marshal.dumps(func.__code__) + self._flint.register_custom_method(plot_id, method_id, serialized_func) + + def handler(*args, **kwargs): + return self._flint.run_custom_method(plot_id, method_id, args, kwargs) + + return handler + def _set_colormap( self, lut: Optional[str] = None, @@ -286,10 +370,13 @@ class BasePlot(object): # Plot classes -class CurvePlot(BasePlot): +class Plot1D(BasePlot): # Name of the corresponding silx widget - WIDGET = "Plot1D" + WIDGET = "silx.gui.plot.Plot1D" + + # Available name to identify this plot + ALIASES = ["curve", "plot1d"] # Name of the method to add data to the plot METHOD = "addCurve" @@ -381,10 +468,13 @@ class CurvePlot(BasePlot): flint.run_method(self._plot_id, "setYAxisLogarithmic", [value == "log"], {}) -class ScatterPlot(BasePlot): +class ScatterView(BasePlot): # Name of the corresponding silx widget - WIDGET = "Plot1D" + WIDGET = "silx.gui.plot.ScatterView" + + # Available name to identify this plot + ALIASES = ["scatter"] # Name of the method to add data to the plot METHOD = "addScatter" @@ -398,38 +488,18 @@ class ScatterPlot(BasePlot): # Data input number for a single representation DATA_INPUT_NUMBER = 3 - def __init__(self, flint, plot_id): - BasePlot.__init__(self, flint, plot_id) + def _init(self): # Make it public self.set_colormap = self._set_colormap -class McaPlot(CurvePlot): - pass - - -class CurveListPlot(BasePlot): +class Plot2D(BasePlot): # Name of the corresponding silx widget - WIDGET = "CurvesView" - - # Name of the method to add data to the plot - METHOD = None - - # The dimension of the data to plot - DATA_DIMENSIONS = (2,) - - # Single / Multiple data handling - MULTIPLE = False - - # Data input number for a single representation - DATA_INPUT_NUMBER = 1 - + WIDGET = "silx.gui.plot.Plot2D" -class ImagePlot(BasePlot): - - # Name of the corresponding silx widget - WIDGET = "Plot2D" + # Available name to identify this plot + ALIASES = ["image", "plot2d"] # Name of the method to add data to the plot METHOD = "addImage" @@ -443,8 +513,7 @@ class ImagePlot(BasePlot): # Data input number for a single representation DATA_INPUT_NUMBER = 1 - def __init__(self, flint, plot_id): - BasePlot.__init__(self, flint, plot_id) + def _init(self): # Make it public self.set_colormap = self._set_colormap @@ -466,10 +535,13 @@ class ImagePlot(BasePlot): return self._wait_for_user_selection(request_id) -class HistogramImagePlot(BasePlot): +class ImageView(BasePlot): # Name of the corresponding silx widget - WIDGET = "ImageView" + WIDGET = "silx.gui.plot.ImageView" + + # Available name to identify this plot + ALIASES = ["imageview", "histogramimage"] # Name of the method to add data to the plot METHOD = "setImage" @@ -484,10 +556,13 @@ class HistogramImagePlot(BasePlot): DATA_INPUT_NUMBER = 1 -class ImageStackPlot(BasePlot): +class StackView(BasePlot): # Name of the corresponding silx widget - WIDGET = "StackView" + WIDGET = "silx.gui.plot.StackView" + + # Available name to identify this plot + ALIASES = ["stack", "imagestack"] # Name of the method to add data to the plot METHOD = "setStack" @@ -500,3 +575,43 @@ class ImageStackPlot(BasePlot): # Data input number for a single representation DATA_INPUT_NUMBER = 1 + + +class LiveCurvePlot(Plot1D): + + WIDGET = None + + ALIASES = ["curve"] + + +class LiveImagePlot(Plot2D): + + WIDGET = None + + ALIASES = ["image"] + + +class LiveScatterPlot(Plot1D): + + WIDGET = None + + ALIASES = ["scatter"] + + +class LiveMcaPlot(Plot1D): + + WIDGET = None + + ALIASES = ["mca"] + + +CUSTOM_CLASSES = [Plot1D, Plot2D, ScatterView, ImageView, StackView] + +LIVE_CLASSES = [LiveCurvePlot, LiveImagePlot, LiveScatterPlot, LiveMcaPlot] + +# For compatibility +CurvePlot = Plot1D +ImagePlot = Plot2D +ScatterPlot = ScatterView +HistogramImagePlot = ImageView +ImageStackPlot = StackView diff --git a/bliss/flint/client/proxy.py b/bliss/flint/client/proxy.py index 83ae65ef5c64d4272942f78614cb07ca7f699f68..5a4461566d863bd5843efcf2d36ad0c6b0ee2774 100644 --- a/bliss/flint/client/proxy.py +++ b/bliss/flint/client/proxy.py @@ -59,11 +59,9 @@ class FlintClient: self._greenlets = None self._callbacks = None - self._plot_mapping = {} - """Store mapping from name to int id. - This should be part of flint_api at one point. - """ + self._init(process) + def _init(self, process): if process is None: self.__start_flint() else: @@ -414,10 +412,10 @@ class FlintClient: """ if kind is not None: if kind == "default-curve": - plot_class = plots.CurvePlot + plot_class = plots.LiveCurvePlot plot_type = "curve" elif kind == "default-scatter": - plot_class = plots.ScatterPlot + plot_class = plots.LiveScatterPlot plot_type = "scatter" else: raise ValueError(f"Unexpected plot kind '{kind}'.") @@ -429,12 +427,12 @@ class FlintClient: return plot_class(plot_id=plot_id, flint=self) elif image_detector is not None: - plot_class = plots.ImagePlot + plot_class = plots.LiveImagePlot plot_id = self.get_live_plot_detector(image_detector, plot_type="image") return plot_class(plot_id=plot_id, flint=self) elif mca_detector is not None: - plot_class = plots.McaPlot + plot_class = plots.LiveMcaPlot plot_id = self.get_live_plot_detector(mca_detector, plot_type="mca") return plot_class(plot_id=plot_id, flint=self) @@ -443,7 +441,7 @@ class FlintClient: def get_plot( self, plot_class: typing.Union[str, object], - name: str, + name: str = None, unique_name: str = None, selected: bool = False, closeable: bool = True, @@ -454,9 +452,8 @@ class FlintClient: Arguments: plot_class: A class defined in `bliss.flint.client.plot`, or a - silx class name. Can be one of "PlotWidget", - "PlotWindow", "Plot1D", "Plot2D", "ImageView", "StackView", - "ScatterView". + silx class name. Can be one of "Plot1D", "Plot2D", "ImageView", + "StackView", "ScatterView". name: Name of the plot as displayed in the tab header. It is not a unique name. unique_name: If defined the plot can be retrieved from flint. @@ -464,24 +461,21 @@ class FlintClient: displayed plot. closeable: If true (default), the tab can be closed manually """ - silx_class_name, plot_class = self.__get_plot_info(plot_class) - - # FIXME: Hack for now, i would prefer to provide a get_live_plot for that - if isinstance(unique_name, str) and unique_name.startswith("live:"): - return plot_class(flint=self, plot_id=unique_name) + plot_class = self.__normalize_plot_class(plot_class) if unique_name is not None: - flint_plot_id = self._plot_mapping.get(unique_name, None) - if flint_plot_id is not None: - if self.is_plot_exists(flint_plot_id): - return plot_class(flint=self, plot_id=flint_plot_id) + if self.is_plot_exists(unique_name): + return plot_class(flint=self, plot_id=unique_name) + silx_class_name = plot_class.WIDGET plot_id = self._proxy.add_plot( - silx_class_name, name=name, selected=selected, closeable=closeable + silx_class_name, + name=name, + selected=selected, + closeable=closeable, + unique_name=unique_name, ) - if unique_name is not None: - self._plot_mapping[unique_name] = plot_id - return plot_class(plot_id=plot_id, flint=self) + return plot_class(plot_id=plot_id, flint=self, register=True) def add_plot( self, @@ -505,30 +499,28 @@ class FlintClient: displayed plot. closeable: If true (default), the tab can be closed manually """ - silx_class_name, plot_class = self.__get_plot_info(plot_class) + plot_class = self.__normalize_plot_class(plot_class) + silx_class_name = plot_class.WIDGET plot_id = self._proxy.add_plot( silx_class_name, name=name, selected=selected, closeable=closeable ) - return plot_class(plot_id=plot_id, flint=self) + return plot_class(plot_id=plot_id, flint=self, register=True) + + def __normalize_plot_class(self, plot_class: typing.Union[str, object]): + """Returns a BLISS side plot class. - def __get_plot_info(self, plot_class): + Arguments: + plot_class: A BLISS side plot class, or one of its alias + """ if isinstance(plot_class, str): - classes = [ - plots.CurvePlot, - plots.CurveListPlot, - plots.HistogramImagePlot, - plots.ImagePlot, - plots.ImageStackPlot, - plots.ScatterPlot, - ] plot_class = plot_class.lower() - for cls in classes: - if cls.WIDGET.lower() == plot_class: + for cls in plots.CUSTOM_CLASSES: + if plot_class in cls.ALIASES: plot_class = cls break else: raise ValueError(f"Name '{plot_class}' does not refer to a plot class") - return plot_class.WIDGET, plot_class + return plot_class def _get_beacon_config(): diff --git a/bliss/flint/flint_api.py b/bliss/flint/flint_api.py index a9c589092a72c9436a3ed0c7b0b25229cbb358fc..e49a91be181210777c0fca60147556ba8d6a1502 100755 --- a/bliss/flint/flint_api.py +++ b/bliss/flint/flint_api.py @@ -17,16 +17,15 @@ from typing import NamedTuple from typing import Optional import sys +import types import logging +import importlib import itertools import functools -import collections import numpy - -import gevent.event +import marshal from silx.gui import qt -from silx.gui import plot as silx_plot import bliss from bliss.flint.helper import plot_interaction, scan_info_helper from bliss.controllers.lima import roi as lima_roi @@ -38,18 +37,11 @@ from bliss.flint.model import plot_state_model from bliss.flint.model import flint_model from bliss.common import event from bliss.flint import config +from bliss.flint.widgets.custom_plot import CustomPlot _logger = logging.getLogger(__name__) -class CustomPlot(NamedTuple): - """Store information to a plot created remotly and providing silx API.""" - - plot: qt.QWidget - tab: qt.QWidget - title: str - - class Request(NamedTuple): """Store information about a request.""" @@ -108,10 +100,6 @@ class FlintApi: """Store the current requests""" self.__flintModel = flintModel - # FIXME: _custom_plots should be owned by flint model or window - self._custom_plots: Dict[object, CustomPlot] = {} - self.data_event = collections.defaultdict(dict) - self.data_dict = collections.defaultdict(dict) self.stdout = MultiplexStreamToCallback(sys.stdout) sys.stdout = self.stdout @@ -157,14 +145,6 @@ class FlintApi: model = self.__flintModel return model.blissSessionName() - def wait_data(self, master, plot_type, index): - ev = ( - self.data_event[master] - .setdefault(plot_type, {}) - .setdefault(index, gevent.event.Event()) - ) - ev.wait(timeout=3) - def get_live_scan_data(self, channel_name): scan = self.__flintModel.currentScan() if isinstance(scan, scan_model.ScanGroup): @@ -314,10 +294,25 @@ class FlintApi: manager.waitFlintStarted() def run_method(self, plot_id, method, args, kwargs): - plot = self._get_plot_widget(plot_id, expect_silx_api=True) - method = getattr(plot, method) + plot = self._get_plot_widget(plot_id) + silxPlot = plot._silxPlot() + method = getattr(silxPlot, method) return method(*args, **kwargs) + def run_custom_method(self, plot_id, method_id, args, kwargs): + """Run a registered method from a custom plot. + """ + plot = self._get_plot_widget(plot_id, live_plot=False) + return plot.runMethod(method_id, args, kwargs) + + def register_custom_method(self, plot_id, method_id, serialized_method): + """Register a method to a custom plot. + """ + plot = self._get_plot_widget(plot_id, live_plot=False) + code = marshal.loads(serialized_method) + method = types.FunctionType(code, globals(), "deserialized_function") + plot.registerMethod(method_id, method) + def ping(self, msg=None, stderr=False): """Debug function to check writing on stdout/stderr remotely.""" if stderr: @@ -332,7 +327,7 @@ class FlintApi: def test_count_displayed_items(self, plot_id): """Debug purpose function to count number of displayed items in a plot widget.""" - widget = self._get_plot_widget(plot_id, expect_silx_api=False, custom_plot=True) + widget = self._get_plot_widget(plot_id) if widget is None: raise Exception("Widget %s not found" % plot_id) count = 0 @@ -344,7 +339,7 @@ class FlintApi: def test_displayed_channel_names(self, plot_id): """Debug purpose function to returns displayed channels from a live plot widget.""" - widget = self._get_plot_widget(plot_id, expect_silx_api=False, custom_plot=True) + widget = self._get_plot_widget(plot_id, custom_plot=False) if widget is None: raise Exception("Widget %s not found" % plot_id) plot = widget.plotModel() @@ -360,7 +355,7 @@ class FlintApi: qaction: The action which will be processed. It have to be a children of the plot and referenced as it's object name. """ - plot = self._get_plot_widget(plot_id, expect_silx_api=True) + plot = self._get_plot_widget(plot_id) action: qt.QAction = plot.findChild(qt.QAction, qaction) action.trigger() @@ -380,10 +375,11 @@ class FlintApi: position: Expected position of the mouse relative_to_center: If try the position is relative to center """ - plot = self._get_plot_widget(plot_id, expect_silx_api=True) + plot = self._get_plot_widget(plot_id) + silxPlot = plot._silxPlot() from silx.gui.utils.testutils import QTest - widget = plot.getWidgetHandle() + widget = silxPlot.getWidgetHandle() assert relative_to_center is True rect = qt.QRect(qt.QPoint(0, 0), widget.size()) base = rect.center() @@ -413,63 +409,77 @@ class FlintApi: except ValueError: return False else: - return plot_id in self._custom_plots + window = self.__flintModel.mainWindow() + custom_plot = window.customPlot(plot_id) + return custom_plot is not None def add_plot( self, - cls_name: str, + class_name: str, name: str = None, selected: bool = False, closeable: bool = True, - ): + unique_name: str = None, + ) -> str: """Create a new custom plot based on the `silx` API. The plot will be created in a new tab on Flint. Arguments: - cls_name: A class name defined by silx. Can be one of "PlotWidget", - "PlotWindow", "Plot1D", "Plot2D", "ImageView", "StackView", - "ScatterView". + class_name: A class to display a plot. Can be one of: + "silx.gui.plot.Plot1D", "silx.gui.plot.Plot2D", + "silx.gui.plot.ImageView", "silx.gui.plot.StackView", + "silx.gui.plot.ScatterView". name: Name of the plot as displayed in the tab header. It is not a unique name. selected: If true (not the default) the plot became the current displayed plot. closeable: If true (default), the tab can be closed manually + unique_name: Unique name for this new plot Returns: A plot_id """ - plot_id = self.create_new_id() + if unique_name is None: + unique_name = "custom_plot:%d" % self.create_new_id() if not name: - name = "Plot %d" % plot_id - new_tab_widget = self.__flintModel.mainWindow().createTab( - name, selected=selected, closeable=closeable + name = "%s" % unique_name + + def get_class(class_name): + try: + module_name, class_name = class_name.rsplit(".", 1) + module = importlib.import_module(module_name) + class_obj = getattr(module, class_name) + return class_obj + except Exception: + _logger.debug( + "Error while reaching class name '%s'", class_name, exc_info=True + ) + raise ValueError("Unknown class name %s" % class_name) + + class_obj = get_class(class_name) + window = self.__flintModel.mainWindow() + plot = class_obj(parent=window) + window.createCustomPlot( + plot, name, unique_name, selected=selected, closeable=closeable ) - # FIXME: Hack to know how to close the widget - new_tab_widget._plot_id = plot_id - qt.QVBoxLayout(new_tab_widget) - cls = getattr(silx_plot, cls_name) - plot = cls(new_tab_widget) - self._custom_plots[plot_id] = CustomPlot(plot, new_tab_widget, name) - new_tab_widget.layout().addWidget(plot) - plot.show() - return plot_id + return unique_name def get_plot_name(self, plot_id): - if isinstance(plot_id, str) and plot_id.startswith("live:"): - widget = self._get_live_plot_widget(plot_id) + widget = self._get_plot_widget(plot_id) + if isinstance(widget, CustomPlot): + return widget.name() + else: return widget.windowTitle() - return self._custom_plots[plot_id].title def remove_plot(self, plot_id): - custom_plot = self._custom_plots.pop(plot_id) window = self.__flintModel.mainWindow() - window.removeTab(custom_plot.tab) - custom_plot.plot.close() + return window.removeCustomPlot(plot_id) def get_interface(self, plot_id): plot = self._get_plot_widget(plot_id) - names = dir(plot) + silxPlot = plot._silxPlot() + names = dir(silxPlot) # Deprecated attrs removes = ["DEFAULT_BACKEND"] for r in removes: @@ -498,7 +508,7 @@ class FlintApi: finite, the marker is removed (or not created) text: A text label for the marker """ - plot = self._get_plot_widget(plot_id, expect_silx_api=False) + plot = self._get_plot_widget(plot_id, custom_plot=False) model = plot.plotModel() if model is None: raise Exception("No model linked to this plot") @@ -538,7 +548,7 @@ class FlintApi: it is removed from the plot """ ydata = _aswritablearray(ydata) - plot = self._get_plot_widget(plot_id, expect_silx_api=False) + plot = self._get_plot_widget(plot_id, custom_plot=False) model = plot.plotModel() if model is None: raise Exception("No model linked to this plot") @@ -603,39 +613,6 @@ class FlintApi: # Nothing to do pass - def update_data(self, plot_id, field, data): - self.data_dict[plot_id][field] = data - - def remove_data(self, plot_id, field): - del self.data_dict[plot_id][field] - - def get_data(self, plot_id, field=None): - if field is None: - return self.data_dict[plot_id] - else: - return self.data_dict[plot_id].get(field, []) - - def select_data(self, plot_id, method, names, kwargs): - plot = self._get_plot_widget(plot_id) - # Hackish legend handling - if "legend" not in kwargs and method.startswith("add"): - kwargs["legend"] = " -> ".join(names) - # Get the data to plot - args = tuple(self.data_dict[plot_id][name] for name in names) - method = getattr(plot, method) - # Plot - method(*args, **kwargs) - - def deselect_data(self, plot_id, names): - plot = self._get_plot_widget(plot_id) - legend = " -> ".join(names) - plot.remove(legend) - - def clear_data(self, plot_id): - self.data_dict[plot_id].clear() - plot = self._get_plot_widget(plot_id) - plot.clear() - def start_image_monitoring(self, channel_name, tango_address): """Start monitoring of an image from a Tango detector. @@ -706,33 +683,26 @@ class FlintApi: widget = widgets[iwidget] return widget - def _get_widget(self, plot_id): - # FIXME: Refactor it, it starts to be ugly - if isinstance(plot_id, str) and plot_id.startswith("live:"): - widget = self._get_live_plot_widget(plot_id) - return widget - return self._custom_plots[plot_id].tab + def _get_plot_widget(self, plot_id: str, live_plot=None, custom_plot=None): + """Get a plot widget (widget while hold a plot) from this `plot_id` - def _get_plot_widget(self, plot_id, expect_silx_api=True, custom_plot=False): - # FIXME: Refactor it, it starts to be ugly - if isinstance(plot_id, str) and plot_id.startswith("live:"): - widget = self._get_live_plot_widget(plot_id) - if not expect_silx_api: + Arguments: + plot_id: Id of a plot + live_plot: If this filter is set, a live plot is returned only if + it's set to True + custom_plot: If this filter is set, a custom plot is returned only + if it's set to True + """ + if live_plot in [None, True]: + if isinstance(plot_id, str) and plot_id.startswith("live:"): + widget = self._get_live_plot_widget(plot_id) return widget - if not hasattr(widget, "_silxPlot"): - raise ValueError( - f"The widget associated to '{plot_id}' do not provide a silx API" - ) - return widget._silxPlot() - - if not expect_silx_api: - raise ValueError( - f"The widget associated to '{plot_id}' only provides a silx API" - ) - - if custom_plot: - return self._custom_plots[plot_id] - return self._custom_plots[plot_id].plot + if custom_plot in [None, True]: + window = self.__flintModel.mainWindow() + customPlot = window.customPlot(plot_id) + if customPlot is not None: + return customPlot + return None # API to custom default live plots @@ -743,7 +713,7 @@ class FlintApi: - If a channel was hidden, it become visible - If a channel is in the plot but not part of this list, it is removed """ - widget = self._get_plot_widget(plot_id, expect_silx_api=False, custom_plot=True) + widget = self._get_plot_widget(plot_id) if widget is None: raise ValueError("Widget %s not found" % plot_id) @@ -786,8 +756,9 @@ class FlintApi: reach the result. The event result is list of shapes describing the selection. """ - plot = self._get_plot_widget(plot_id, expect_silx_api=True) - selector = plot_interaction.ShapesSelector(plot) + plot = self._get_plot_widget(plot_id) + silxPlot = plot._silxPlot() + selector = plot_interaction.ShapesSelector(silxPlot) if isinstance(kinds, str): kinds = [kinds] selector.setKinds(kinds) @@ -811,8 +782,9 @@ class FlintApi: is defined by a tuple of 2 floats (x, y). If nothing is selected an empty sequence is returned. """ - plot = self._get_plot_widget(plot_id, expect_silx_api=True) - selector = plot_interaction.PointsSelector(plot) + plot = self._get_plot_widget(plot_id) + silxPlot = plot._silxPlot() + selector = plot_interaction.PointsSelector(silxPlot) selector.setNbPoints(nb) return self.__request_selector(plot_id, selector) @@ -833,8 +805,9 @@ class FlintApi: A point is defined by a tuple of 2 floats (x, y). If nothing is selected an empty sequence is returned. """ - plot = self._get_plot_widget(plot_id, expect_silx_api=True) - selector = plot_interaction.ShapeSelector(plot) + plot = self._get_plot_widget(plot_id) + silxPlot = plot._silxPlot() + selector = plot_interaction.ShapeSelector(silxPlot) selector.setShapeSelection(shape) return self.__request_selector(plot_id, selector) @@ -857,8 +830,9 @@ class FlintApi: The event is a numpy.array describing the selection """ - plot = self._get_plot_widget(plot_id, expect_silx_api=True) - selector = plot_interaction.MaskImageSelector(plot) + plot = self._get_plot_widget(plot_id) + silxPlot = plot._silxPlot() + selector = plot_interaction.MaskImageSelector(silxPlot) initial_mask = _aswritablearray(initial_mask) if initial_mask is not None: selector.setInitialMask(initial_mask, copy=False) @@ -866,18 +840,16 @@ class FlintApi: return self.__request_selector(plot_id, selector) def __request_selector(self, plot_id, selector: plot_interaction.Selector) -> str: - custom_plot = self._get_plot_widget(plot_id, custom_plot=True) + plot = self._get_plot_widget(plot_id) # Set the focus as an user input is requested - if isinstance(custom_plot, CustomPlot): - plot = custom_plot.plot + window = self.__flintModel.mainWindow() + if isinstance(plot, CustomPlot): # Set the focus as an user input is requested - window = self.__flintModel.mainWindow() - window.setFocusOnPlot(custom_plot.tab) + window.setFocusOnPlot(plot) else: window = self.__flintModel.mainWindow() window.setFocusOnLiveScan() - plot = custom_plot request_id = self.__create_request_id() request = Request(plot, request_id, selector) @@ -932,18 +904,18 @@ class FlintApi: def set_plot_focus(self, plot_id): """Set the focus on a plot""" - widget = self._get_widget(plot_id) + widget = self._get_plot_widget(plot_id) if widget is None: raise ValueError("Widget %s not found" % plot_id) model = self.__flintModel window = model.mainWindow() - if isinstance(plot_id, str) and plot_id.startswith("live:"): + if isinstance(widget, CustomPlot): + window.setFocusOnPlot(widget) + else: window.setFocusOnLiveScan() widget.show() widget.raise_() widget.setFocus(qt.Qt.OtherFocusReason) - else: - window.setFocusOnPlot(widget) def set_plot_colormap( self, @@ -959,7 +931,7 @@ class FlintApi: """ Allows to setup the default colormap of a widget. """ - widget = self._get_widget(plot_id) + widget = self._get_plot_widget(plot_id) if not hasattr(widget, "defaultColormap"): raise TypeError("Widget %s does not expose a colormap" % plot_id) @@ -987,7 +959,7 @@ class FlintApi: def export_to_logbook(self, plot_id): """Export a plot to the logbook if available""" - widget = self._get_widget(plot_id) + widget = self._get_plot_widget(plot_id, custom_plot=False) if widget is None: raise ValueError("Widget %s not found" % plot_id) if not hasattr(widget, "logbookAction"): diff --git a/bliss/flint/flint_window.py b/bliss/flint/flint_window.py index 7c290de422b57a8c5948bb683485dc001cbf3014..d40fad6bef15098af0f68726a836e661d3721fc0 100755 --- a/bliss/flint/flint_window.py +++ b/bliss/flint/flint_window.py @@ -6,6 +6,10 @@ # Distributed under the GNU LGPLv3. See LICENSE for more info. """Module containing the description of the main window provided by Flint""" +from __future__ import annotations +from typing import Dict +from typing import NamedTuple + import logging import os @@ -13,6 +17,7 @@ from silx.gui import qt from bliss.flint.widgets.log_widget import LogWidget from bliss.flint.widgets.live_window import LiveWindow +from bliss.flint.widgets.custom_plot import CustomPlot from bliss.flint.widgets.state_indicator import StateIndicator from bliss.flint.model import flint_model @@ -28,6 +33,7 @@ class FlintWindow(qt.QMainWindow): self.__flintState: flint_model.FlintState = None self.__stateIndicator: StateIndicator = None + self.__customPlots: Dict[object, CustomPlot] = {} central_widget = qt.QWidget(self) @@ -58,12 +64,10 @@ class FlintWindow(qt.QMainWindow): return self.__tabs def __tabCloseRequested(self, tabIndex): - new_tab_widget = self.__tabs.widget(tabIndex) - # FIXME: CustomPlot should not be a flint_api concept - # FIXME: There should not be a link to flint_api - plot_id = new_tab_widget._plot_id - flintApi = self.__flintState.flintApi() - flintApi.remove_plot(plot_id) + widget = self.__tabs.widget(tabIndex) + if isinstance(widget, CustomPlot): + plotId = widget.plotId() + self.removeCustomPlot(plotId) def __initLogWindow(self): logWindow = qt.QDialog(self) @@ -293,3 +297,24 @@ class FlintWindow(qt.QMainWindow): settings.setValue("size", self.__logWindow.size()) settings.setValue("pos", self.__logWindow.pos()) settings.endGroup() + + def createCustomPlot(self, plotWidget, name, plot_id, selected, closeable): + """Create a custom plot""" + customPlot = self.createTab( + name, widgetClass=CustomPlot, selected=selected, closeable=closeable + ) + customPlot.setPlotId(plot_id) + customPlot.setName(name) + customPlot.setPlot(plotWidget) + self.__customPlots[plot_id] = customPlot + plotWidget.show() + + def removeCustomPlot(self, plot_id): + """Remove a custom plot by its id""" + customPlot = self.__customPlots.pop(plot_id) + self.removeTab(customPlot) + + def customPlot(self, plot_id) -> CustomPlot: + """If the plot does not exist, returns None""" + plot = self.__customPlots.get(plot_id) + return plot diff --git a/bliss/flint/widgets/custom_plot.py b/bliss/flint/widgets/custom_plot.py new file mode 100644 index 0000000000000000000000000000000000000000..649b958d42a6cdbeb804f1a7078d4152cefefbf7 --- /dev/null +++ b/bliss/flint/widgets/custom_plot.py @@ -0,0 +1,85 @@ +# -*- coding: utf-8 -*- +# +# This file is part of the bliss project +# +# Copyright (c) 2015-2020 Beamline Control Unit, ESRF +# Distributed under the GNU LGPLv3. See LICENSE for more info. + +import logging + +from silx.gui import qt + + +_logger = logging.getLogger(__name__) + + +class CustomPlot(qt.QWidget): + """ + Widget holder to contain plot managed by BLISS. + + It provides few helpers to identify and interact with it. + """ + + def __init__(self, parent=None): + super(CustomPlot, self).__init__(parent=parent) + layout = qt.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + self.__plot = None + self.__plotId = None + self.__name = None + self.__data = {} + self.__methods = {} + + def setName(self, name): + self.__name = name + + def name(self): + return self.__name + + def setPlotId(self, plotId): + self.__plotId = plotId + + def plotId(self): + return self.__plotId + + def logger(self): + global _logger + return _logger + + def widget(self): + return self.__plot + + def data(self): + return self.__data + + def setPlot(self, plot: qt.QWidget): + """ + Set a plot to this custom plot holder. + """ + # FIXME: Remove the previous one if there was one + layout = self.layout() + layout.addWidget(plot) + self.__plot = plot + + def _silxPlot(self): + return self.__plot + + def registerMethod(self, method_id, method): + if method_id in self.__methods: + raise ValueError(f"Method {method_id} already registred") + self.__methods[method_id] = method + + def runMethod(self, method_id, args, kwargs): + method = self.__methods.get(method_id) + if method_id is None: + plot_id = self.plotId() + raise ValueError( + "Method '%s' on plot id '%s' is unknown", method_id, plot_id + ) + return method(self, *args, **kwargs) + + def getData(self, field=None): + if field is None: + return self.__data + else: + return self.__data.get(field, []) diff --git a/doc/docs/flint/flint_data_plotting.md b/doc/docs/flint/flint_data_plotting.md index b32a9783619db3f8a14a2a4706468d2b9226f4a2..bcb6edc57bdbb713a71063797b07e317f05d2b7f 100644 --- a/doc/docs/flint/flint_data_plotting.md +++ b/doc/docs/flint/flint_data_plotting.md @@ -7,25 +7,39 @@ During a BLISS session users may create data (other than scan data) that needs t The **bliss.common.plot** module offers several types of plot: -### curve plot +### Curve plot `class CurvePlot(BasePlot)` - * used for `ascan`, `a2scan`, etc. * plotting of one or several 1D data as curves * Optional x-axis data can be provided * the plot is created using `plot_curve` -### scatter plot +```python +import numpy +from bliss.common import plot as plot_mdl + +# Function +t = numpy.linspace(0, 10 * numpy.pi, 100) +y = numpy.sin(t) +plot_mdl.plot_curve(data=y, name="My sin") + +# Parametric function +t = numpy.linspace(-3, 3, 50) +x = 16 * numpy.sin(t)**3 +y = 13 * numpy.cos(t) - 5 * numpy.cos(2*t) - 2 * numpy.cos(3*t) - numpy.cos(4*t) +plot_mdl.plot_curve(data=y, x=x, name="My heart") +``` + +### Scatter plot `class ScatterPlot(BasePlot)` - * used for `amesh` scan etc. * plotting one or several scattered data * each scatter is a group of three 1D data of same length * the plot is created using `plot_scatter` -### image plot +### Image plot `class ImagePlot(BasePlot)` @@ -34,7 +48,7 @@ The **bliss.common.plot** module offers several types of plot: * the plot is created using `plot_image` -### image + histogram plot +### Image + histogram plot `class HistogramImagePlot(BasePlot)` @@ -42,16 +56,7 @@ The **bliss.common.plot** module offers several types of plot: * two histograms along the X and Y dimensions are displayed * the plot is created using `plot_image_with_histogram` -### curve list plot - -`CurveListPlot(BasePlot)` - - * plot a single list of 1D data as curves - * a slider and an envelop view are provided - * the plot is created using `plot_curve_list` - * this widget is not integrated yet! - -### image stack plot +### Image stack plot `class ImageStackPlot(BasePlot)` @@ -69,12 +74,8 @@ All the above plot types provide the same interface. They take the data as an argument and return a plot. Here's an example on how to display a cosine wave in a curve plot. ```python -from bliss.common.plot import * -import numpy - xx = numpy.linspace(0, 4*3.14, 50) yy = numpy.cos(xx) - plot(yy, name="Plot 0") ``` @@ -152,11 +153,11 @@ p.clear_data() To sum up, here's how to achieve the same cosine chart of the previous section in a different way: ```python -from bliss.common.plot import * +from bliss.common import plot as plot_mdl import numpy # create plot object -p = bliss.common.plot.CurvePlot() +p = plot_mdl.plot_curve() # create data : x and y values xx = numpy.linspace(0, 4*3.14, 50) diff --git a/tests/flint/conftest.py b/tests/flint/conftest.py index 2b0283880f9be7e6fc618de7072858a807de887f..6035d276361a4d780e243ebc4f3e8c28df5dd59c 100644 --- a/tests/flint/conftest.py +++ b/tests/flint/conftest.py @@ -15,13 +15,18 @@ def _get_real_flint(*args, **kwargs): """Replacement function for monkey patch of `bliss.common.plot`""" from bliss.flint import flint from silx.gui import qt + from bliss.flint.client.proxy import FlintClient flint.initApplication([]) settings = qt.QSettings() flint_model = flint.create_flint_model(settings) - interface = flint_model.flintApi() - interface._pid = -666 - return interface + + class FlintClientMock(FlintClient): + def _init(self, process): + self._proxy = flint_model.flintApi() + self._pid = -666 + + return FlintClientMock() @contextmanager diff --git a/tests/flint/test_flint_gui.py b/tests/flint/test_common_plot.py similarity index 59% rename from tests/flint/test_flint_gui.py rename to tests/flint/test_common_plot.py index b7cce60997d1f21b294a59ff9a8b7633e0433680..11aa01a49b34b672425dadafbd2333ba0a8a204a 100755 --- a/tests/flint/test_flint_gui.py +++ b/tests/flint/test_common_plot.py @@ -1,24 +1,45 @@ -"""Testing Flint.""" +"""Testing the BLISS bliss.common.plot API.""" import pytest +import numpy from bliss.common import plot def test_empty_plot(flint_session): - p = plot.plot() + p = plot.plot(name="Foo") pid = plot.get_flint()._pid assert "flint_pid={}".format(pid) in repr(p) - assert p.name == "Plot {}".format(p._plot_id) + assert p.name == "Foo" p = plot.plot(name="Some name") assert "flint_pid={}".format(pid) in repr(p) assert p.name == "Some name" +def test_reuse_custom_plot__api_1_0(flint_session): + """Test reuse of custom plot from an ID""" + widget = plot.plot_curve(name="foo") + cos_data = numpy.cos(numpy.linspace(0, 2 * numpy.pi, 10)) + widget.add_data({"cos": cos_data, "foo": cos_data}) + widget2 = plot.plot_curve(name="foo", existing_id=widget.plot_id) + cos = widget2.get_data()["cos"] + numpy.testing.assert_allclose(cos, cos_data) + + +def test_reuse_custom_plot__api_1_6(flint_session): + """Test reuse of custom plot from a name""" + widget = plot.plot_curve(name="foo", existing_id="myplot") + cos_data = numpy.cos(numpy.linspace(0, 2 * numpy.pi, 10)) + widget.add_data({"cos": cos_data, "foo": cos_data}) + widget2 = plot.plot_curve(name="foo", existing_id="myplot") + cos = widget2.get_data()["cos"] + numpy.testing.assert_allclose(cos, cos_data) + + def test_simple_plot(flint_session): sin = flint_session.env_dict["sin_data"] p = plot.plot(sin) - assert "CurvePlot" in repr(p) + assert "Plot1D" in repr(p) data = p.get_data() assert data == { "default": pytest.approx(sin), @@ -30,7 +51,7 @@ def test_plot_curve_with_x(flint_session): sin = flint_session.env_dict["sin_data"] cos = flint_session.env_dict["cos_data"] p = plot.plot({"sin": sin, "cos": cos}, x="sin") - assert "CurvePlot" in repr(p) + assert "Plot1D" in repr(p) data = p.get_data() assert data == {"sin": pytest.approx(sin), "cos": pytest.approx(cos)} @@ -38,12 +59,12 @@ def test_plot_curve_with_x(flint_session): def test_image_plot(flint_session): grey_image = flint_session.env_dict["grey_image"] p = plot.plot(grey_image) - assert "ImagePlot" in repr(p) + assert "Plot2D" in repr(p) data = p.get_data() assert data == {"default": pytest.approx(grey_image)} colored_image = flint_session.env_dict["colored_image"] p = plot.plot(colored_image) - assert "ImagePlot" in repr(p) + assert "Plot2D" in repr(p) data = p.get_data() assert data == {"default": pytest.approx(colored_image)} @@ -54,7 +75,7 @@ def test_curve_plot(flint_session): scan = flint_session.env_dict["sin_cos_scan"] for sin_cos in (dct, struct, scan): p = plot.plot(sin_cos) - assert "CurvePlot" in repr(p) + assert "Plot1D" in repr(p) data = p.get_data() assert data == { "x": pytest.approx(sin_cos["x"]), diff --git a/tests/flint/test_custom_plots.py b/tests/flint/test_custom_plots.py index 8abd3e2ea20edbd89544b4301cc06c39f3978342..5d352f7168aad0b3e2f4430fd4c1055cbc1942fb 100644 --- a/tests/flint/test_custom_plots.py +++ b/tests/flint/test_custom_plots.py @@ -1,10 +1,55 @@ """Testing custom plots provided by Flint.""" +import pytest import gevent +import numpy from bliss.common import plot from bliss.controllers.lima import roi as lima_roi +def test_empty_plot(flint_session): + flint = plot.get_flint() + p = flint.get_plot(plot_class="curve", name="foo-empty") + assert flint.is_plot_exists("foo-empty") is False + assert p is not None + + +def test_remove_custom_plot(flint_session): + flint = plot.get_flint() + p = flint.get_plot(plot_class="curve", name="foo-rm") + flint.remove_plot(p.plot_id) + assert flint.is_plot_exists("foo-rm") is False + + +def test_custom_plot_curveplot(flint_session): + flint = plot.get_flint() + p = flint.get_plot(plot_class="curve", name="foo-cp") + + cos_data = numpy.cos(numpy.linspace(0, 2 * numpy.pi, 10)) + sin_data = numpy.sin(numpy.linspace(0, 2 * numpy.pi, 10)) + + p.add_data({"cos": cos_data, "sin": sin_data}) + p.select_data("sin", "cos") + p.select_data("sin", "cos", color="green", symbol="x") + p.deselect_data("sin", "cos") + p.remove_data("sin") + + data = p.get_data("cos") + assert data == pytest.approx(cos_data) + + p.clear_data() + + +def test_reuse_custom_plot(flint_session): + flint = plot.get_flint() + p = flint.get_plot(plot_class="curve", unique_name="foo-reuse") + cos_data = numpy.cos(numpy.linspace(0, 2 * numpy.pi, 10)) + p.add_data({"cos": cos_data}) + p2 = flint.get_plot(plot_class="curve", unique_name="foo-reuse") + data = p2.get_data("cos") + assert data == pytest.approx(cos_data) + + def test_select_points(flint_session): flint = plot.get_flint() p = plot.plot() diff --git a/tests/flint/test_flint_api.py b/tests/flint/test_flint_api.py index 9ae20d4b50600f57c96c9fd4156ea0189ed54b57..5fa7bbc89009f512c548ccc9f5b7eea87a55f4e5 100755 --- a/tests/flint/test_flint_api.py +++ b/tests/flint/test_flint_api.py @@ -1,51 +1,13 @@ -"""Testing LogWidget.""" +"""Testing the remote API provided by Flint.""" import logging -import pytest -import numpy import pickle -from silx.gui import qt # noqa: F401 -from silx.gui.utils.testutils import TestCaseQt -from bliss.common import plot -from bliss.flint.client import plots from bliss.controllers.lima import roi as lima_roi logger = logging.getLogger(__name__) -@pytest.mark.skip(reason="This test often segfault the bcu-ci") -@pytest.mark.usefixtures("flint_norpc") -class TestFlint(TestCaseQt): - def test_empty_plot(self): - p = plot.plot() - pid = plot.get_flint()._pid - assert "flint_pid={}".format(pid) in repr(p) - assert p.name == "Plot {}".format(p._plot_id) - - p = plot.plot(name="Some name") - assert "flint_pid={}".format(pid) in repr(p) - assert p.name == "Some name" - - def test_remove_custom_plot(self): - widget = plots.CurvePlot(name="foo-rm") - plot_id = widget.plot_id - flint_api = widget._flint - flint_api.remove_plot(plot_id) - - def test_custom_plot_curveplot(self): - widget = plots.CurvePlot(name="foo") - - cos_data = numpy.cos(numpy.linspace(0, 2 * numpy.pi, 10)) - sin_data = numpy.sin(numpy.linspace(0, 2 * numpy.pi, 10)) - - widget.add_data({"cos": cos_data, "sin": sin_data}) - widget.select_data("sin", "cos") - widget.select_data("sin", "cos", color="green", symbol="x") - widget.deselect_data("sin", "cos") - widget.clear_data() - - def test_used_object(): """Make sure object shared in the RPC are still picklable""" roi = lima_roi.ArcRoi(0, 1, 2, 3, 4, 5, 6)