plots.py 19.3 KB
Newer Older
1
2
3
4
5
6
7
8
9
10
# -*- 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.
"""
Provides plot helper class to deal with flint proxy.
"""

11
import typing
12
13
14
15
from typing import Union
from typing import Optional

import numpy
16
import gevent
17
import contextlib
18

19
20
21
22
23
24
25
26
27
from . import proxy
from bliss.common import event


class BasePlot(object):

    # Name of the corresponding silx widget
    WIDGET = NotImplemented

28
29
30
    # Available name to identify this plot
    ALIASES = []

31
    def __init__(self, flint, plot_id, register=False):
32
        """Describe a custom plot handled by Flint.
33
        """
34
35
        self._plot_id = plot_id
        self._flint = flint
36
37
        self._xlabel = None
        self._ylabel = None
38
        self._init()
39
        if flint is not None:
40
41
            if register:
                self._init_plot()
42
43
44
45
46

    def _init(self):
        """Allow to initialize extra attributes in a derived class, without
        redefining the constructor"""
        pass
47

Valentin Valls's avatar
Valentin Valls committed
48
49
    def _init_plot(self):
        """Inherits it to custom the plot initialization"""
50
51
52
53
54
        if self._xlabel is not None:
            self.submit("setGraphXLabel", self._xlabel)
        if self._ylabel is not None:
            self.submit("setGraphYLabel", self._ylabel)

55
56
57
58
59
60
61
62
63
    @property
    def title(self):
        return self._title

    @title.setter
    def title(self, txt):
        self._title = str(txt)
        self.submit("setGraphTitle", self._title)

64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
    @property
    def xlabel(self):
        return self._xlabel

    @xlabel.setter
    def xlabel(self, txt):
        self._xlabel = str(txt)
        self.submit("setGraphXLabel", self._xlabel)

    @property
    def ylabel(self):
        return self._ylabel

    @ylabel.setter
    def ylabel(self, txt):
        self._ylabel = str(txt)
        self.submit("setGraphYLabel", self._ylabel)
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108

    def __repr__(self):
        try:
            # Protect problems on RPC
            name = self._flint.get_plot_name(self._plot_id)
        except Exception:
            name = None
        return "{}(plot_id={!r}, flint_pid={!r}, name={!r})".format(
            self.__class__.__name__, self.plot_id, self.flint_pid, name
        )

    def submit(self, method, *args, **kwargs):
        return self._flint.run_method(self.plot_id, method, args, kwargs)

    # Properties

    @property
    def flint_pid(self):
        return self._flint._pid

    @property
    def plot_id(self):
        return self._plot_id

    @property
    def name(self):
        return self._flint.get_plot_name(self._plot_id)

109
110
111
112
    def focus(self):
        """Set the focus on this plot"""
        self._flint.set_plot_focus(self._plot_id)

113
114
115
116
    def export_to_logbook(self):
        """Set the focus on this plot"""
        self._flint.export_to_logbook(self._plot_id)

117
118
    def get_data_range(self):
        """Returns the current data range used by this plot"""
119
        return self.submit("getDataRange")
120

121
122
    # Clean up

123
124
125
126
127
    def is_open(self) -> bool:
        """Returns true if the plot is still open in the linked Flint
        application"""
        try:
            return self._flint.is_plot_exists(self._plot_id)
Valentin Valls's avatar
Valentin Valls committed
128
        except Exception:
129
130
131
            # The proxy is maybe dead
            return False

132
133
134
135
136
137
138
139
140
141
    def close(self):
        self._flint.remove_plot(self.plot_id)

    # Interaction

    def _wait_for_user_selection(self, request_id):
        """Wait for a user selection and clean up result in case of error"""
        proxy.FLINT_LOGGER.warning("Waiting for selection in Flint window.")
        flint = self._flint
        results = gevent.queue.Queue()
142
        event.connect(flint._proxy, request_id, results.put)
143
144
145
146
        try:
            result = results.get()
            return result
        except Exception:
147
148
            try:
                flint.cancel_request(request_id)
Valentin Valls's avatar
Valentin Valls committed
149
            except Exception:
150
151
152
153
154
                proxy.FLINT_LOGGER.debug(
                    "Error while canceling the request", exc_info=True
                )
                pass
            proxy.FLINT_LOGGER.warning("Plot selection cancelled. An error occurred.")
155
156
            raise
        except KeyboardInterrupt:
157
158
            try:
                flint.cancel_request(request_id)
Valentin Valls's avatar
Valentin Valls committed
159
            except Exception:
160
161
162
163
                proxy.FLINT_LOGGER.debug(
                    "Error while canceling the request", exc_info=True
                )
                pass
164
165
166
            proxy.FLINT_LOGGER.warning("Plot selection cancelled by bliss user.")
            raise

167
168
    def select_shapes(
        self,
Valentin Valls's avatar
Valentin Valls committed
169
        initial_selection: typing.Optional[typing.List[typing.Any]] = None,
170
171
        kinds: typing.Union[str, typing.List[str]] = "rectangle",
    ):
Valentin Valls's avatar
Valentin Valls committed
172
173
174
175
176
        """
        Request user selection of shapes.

        `initial_selection` is a list of ROIs from `bliss.controllers.lima.roi`.

177
178
179
        It also supports key-value dictionary for simple rectangle.
        In this case, the dictionary contains "kind" (which is "Rectangle"),
        and "label", "origin" and "size" which are tuples of 2 floats.
Valentin Valls's avatar
Valentin Valls committed
180
181
182

        Arguments:
            initial_selection: List of shapes already selected.
183
184
185
186
            kinds: List or ROI kind which can be created (for now, "rectangle"
                (described as a dict), "lima-rectangle", "lima-arc",
                "lima-vertical-profile",
                "lima-horizontal-profile")
Valentin Valls's avatar
Valentin Valls committed
187
        """
188
        flint = self._flint
189
190
191
        request_id = flint.request_select_shapes(
            self._plot_id, initial_selection, kinds=kinds
        )
Valentin Valls's avatar
Valentin Valls committed
192
193
        result = self._wait_for_user_selection(request_id)
        return result
194
195
196
197
198
199
200
201
202
203
204

    def select_points(self, nb):
        flint = self._flint
        request_id = flint.request_select_points(self._plot_id, nb)
        return self._wait_for_user_selection(request_id)

    def select_shape(self, shape):
        flint = self._flint
        request_id = flint.request_select_shape(self._plot_id, shape)
        return self._wait_for_user_selection(request_id)

205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
    def _set_colormap(
        self,
        lut: Optional[str] = None,
        vmin: Optional[Union[float, str]] = None,
        vmax: Optional[Union[float, str]] = None,
        normalization: Optional[str] = None,
        gamma_normalization: Optional[float] = None,
        autoscale: Optional[bool] = None,
        autoscale_mode: Optional[str] = None,
    ):
        """
        Allows to setup the default colormap of this plot.

        Arguments:
            lut: A name of a LUT. At least the following names are supported:
                 `"gray"`, `"reversed gray"`, `"temperature"`, `"red"`, `"green"`,
                 `"blue"`, `"jet"`, `"viridis"`, `"magma"`, `"inferno"`, `"plasma"`.
            vmin: Can be a float or "`auto"` to set the min level value
            vmax: Can be a float or "`auto"` to set the max level value
            normalization: Can be on of `"linear"`, `"log"`, `"arcsinh"`,
                           `"sqrt"`, `"gamma"`.
            gamma_normalization: float defining the gamma normalization.
                                 If this argument is defined the `normalization`
                                 argument is ignored
            autoscale: If true, the auto scale is set for min and max
                       (vmin and vmax arguments are ignored)
            autoscale_mode: Can be one of `"minmax"` or `"3stddev"`
        """
        flint = self._flint
        flint.set_plot_colormap(
            self._plot_id,
            lut=lut,
            vmin=vmin,
            vmax=vmax,
            normalization=normalization,
            gammaNormalization=gamma_normalization,
            autoscale=autoscale,
            autoscaleMode=autoscale_mode,
        )

245

246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
class _DataPlot(BasePlot):
    """
    Plot providing a common API to store data

    This was introduced for baward compatibility with BLISS <= 1.8

    FIXME: This have to be deprecated and removed. Plots should be updated using
    another API
    """

    # Data handling

    def upload_data(self, field, data):
        """
        Update data as an identifier into the server side

        Argument:
            field: Identifier in the targeted plot
            data: Data to upload
        """
        return self.submit("updateStoredData", field, data)

    def upload_data_if_needed(self, field, data):
        """Upload data only if it is a numpy array or a list
        """
        if isinstance(data, (numpy.ndarray, list)):
            self.submit("updateStoredData", field, data)
            return field
        else:
            return data

    def add_data(self, data, field="default"):
        # Get fields
        if isinstance(data, dict):
            fields = list(data)
        else:
            fields = numpy.array(data).dtype.fields
        # Single data
        if fields is None:
            data_dict = dict([(field, data)])
        # Multiple data
        else:
            data_dict = dict((field, data[field]) for field in fields)
        # Send data
        for field, value in data_dict.items():
            self.upload_data(field, value)
        # Return data dict
        return data_dict

    def remove_data(self, field):
        self.submit("removeStoredData", field)

    def select_data(self, *names, **kwargs):
        self.submit("selectStoredData", *names, **kwargs)

    def deselect_data(self, *names):
        self.submit("deselectStoredData", *names)

    def clear_data(self):
        self.submit("clear")

    def get_data(self, field=None):
        return self.submit("getStoredData", field=field)


311
312
313
# Plot classes


314
class Plot1D(_DataPlot):
315
316

    # Name of the corresponding silx widget
317
    WIDGET = "bliss.flint.custom_plots.silx_plots.Plot1D"
318
319
320

    # Available name to identify this plot
    ALIASES = ["curve", "plot1d"]
321
322
323
324
325
326
327
328
329

    def update_axis_marker(
        self, unique_name: str, channel_name, position: float, text: str
    ):
        """Mark a location in a specific axis in this plot"""
        self._flint.update_axis_marker(
            self._plot_id, unique_name, channel_name, position, text
        )

330
    def add_curve(self, x, y, **kwargs):
331
332
333
        """
        Create a curve in this plot.
        """
334
335
336
337
        if x is None:
            x = numpy.arange(len(y))
        if y is None:
            raise ValueError("A y value is expected. None found.")
338
339
        self.submit("addCurve", x, y, **kwargs)

340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
    def set_xaxis_scale(self, value):
        """
        Set the X-axis scale of this plot.

        Argument:
            value: One of "linear" or "log"
        """
        assert value in ("linear", "log")
        flint = self._flint
        flint.run_method(self._plot_id, "setXAxisLogarithmic", [value == "log"], {})

    def set_yaxis_scale(self, value):
        """
        Set the Y-axis scale of this plot.

        Argument:
            value: One of "linear" or "log"
        """
        assert value in ("linear", "log")
        flint = self._flint
        flint.run_method(self._plot_id, "setYAxisLogarithmic", [value == "log"], {})

362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
    def clear_items(self):
        """Remove all the items described in this plot

        If no transaction was open, it will update the plot and refresh the plot
        view.
        """
        self.submit("clearItems")

    def add_curve_item(self, xname: str, yname: str, legend: str = None, **kwargs):
        """Define a specific curve item

        If no transaction was open, it will update the plot and refresh the plot
        view.
        """
        self.submit("addCurveItem", xname, yname, legend=legend, **kwargs)

    def remove_item(self, legend: str):
        """Remove a specific item.

        If no transaction was open, it will update the plot and refresh the plot
        view.
        """
        self.submit("removeItem", legend)

    def set_data(self, **kwargs):
        """Set data named from keys with associated values.

        If no transaction was open, it will update the plot and refresh the plot
        view.
        """
        self.submit("setData", **kwargs)

    def append_data(self, **kwargs):
        """Append data named from keys with associated values.

        If no transaction was open, it will update the plot and refresh the plot
        view.
        """
        self.submit("appendData", **kwargs)

    @contextlib.contextmanager
    def transaction(self, resetzoom=True):
        """Context manager to handle a set of changes and a single refresh of
        the plot. This is needed cause the action are done on the plot
        asynchronously"""
        self.submit("setAutoUpdatePlot", False)
        try:
            yield
        finally:
            self.submit("setAutoUpdatePlot", True)
            self.submit("updatePlot", resetzoom=resetzoom)

414

415
class ScatterView(_DataPlot):
416
417

    # Name of the corresponding silx widget
418
    WIDGET = "bliss.flint.custom_plots.silx_plots.ScatterView"
419
420
421

    # Available name to identify this plot
    ALIASES = ["scatter"]
422

423
    def _init(self):
424
425
426
        # Make it public
        self.set_colormap = self._set_colormap

427
    def set_data(self, x, y, value, resetzoom=True, **kwargs):
428
429
430
431
432
        if x is None or y is None or value is None:
            self.clear_data()
        else:
            self.submit("setData", x, y, value, **kwargs)

433

434
class Plot2D(_DataPlot):
435
436

    # Name of the corresponding silx widget
437
    WIDGET = "bliss.flint.custom_plots.silx_plots.Plot2D"
438
439

    # Available name to identify this plot
440
    ALIASES = ["plot2d"]
441

442
    def _init(self):
443
444
445
        # Make it public
        self.set_colormap = self._set_colormap

446
    def _init_plot(self):
447
        super(Plot2D, self)._init_plot()
448
        self.submit("setKeepDataAspectRatio", True)
449
        self.submit("setDisplayedIntensityHistogram", True)
450

451
452
453
    def add_image(self, data, **kwargs):
        self.submit("addImage", data, **kwargs)

454
    def select_mask(self, initial_mask: numpy.ndarray = None, directory: str = None):
455
456
457
458
        """Request a mask image from user selection.

        Argument:
            initial_mask: An initial mask image, else None
459
            directory: Directory used to import/export masks
Valentin Valls's avatar
Valentin Valls committed
460

461
462
463
464
        Return:
            A numpy array containing the user mask image
        """
        flint = self._flint
465
466
467
        request_id = flint.request_select_mask_image(
            self._plot_id, initial_mask, directory=directory
        )
468
469
470
        return self._wait_for_user_selection(request_id)


471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
class CurveStack(BasePlot):
    # Name of the corresponding silx widget
    WIDGET = "bliss.flint.custom_plots.curve_stack.CurveStack"

    # Available name to identify this plot
    ALIASES = ["curvestack"]

    def set_data(self, curves, x=None, reset_zoom=None):
        """
        Set the data displayed in this plot.

        Arguments:
            curves: The data of the curves (first dim is curve index, second dim
                    is the x index)
            x: Mapping of the real X axis values to use
            reset_zoom: If True force reset zoom, else the user selection is
                        applied
        """
489
        self.submit("setData", data=curves, x=x, resetZoom=reset_zoom)
490

491
492
493
    def clear_data(self):
        self.submit("clear")

494

495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
class TimeCurvePlot(BasePlot):
    # Name of the corresponding silx widget
    WIDGET = "bliss.flint.custom_plots.time_curve_plot.TimeCurvePlot"

    # Available name to identify this plot
    ALIASES = ["timecurveplot"]

    def select_x_axis(self, name: str):
        """
        Select the x-axis to use

        Arguments:
            name: Name of the data to use as x-axis
        """
        self.submit("setXName", name)

511
512
513
514
515
516
517
518
519
    def select_x_duration(self, second: int):
        """
        Select the x-axis duration in second

        Arguments:
            second: Amount of seconds displayed in the x-axis
        """
        self.submit("setXDuration", second)

520
    def add_time_curve_item(self, yname, **kwargs):
521
        """
522
        Select a dedicated data to be displayed against the time.
523
524
525
526
527

        Arguments:
            name: Name of the data to use as y-axis
            kwargs: Associated style (see `addCurve` from silx plot)
        """
528
        self.submit("addTimeCurveItem", yname, **kwargs)
529
530
531
532
533
534
535
536
537
538

    def set_data(self, **kwargs):
        """
        Set the data displayed in this plot.

        Arguments:
            kwargs: Name of the data associated to the new numpy array to use
        """
        self.submit("setData", **kwargs)

Valentin Valls's avatar
Valentin Valls committed
539
540
541
542
543
544
545
546
547
    def append_data(self, **kwargs):
        """
        Append the data displayed in this plot.

        Arguments:
            kwargs: Name of the data associated to the numpy array to append
        """
        self.submit("appendData", **kwargs)

548
549
550
    def clear_data(self):
        self.submit("clear")

551

552
class ImageView(_DataPlot):
553
554

    # Name of the corresponding silx widget
555
    WIDGET = "bliss.flint.custom_plots.silx_plots.ImageView"
556
557

    # Available name to identify this plot
558
    ALIASES = ["image", "imageview", "histogramimage"]
559

560
561
562
563
    def _init(self):
        # Make it public
        self.set_colormap = self._set_colormap

564
565
566
567
568
    def _init_plot(self):
        super(ImageView, self)._init_plot()
        self.submit("setKeepDataAspectRatio", True)
        self.submit("setDisplayedIntensityHistogram", True)

569
570
571
    def set_data(self, data, **kwargs):
        self.submit("setImage", data, **kwargs)

572

573
class StackView(_DataPlot):
574
575

    # Name of the corresponding silx widget
576
    WIDGET = "bliss.flint.custom_plots.silx_plots.StackImageView"
577
578

    # Available name to identify this plot
Valentin Valls's avatar
Valentin Valls committed
579
    ALIASES = ["stack", "imagestack", "stackview"]
580

581
582
583
584
585
586
587
    def _init(self):
        # Make it public
        self.set_colormap = self._set_colormap

    def set_data(self, data, **kwargs):
        self.submit("setStack", data, **kwargs)

588

589
class LiveCurvePlot(BasePlot):
590
591
592
593
594

    WIDGET = None

    ALIASES = ["curve"]

Valentin Valls's avatar
Valentin Valls committed
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
    def update_user_data(
        self, unique_name: str, channel_name: str, ydata: Optional[numpy.ndarray]
    ):
        """Add user data to a live plot.

        It will define a curve in the plot using the y-data provided and the
        x-data from the parent item (defined by the `channel_name`)

        The key `unique_name` + `channel_name` is unique. So if it already
        exists the item will be updated.

        Arguments:
            unique_name: Name of this item in the property tree
            channel_name: Name of the channel that will be used as parent for
                this item. If this parent item does not exist, it is created
                but set hidden.
            ydata: Y-data for this item. If `None`, if the item already exists,
                it is removed from the plot
        """
        if ydata is not None:
            ydata = numpy.asarray(ydata)
        self._flint.update_user_data(self._plot_id, unique_name, channel_name, ydata)

618

619
class LiveImagePlot(BasePlot):
620
621
622
623
624

    WIDGET = None

    ALIASES = ["image"]

625
626
627
628
    def _init(self):
        # Make it public
        self.set_colormap = self._set_colormap

629

630
class LiveScatterPlot(BasePlot):
631
632
633
634
635

    WIDGET = None

    ALIASES = ["scatter"]

636
637
638
639
    def _init(self):
        # Make it public
        self.set_colormap = self._set_colormap

640

641
class LiveMcaPlot(BasePlot):
642
643
644
645

    WIDGET = None

    ALIASES = ["mca"]
646
647


648
class LiveOneDimPlot(BasePlot):
649
650
651
652
653
654

    WIDGET = None

    ALIASES = ["onedim"]


655
656
657
658
659
660
661
662
663
CUSTOM_CLASSES = [
    Plot1D,
    Plot2D,
    ScatterView,
    ImageView,
    StackView,
    CurveStack,
    TimeCurvePlot,
]
664

665
666
667
668
669
670
671
LIVE_CLASSES = [
    LiveCurvePlot,
    LiveImagePlot,
    LiveScatterPlot,
    LiveMcaPlot,
    LiveOneDimPlot,
]
672

673
674
675
676
677
678
# For compatibility
CurvePlot = Plot1D
ImagePlot = Plot2D
ScatterPlot = ScatterView
HistogramImagePlot = ImageView
ImageStackPlot = StackView