phasorpy 0.1__cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
phasorpy/plot.py ADDED
@@ -0,0 +1,2074 @@
1
+ """Plot phasor coordinates and related data.
2
+
3
+ The ``phasorpy.plot`` module provides functions and classes to visualize
4
+ phasor coordinates and related data using the
5
+ `matplotlib <https://matplotlib.org/>`_ library.
6
+
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ __all__ = [
12
+ 'PhasorPlot',
13
+ 'PhasorPlotFret',
14
+ 'plot_phasor',
15
+ 'plot_phasor_image',
16
+ 'plot_signal_image',
17
+ 'plot_polar_frequency',
18
+ ]
19
+
20
+ import math
21
+ import os
22
+ from collections.abc import Sequence
23
+ from typing import TYPE_CHECKING
24
+
25
+ if TYPE_CHECKING:
26
+ from ._typing import Any, ArrayLike, NDArray, Literal, IO
27
+
28
+ from matplotlib.axes import Axes
29
+ from matplotlib.image import AxesImage
30
+ from matplotlib.figure import Figure
31
+
32
+ import numpy
33
+ from matplotlib import pyplot
34
+ from matplotlib.font_manager import FontProperties
35
+ from matplotlib.gridspec import GridSpec
36
+ from matplotlib.lines import Line2D
37
+ from matplotlib.patches import Arc, Circle, Ellipse, Polygon
38
+ from matplotlib.path import Path
39
+ from matplotlib.patheffects import AbstractPathEffect
40
+ from matplotlib.widgets import Slider
41
+
42
+ from ._phasorpy import _intersection_circle_circle, _intersection_circle_line
43
+ from ._utils import (
44
+ dilate_coordinates,
45
+ parse_kwargs,
46
+ phasor_from_polar_scalar,
47
+ phasor_to_polar_scalar,
48
+ sort_coordinates,
49
+ update_kwargs,
50
+ )
51
+ from .phasor import (
52
+ phasor_from_fret_acceptor,
53
+ phasor_from_fret_donor,
54
+ phasor_from_lifetime,
55
+ phasor_semicircle,
56
+ phasor_to_apparent_lifetime,
57
+ phasor_to_polar,
58
+ phasor_transform,
59
+ )
60
+
61
+ GRID_COLOR = '0.5'
62
+ GRID_LINESTYLE = ':'
63
+ GRID_LINESTYLE_MAJOR = '-'
64
+ GRID_LINEWIDH = 1.0
65
+ GRID_LINEWIDH_MINOR = 0.5
66
+ GRID_FILL = False
67
+
68
+
69
+ class PhasorPlot:
70
+ """Phasor plot.
71
+
72
+ Create publication quality visualizations of phasor coordinates.
73
+
74
+ Parameters
75
+ ----------
76
+ allquadrants : bool, optional
77
+ Show all quandrants of phasor space.
78
+ By default, only the first quadrant with universal semicircle is shown.
79
+ ax : matplotlib axes, optional
80
+ Matplotlib axes used for plotting.
81
+ By default, a new subplot axes is created.
82
+ frequency : float, optional
83
+ Laser pulse or modulation frequency in MHz.
84
+ grid : bool, optional, default: True
85
+ Display polar grid or semicircle.
86
+ **kwargs
87
+ Additional properties to set on `ax`.
88
+
89
+ See Also
90
+ --------
91
+ phasorpy.plot.plot_phasor
92
+ :ref:`sphx_glr_tutorials_api_phasorpy_phasorplot.py`
93
+
94
+ """
95
+
96
+ _ax: Axes
97
+ """Matplotlib axes."""
98
+
99
+ _limits: tuple[tuple[float, float], tuple[float, float]]
100
+ """Axes limits (xmin, xmax), (ymin, ymax)."""
101
+
102
+ _full: bool
103
+ """Show all quadrants of phasor space."""
104
+
105
+ _semicircle_ticks: SemicircleTicks | None
106
+ """Last SemicircleTicks instance created."""
107
+
108
+ _frequency: float
109
+ """Laser pulse or modulation frequency in MHz."""
110
+
111
+ def __init__(
112
+ self,
113
+ /,
114
+ allquadrants: bool | None = None,
115
+ ax: Axes | None = None,
116
+ *,
117
+ frequency: float | None = None,
118
+ grid: bool = True,
119
+ **kwargs: Any,
120
+ ) -> None:
121
+ # initialize empty phasor plot
122
+ self._ax = pyplot.subplots()[1] if ax is None else ax
123
+ self._ax.format_coord = ( # type: ignore[method-assign]
124
+ self._on_format_coord
125
+ )
126
+
127
+ self._semicircle_ticks = None
128
+
129
+ self._full = bool(allquadrants)
130
+ if self._full:
131
+ xlim = (-1.05, 1.05)
132
+ ylim = (-1.05, 1.05)
133
+ xticks: tuple[float, ...] = (-1.0, -0.5, 0.0, 0.5, 1.0)
134
+ yticks: tuple[float, ...] = (-1.0, -0.5, 0.0, 0.5, 1.0)
135
+ if grid:
136
+ self.polar_grid()
137
+ else:
138
+ xlim = (-0.05, 1.05)
139
+ ylim = (-0.05, 0.7)
140
+ xticks = (0.0, 0.2, 0.4, 0.6, 0.8, 1.0)
141
+ yticks = (0.0, 0.2, 0.4, 0.6)
142
+ if grid:
143
+ self.semicircle(frequency=frequency)
144
+
145
+ title = 'Phasor plot'
146
+ if frequency is not None:
147
+ self._frequency = float(frequency)
148
+ title += f' ({frequency:g} MHz)'
149
+ else:
150
+ self._frequency = 0.0
151
+
152
+ update_kwargs(
153
+ kwargs,
154
+ title=title,
155
+ xlabel='G, real',
156
+ ylabel='S, imag',
157
+ aspect='equal',
158
+ xlim=xlim,
159
+ ylim=ylim,
160
+ xticks=xticks,
161
+ yticks=yticks,
162
+ )
163
+ self._limits = (kwargs['xlim'], kwargs['ylim'])
164
+ self._ax.set(**kwargs)
165
+
166
+ @property
167
+ def ax(self) -> Axes:
168
+ """Matplotlib :py:class:`matplotlib.axes.Axes`."""
169
+ return self._ax
170
+
171
+ @property
172
+ def fig(self) -> Figure | None:
173
+ """Matplotlib :py:class:`matplotlib.figure.Figure`."""
174
+ return self._ax.get_figure()
175
+
176
+ @property
177
+ def dataunit_to_point(self) -> float:
178
+ """Factor to convert data to point unit."""
179
+ fig = self._ax.get_figure()
180
+ assert fig is not None
181
+ length = fig.bbox_inches.height * self._ax.get_position().height * 72.0
182
+ vrange: float = numpy.diff(self._ax.get_ylim()).item()
183
+ return length / vrange
184
+
185
+ def show(self) -> None:
186
+ """Display all open figures. Call :py:func:`matplotlib.pyplot.show`."""
187
+ # self.fig.show()
188
+ pyplot.show()
189
+
190
+ def save(
191
+ self,
192
+ file: str | os.PathLike[Any] | IO[bytes] | None,
193
+ /,
194
+ **kwargs: Any,
195
+ ) -> None:
196
+ """Save current figure to file.
197
+
198
+ Parameters
199
+ ----------
200
+ file : str, path-like, or binary file-like
201
+ Path or Python file-like object to write the current figure to.
202
+ **kwargs
203
+ Additional keyword arguments passed to
204
+ :py:func:`matplotlib:pyplot.savefig`.
205
+
206
+ """
207
+ pyplot.savefig(file, **kwargs)
208
+
209
+ def plot(
210
+ self,
211
+ real: ArrayLike,
212
+ imag: ArrayLike,
213
+ /,
214
+ fmt: str = 'o',
215
+ *,
216
+ label: str | Sequence[str] | None = None,
217
+ **kwargs: Any,
218
+ ) -> list[Line2D]:
219
+ """Plot imag versus real coordinates as markers and/or lines.
220
+
221
+ Parameters
222
+ ----------
223
+ real : array_like
224
+ Real component of phasor coordinates.
225
+ Must be one or two dimensional.
226
+ imag : array_like
227
+ Imaginary component of phasor coordinates.
228
+ Must be of same shape as `real`.
229
+ fmt : str, optional, default: 'o'
230
+ Matplotlib style format string.
231
+ label : str or sequence of str, optional
232
+ Plot label.
233
+ May be a sequence if phasor coordinates are two dimensional arrays.
234
+ **kwargs
235
+ Additional parameters passed to
236
+ :py:meth:`matplotlib.axes.Axes.plot`.
237
+
238
+ Returns
239
+ -------
240
+ list[matplotlib.lines.Line2D]
241
+ Lines representing data plotted last.
242
+
243
+ """
244
+ lines = []
245
+ if fmt == 'o':
246
+ if 'marker' in kwargs:
247
+ fmt = ''
248
+ if 'linestyle' not in kwargs and 'ls' not in kwargs:
249
+ kwargs['linestyle'] = ''
250
+ args = (fmt,) if fmt else ()
251
+ ax = self._ax
252
+ if label is not None and (
253
+ isinstance(label, str) or not isinstance(label, Sequence)
254
+ ):
255
+ label = (label,)
256
+ for (
257
+ i,
258
+ (re, im),
259
+ ) in enumerate(
260
+ zip(
261
+ numpy.atleast_2d(numpy.asarray(real)),
262
+ numpy.atleast_2d(numpy.asarray(imag)),
263
+ )
264
+ ):
265
+ lbl = None
266
+ if label is not None:
267
+ try:
268
+ lbl = label[i]
269
+ except IndexError:
270
+ pass
271
+ lines = ax.plot(re, im, *args, label=lbl, **kwargs)
272
+ if label is not None:
273
+ ax.legend()
274
+ self._reset_limits()
275
+ return lines
276
+
277
+ def _histogram2d(
278
+ self,
279
+ real: ArrayLike,
280
+ imag: ArrayLike,
281
+ /,
282
+ **kwargs: Any,
283
+ ) -> tuple[NDArray[Any], NDArray[Any], NDArray[Any]]:
284
+ """Return 2D histogram of imag versus real coordinates."""
285
+ update_kwargs(kwargs, range=self._limits)
286
+ (xmin, xmax), (ymin, ymax) = kwargs['range']
287
+ assert xmax > xmin and ymax > ymin
288
+ bins = kwargs.get('bins', 128)
289
+ if isinstance(bins, int):
290
+ assert bins > 0
291
+ aspect = (xmax - xmin) / (ymax - ymin)
292
+ if aspect > 1:
293
+ bins = (bins, max(int(bins / aspect), 1))
294
+ else:
295
+ bins = (max(int(bins * aspect), 1), bins)
296
+ kwargs['bins'] = bins
297
+ return numpy.histogram2d(
298
+ numpy.asanyarray(real).reshape(-1),
299
+ numpy.asanyarray(imag).reshape(-1),
300
+ **kwargs,
301
+ )
302
+
303
+ def _reset_limits(self) -> None:
304
+ """Reset axes limits."""
305
+ try:
306
+ self._ax.set(xlim=self._limits[0], ylim=self._limits[1])
307
+ except AttributeError:
308
+ pass
309
+
310
+ def hist2d(
311
+ self,
312
+ real: ArrayLike,
313
+ imag: ArrayLike,
314
+ /,
315
+ **kwargs: Any,
316
+ ) -> None:
317
+ """Plot 2D histogram of imag versus real coordinates.
318
+
319
+ Parameters
320
+ ----------
321
+ real : array_like
322
+ Real component of phasor coordinates.
323
+ imag : array_like
324
+ Imaginary component of phasor coordinates.
325
+ Must be of same shape as `real`.
326
+ **kwargs
327
+ Additional parameters passed to :py:meth:`numpy.histogram2d`
328
+ and :py:meth:`matplotlib.axes.Axes.pcolormesh`.
329
+
330
+ """
331
+ kwargs_hist2d = parse_kwargs(
332
+ kwargs, 'bins', 'range', 'density', 'weights'
333
+ )
334
+ h, xedges, yedges = self._histogram2d(real, imag, **kwargs_hist2d)
335
+
336
+ update_kwargs(kwargs, cmap='Blues', norm='log')
337
+ cmin = kwargs.pop('cmin', 1)
338
+ cmax = kwargs.pop('cmax', None)
339
+ if cmin is not None:
340
+ h[h < cmin] = None
341
+ if cmax is not None:
342
+ h[h > cmax] = None
343
+ self._ax.pcolormesh(xedges, yedges, h.T, **kwargs)
344
+ self._reset_limits()
345
+
346
+ def contour(
347
+ self,
348
+ real: ArrayLike,
349
+ imag: ArrayLike,
350
+ /,
351
+ **kwargs: Any,
352
+ ) -> None:
353
+ """Plot contours of imag versus real coordinates (not implemented).
354
+
355
+ Parameters
356
+ ----------
357
+ real : array_like
358
+ Real component of phasor coordinates.
359
+ imag : array_like
360
+ Imaginary component of phasor coordinates.
361
+ Must be of same shape as `real`.
362
+ **kwargs
363
+ Additional parameters passed to :py:func:`numpy.histogram2d`
364
+ and :py:meth:`matplotlib.axes.Axes.contour`.
365
+
366
+ """
367
+ update_kwargs(kwargs, cmap='Blues', norm='log')
368
+ kwargs_hist2d = parse_kwargs(
369
+ kwargs, 'bins', 'range', 'density', 'weights'
370
+ )
371
+ h, xedges, yedges = self._histogram2d(real, imag, **kwargs_hist2d)
372
+ xedges = xedges[:-1] + ((xedges[1] - xedges[0]) / 2.0)
373
+ yedges = yedges[:-1] + ((yedges[1] - yedges[0]) / 2.0)
374
+ self._ax.contour(xedges, yedges, h.T, **kwargs)
375
+ self._reset_limits()
376
+
377
+ def imshow(
378
+ self,
379
+ image: ArrayLike,
380
+ /,
381
+ **kwargs: Any,
382
+ ) -> None:
383
+ """Plot an image, for example, a 2D histogram (not implemented).
384
+
385
+ Parameters
386
+ ----------
387
+ image : array_like
388
+ Image to display.
389
+ **kwargs
390
+ Additional parameters passed to
391
+ :py:meth:`matplotlib.axes.Axes.imshow`.
392
+
393
+ """
394
+ raise NotImplementedError
395
+
396
+ def components(
397
+ self,
398
+ real: ArrayLike,
399
+ imag: ArrayLike,
400
+ /,
401
+ fraction: ArrayLike | None = None,
402
+ labels: Sequence[str] | None = None,
403
+ label_offset: float | None = None,
404
+ **kwargs: Any,
405
+ ) -> None:
406
+ """Plot linear combinations of phasor coordinates or ranges thereof.
407
+
408
+ Parameters
409
+ ----------
410
+ real : (N,) array_like
411
+ Real component of phasor coordinates.
412
+ imag : (N,) array_like
413
+ Imaginary component of phasor coordinates.
414
+ fraction : (N,) array_like, optional
415
+ Weight associated with each component.
416
+ If None (default), outline the polygon area of possible linear
417
+ combinations of components.
418
+ Else, draw lines from the component coordinates to the weighted
419
+ average.
420
+ labels : Sequence of str, optional
421
+ Text label for each component.
422
+ label_offset : float, optional
423
+ Distance of text label to component coordinate.
424
+ **kwargs
425
+ Additional parameters passed to
426
+ :py:class:`matplotlib.patches.Polygon`,
427
+ :py:class:`matplotlib.lines.Line2D`, or
428
+ :py:class:`matplotlib.axes.Axes.annotate`
429
+
430
+ """
431
+ # TODO: use convex hull for outline
432
+ # TODO: improve automatic placement of labels
433
+ # TODO: catch more annotate properties?
434
+ real, imag, indices = sort_coordinates(real, imag)
435
+
436
+ label_ = kwargs.pop('label', None)
437
+ marker = kwargs.pop('marker', None)
438
+ color = kwargs.pop('color', None)
439
+ fontsize = kwargs.pop('fontsize', 12)
440
+ fontweight = kwargs.pop('fontweight', 'bold')
441
+ horizontalalignment = kwargs.pop('horizontalalignment', 'center')
442
+ verticalalignment = kwargs.pop('verticalalignment', 'center')
443
+ if label_offset is None:
444
+ label_offset = numpy.diff(self._ax.get_xlim()).item() * 0.04
445
+
446
+ if labels is not None:
447
+ if len(labels) != real.size:
448
+ raise ValueError(
449
+ f'number labels={len(labels)} != components={real.size}'
450
+ )
451
+ labels = [labels[i] for i in indices]
452
+ textposition = dilate_coordinates(real, imag, label_offset)
453
+ for label, re, im, x, y in zip(labels, real, imag, *textposition):
454
+ if not label:
455
+ continue
456
+ self._ax.annotate(
457
+ label,
458
+ (re, im),
459
+ xytext=(x, y),
460
+ color=color,
461
+ fontsize=fontsize,
462
+ fontweight=fontweight,
463
+ horizontalalignment=horizontalalignment,
464
+ verticalalignment=verticalalignment,
465
+ )
466
+
467
+ if fraction is None:
468
+ update_kwargs(
469
+ kwargs,
470
+ edgecolor=GRID_COLOR if color is None else color,
471
+ linestyle=GRID_LINESTYLE,
472
+ linewidth=GRID_LINEWIDH,
473
+ fill=GRID_FILL,
474
+ )
475
+ self._ax.add_patch(Polygon(numpy.vstack((real, imag)).T, **kwargs))
476
+ if marker is not None:
477
+ self._ax.plot(
478
+ real,
479
+ imag,
480
+ marker=marker,
481
+ linestyle='',
482
+ color=color,
483
+ label=label_,
484
+ )
485
+ if label_ is not None:
486
+ self._ax.legend()
487
+ return
488
+
489
+ fraction = numpy.asarray(fraction)[indices]
490
+ update_kwargs(
491
+ kwargs,
492
+ color=GRID_COLOR if color is None else color,
493
+ linestyle=GRID_LINESTYLE,
494
+ linewidth=GRID_LINEWIDH,
495
+ )
496
+ center_re, center_im = numpy.average(
497
+ numpy.vstack((real, imag)), axis=-1, weights=fraction
498
+ )
499
+ for re, im in zip(real, imag):
500
+ self._ax.add_line(
501
+ Line2D([center_re, re], [center_im, im], **kwargs)
502
+ )
503
+ if marker is not None:
504
+ self._ax.plot(real, imag, marker=marker, linestyle='', color=color)
505
+ self._ax.plot(
506
+ center_re,
507
+ center_im,
508
+ marker=marker,
509
+ linestyle='',
510
+ color=color,
511
+ label=label_,
512
+ )
513
+ if label_ is not None:
514
+ self._ax.legend()
515
+
516
+ def line(
517
+ self,
518
+ real: ArrayLike,
519
+ imag: ArrayLike,
520
+ /,
521
+ **kwargs: Any,
522
+ ) -> list[Line2D]:
523
+ """Draw grid line.
524
+
525
+ Parameters
526
+ ----------
527
+ real : array_like, shape (n, )
528
+ Real components of line start and end coordinates.
529
+ imag : array_like, shape (n, )
530
+ Imaginary components of line start and end coordinates.
531
+ **kwargs
532
+ Additional parameters passed to
533
+ :py:class:`matplotlib.lines.Line2D`.
534
+
535
+ Returns
536
+ -------
537
+ list[matplotlib.lines.Line2D]
538
+ List containing plotted line.
539
+
540
+ """
541
+ update_kwargs(
542
+ kwargs,
543
+ color=GRID_COLOR,
544
+ linestyle=GRID_LINESTYLE,
545
+ linewidth=GRID_LINEWIDH,
546
+ )
547
+ return [self._ax.add_line(Line2D(real, imag, **kwargs))]
548
+
549
+ def circle(
550
+ self,
551
+ real: float,
552
+ imag: float,
553
+ /,
554
+ radius: float,
555
+ **kwargs: Any,
556
+ ) -> None:
557
+ """Draw grid circle of radius around center.
558
+
559
+ Parameters
560
+ ----------
561
+ real : float
562
+ Real component of circle center coordinate.
563
+ imag : float
564
+ Imaginary component of circle center coordinate.
565
+ radius : float
566
+ Circle radius.
567
+ **kwargs
568
+ Additional parameters passed to
569
+ :py:class:`matplotlib.patches.Circle`.
570
+
571
+ """
572
+ update_kwargs(
573
+ kwargs,
574
+ color=GRID_COLOR,
575
+ linestyle=GRID_LINESTYLE,
576
+ linewidth=GRID_LINEWIDH,
577
+ fill=GRID_FILL,
578
+ )
579
+ self._ax.add_patch(Circle((real, imag), radius, **kwargs))
580
+
581
+ def cursor(
582
+ self,
583
+ real: float,
584
+ imag: float,
585
+ /,
586
+ real_limit: float | None = None,
587
+ imag_limit: float | None = None,
588
+ radius: float | None = None,
589
+ radius_minor: float | None = None,
590
+ angle: float | None = None,
591
+ align_semicircle: bool = False,
592
+ **kwargs: Any,
593
+ ) -> None:
594
+ """Plot phase and modulation grid lines and arcs at phasor coordinates.
595
+
596
+ Parameters
597
+ ----------
598
+ real : float
599
+ Real component of phasor coordinate.
600
+ imag : float
601
+ Imaginary component of phasor coordinate.
602
+ real_limit : float, optional
603
+ Real component of limiting phasor coordinate.
604
+ imag_limit : float, optional
605
+ Imaginary component of limiting phasor coordinate.
606
+ radius : float, optional
607
+ Radius of circle limiting phase and modulation grid lines and arcs.
608
+ radius_minor : float, optional
609
+ Radius of elliptic cursor along semi-minor axis.
610
+ By default, `radius_minor` is equal to `radius`, that is,
611
+ the ellipse is circular.
612
+ angle : float, optional
613
+ Rotation angle of semi-major axis of elliptic cursor in radians.
614
+ If None (default), orient ellipse cursor according to
615
+ `align_semicircle`.
616
+ align_semicircle : bool, optional
617
+ Determines elliptic cursor orientation if `angle` is not provided.
618
+ If true, align the minor axis of the ellipse with the closest
619
+ tangent on the universal semicircle, else align to the unit circle.
620
+ **kwargs
621
+ Additional parameters passed to
622
+ :py:class:`matplotlib.lines.Line2D`,
623
+ :py:class:`matplotlib.patches.Circle`,
624
+ :py:class:`matplotlib.patches.Ellipse`, or
625
+ :py:class:`matplotlib.patches.Arc`.
626
+
627
+ See Also
628
+ --------
629
+ phasorpy.plot.PhasorPlot.polar_cursor
630
+
631
+ """
632
+ if real_limit is not None and imag_limit is not None:
633
+ return self.polar_cursor(
634
+ *phasor_to_polar_scalar(real, imag),
635
+ *phasor_to_polar_scalar(real_limit, imag_limit),
636
+ radius=radius,
637
+ radius_minor=radius_minor,
638
+ angle=angle,
639
+ align_semicircle=align_semicircle,
640
+ **kwargs,
641
+ )
642
+ return self.polar_cursor(
643
+ *phasor_to_polar_scalar(real, imag),
644
+ radius=radius,
645
+ radius_minor=radius_minor,
646
+ angle=angle,
647
+ align_semicircle=align_semicircle,
648
+ # _circle_only=True,
649
+ **kwargs,
650
+ )
651
+
652
+ def polar_cursor(
653
+ self,
654
+ phase: float | None = None,
655
+ modulation: float | None = None,
656
+ phase_limit: float | None = None,
657
+ modulation_limit: float | None = None,
658
+ radius: float | None = None,
659
+ radius_minor: float | None = None,
660
+ angle: float | None = None,
661
+ align_semicircle: bool = False,
662
+ **kwargs: Any,
663
+ ) -> None:
664
+ """Plot phase and modulation grid lines and arcs.
665
+
666
+ Parameters
667
+ ----------
668
+ phase : float, optional
669
+ Angular component of polar coordinate in radians.
670
+ modulation : float, optional
671
+ Radial component of polar coordinate.
672
+ phase_limit : float, optional
673
+ Angular component of limiting polar coordinate (in radians).
674
+ Modulation grid arcs are drawn between `phase` and `phase_limit`.
675
+ modulation_limit : float, optional
676
+ Radial component of limiting polar coordinate.
677
+ Phase grid lines are drawn from `modulation` to `modulation_limit`.
678
+ radius : float, optional
679
+ Radius of circle limiting phase and modulation grid lines and arcs.
680
+ radius_minor : float, optional
681
+ Radius of elliptic cursor along semi-minor axis.
682
+ By default, `radius_minor` is equal to `radius`, that is,
683
+ the ellipse is circular.
684
+ angle : float, optional
685
+ Rotation angle of semi-major axis of elliptic cursor in radians.
686
+ If None (default), orient ellipse cursor according to
687
+ `align_semicircle`.
688
+ align_semicircle : bool, optional
689
+ Determines elliptic cursor orientation if `angle` is not provided.
690
+ If true, align the minor axis of the ellipse with the closest
691
+ tangent on the universal semicircle, else align to the unit circle.
692
+ **kwargs
693
+ Additional parameters passed to
694
+ :py:class:`matplotlib.lines.Line2D`,
695
+ :py:class:`matplotlib.patches.Circle`,
696
+ :py:class:`matplotlib.patches.Ellipse`, or
697
+ :py:class:`matplotlib.patches.Arc`.
698
+
699
+ See Also
700
+ --------
701
+ phasorpy.plot.PhasorPlot.cursor
702
+
703
+ """
704
+ update_kwargs(
705
+ kwargs,
706
+ color=GRID_COLOR,
707
+ linestyle=GRID_LINESTYLE,
708
+ linewidth=GRID_LINEWIDH,
709
+ fill=GRID_FILL,
710
+ )
711
+ _circle_only = kwargs.pop('_circle_only', False)
712
+ ax = self._ax
713
+ if radius is not None and phase is not None and modulation is not None:
714
+ x = modulation * math.cos(phase)
715
+ y = modulation * math.sin(phase)
716
+ if radius_minor is not None and radius_minor != radius:
717
+ if angle is None:
718
+ if align_semicircle:
719
+ angle = math.atan2(y, x - 0.5)
720
+ else:
721
+ angle = phase
722
+ angle = math.degrees(angle)
723
+ ax.add_patch(
724
+ Ellipse(
725
+ (x, y),
726
+ radius * 2,
727
+ radius_minor * 2,
728
+ angle=angle,
729
+ **kwargs,
730
+ )
731
+ )
732
+ # TODO: implement gridlines intersecting with ellipse
733
+ return None
734
+ ax.add_patch(Circle((x, y), radius, **kwargs))
735
+ if _circle_only:
736
+ return None
737
+ del kwargs['fill']
738
+ x0, y0, x1, y1 = _intersection_circle_line(
739
+ x, y, radius, 0, 0, x, y
740
+ )
741
+ ax.add_line(Line2D((x0, x1), (y0, y1), **kwargs))
742
+ x0, y0, x1, y1 = _intersection_circle_circle(
743
+ 0, 0, modulation, x, y, radius
744
+ )
745
+ ax.add_patch(
746
+ Arc(
747
+ (0, 0),
748
+ modulation * 2,
749
+ modulation * 2,
750
+ theta1=math.degrees(math.atan2(y0, x0)),
751
+ theta2=math.degrees(math.atan2(y1, x1)),
752
+ fill=False,
753
+ **kwargs,
754
+ )
755
+ )
756
+ return None
757
+
758
+ del kwargs['fill']
759
+ for phi in (phase, phase_limit):
760
+ if phi is not None:
761
+ if modulation is not None and modulation_limit is not None:
762
+ x0 = modulation * math.cos(phi)
763
+ y0 = modulation * math.sin(phi)
764
+ x1 = modulation_limit * math.cos(phi)
765
+ y1 = modulation_limit * math.sin(phi)
766
+ else:
767
+ x0 = 0
768
+ y0 = 0
769
+ x1 = math.cos(phi)
770
+ y1 = math.sin(phi)
771
+ ax.add_line(Line2D((x0, x1), (y0, y1), **kwargs))
772
+ for mod in (modulation, modulation_limit):
773
+ if mod is not None:
774
+ if phase is not None and phase_limit is not None:
775
+ theta1 = math.degrees(min(phase, phase_limit))
776
+ theta2 = math.degrees(max(phase, phase_limit))
777
+ else:
778
+ theta1 = 0.0
779
+ theta2 = 360.0 if self._full else 90.0
780
+ ax.add_patch(
781
+ Arc(
782
+ (0, 0),
783
+ mod * 2,
784
+ mod * 2,
785
+ theta1=theta1,
786
+ theta2=theta2,
787
+ fill=False, # filling arc objects is not supported
788
+ **kwargs,
789
+ )
790
+ )
791
+ return None
792
+
793
+ def polar_grid(self, **kwargs: Any) -> None:
794
+ """Draw polar coordinate system.
795
+
796
+ Parameters
797
+ ----------
798
+ **kwargs
799
+ Parameters passed to
800
+ :py:class:`matplotlib.patches.Circle` and
801
+ :py:class:`matplotlib.lines.Line2D`.
802
+
803
+ """
804
+ ax = self._ax
805
+ # major gridlines
806
+ kwargs_copy = kwargs.copy()
807
+ update_kwargs(
808
+ kwargs,
809
+ color=GRID_COLOR,
810
+ linestyle=GRID_LINESTYLE_MAJOR,
811
+ linewidth=GRID_LINEWIDH,
812
+ # fill=GRID_FILL,
813
+ )
814
+ ax.add_line(Line2D([-1, 1], [0, 0], **kwargs))
815
+ ax.add_line(Line2D([0, 0], [-1, 1], **kwargs))
816
+ ax.add_patch(Circle((0, 0), 1, fill=False, **kwargs))
817
+ # minor gridlines
818
+ kwargs = kwargs_copy
819
+ update_kwargs(
820
+ kwargs,
821
+ color=GRID_COLOR,
822
+ linestyle=GRID_LINESTYLE,
823
+ linewidth=GRID_LINEWIDH_MINOR,
824
+ )
825
+ for r in (1 / 3, 2 / 3):
826
+ ax.add_patch(Circle((0, 0), r, fill=False, **kwargs))
827
+ for a in (3, 6):
828
+ x = math.cos(math.pi / a)
829
+ y = math.sin(math.pi / a)
830
+ ax.add_line(Line2D([-x, x], [-y, y], **kwargs))
831
+ ax.add_line(Line2D([-x, x], [y, -y], **kwargs))
832
+
833
+ def semicircle(
834
+ self,
835
+ frequency: float | None = None,
836
+ *,
837
+ polar_reference: tuple[float, float] | None = None,
838
+ phasor_reference: tuple[float, float] | None = None,
839
+ lifetime: Sequence[float] | None = None,
840
+ labels: Sequence[str] | None = None,
841
+ show_circle: bool = True,
842
+ use_lines: bool = False,
843
+ **kwargs: Any,
844
+ ) -> list[Line2D]:
845
+ """Draw universal semicircle.
846
+
847
+ Parameters
848
+ ----------
849
+ frequency : float, optional
850
+ Laser pulse or modulation frequency in MHz.
851
+ polar_reference : (float, float), optional, default: (0, 1)
852
+ Polar coordinates of zero lifetime.
853
+ phasor_reference : (float, float), optional, default: (1, 0)
854
+ Phasor coordinates of zero lifetime.
855
+ Alternative to `polar_reference`.
856
+ lifetime : sequence of float, optional
857
+ Single component lifetimes at which to draw ticks and labels.
858
+ Only applies when `frequency` is specified.
859
+ labels : sequence of str, optional
860
+ Tick labels. By default, the values of `lifetime`.
861
+ Only applies when `frequency` and `lifetime` are specified.
862
+ show_circle : bool, optional, default: True
863
+ Draw universal semicircle.
864
+ use_lines : bool, optional, default: False
865
+ Draw universal semicircle using lines instead of arc.
866
+ **kwargs
867
+ Additional parameters passed to
868
+ :py:class:`matplotlib.lines.Line2D` or
869
+ :py:class:`matplotlib.patches.Arc` and
870
+ :py:meth:`matplotlib.axes.Axes.plot`.
871
+
872
+ Returns
873
+ -------
874
+ list[matplotlib.lines.Line2D]
875
+ Lines representing plotted semicircle and ticks.
876
+
877
+ """
878
+ if frequency is not None:
879
+ self._frequency = float(frequency)
880
+
881
+ update_kwargs(
882
+ kwargs,
883
+ color=GRID_COLOR,
884
+ linestyle=GRID_LINESTYLE_MAJOR,
885
+ linewidth=GRID_LINEWIDH,
886
+ )
887
+ if phasor_reference is not None:
888
+ polar_reference = phasor_to_polar_scalar(*phasor_reference)
889
+ if polar_reference is None:
890
+ polar_reference = (0.0, 1.0)
891
+ if phasor_reference is None:
892
+ phasor_reference = phasor_from_polar_scalar(*polar_reference)
893
+ ax = self._ax
894
+
895
+ lines = []
896
+
897
+ if show_circle:
898
+ if use_lines:
899
+ lines = [
900
+ ax.add_line(
901
+ Line2D(
902
+ *phasor_transform(
903
+ *phasor_semicircle(), *polar_reference
904
+ ),
905
+ **kwargs,
906
+ )
907
+ )
908
+ ]
909
+ else:
910
+ ax.add_patch(
911
+ Arc(
912
+ (phasor_reference[0] / 2, phasor_reference[1] / 2),
913
+ polar_reference[1],
914
+ polar_reference[1],
915
+ theta1=math.degrees(polar_reference[0]),
916
+ theta2=math.degrees(polar_reference[0]) + 180.0,
917
+ fill=False,
918
+ **kwargs,
919
+ )
920
+ )
921
+
922
+ if frequency is not None and polar_reference == (0.0, 1.0):
923
+ # draw ticks and labels
924
+ lifetime, labels = _semicircle_ticks(frequency, lifetime, labels)
925
+ self._semicircle_ticks = SemicircleTicks(labels=labels)
926
+ lines.extend(
927
+ ax.plot(
928
+ *phasor_transform(
929
+ *phasor_from_lifetime(frequency, lifetime),
930
+ *polar_reference,
931
+ ),
932
+ path_effects=[self._semicircle_ticks],
933
+ **kwargs,
934
+ )
935
+ )
936
+ self._reset_limits()
937
+ return lines
938
+
939
+ def _on_format_coord(self, x: float, y: float) -> str:
940
+ """Callback function to update coordinates displayed in toolbar."""
941
+ phi, mod = phasor_to_polar_scalar(x, y)
942
+ ret = [
943
+ f'[{x:4.2f}, {y:4.2f}]',
944
+ f'[{math.degrees(phi):.0f}°, {mod * 100:.0f}%]',
945
+ ]
946
+ if x > 0.0 and y > 0.0 and self._frequency > 0.0:
947
+ tp, tm = phasor_to_apparent_lifetime(x, y, self._frequency)
948
+ ret.append(f'[{tp:.2f}, {tm:.2f} ns]')
949
+ return ' '.join(reversed(ret))
950
+
951
+
952
+ class PhasorPlotFret(PhasorPlot):
953
+ """FRET phasor plot.
954
+
955
+ Plot Förster Resonance Energy Transfer efficiency trajectories
956
+ of donor and acceptor channels in phasor space.
957
+
958
+ Parameters
959
+ ----------
960
+ frequency : array_like
961
+ Laser pulse or modulation frequency in MHz.
962
+ donor_lifetime : array_like
963
+ Lifetime of donor without FRET in ns.
964
+ acceptor_lifetime : array_like
965
+ Lifetime of acceptor in ns.
966
+ fret_efficiency : array_like, optional, default 0
967
+ FRET efficiency in range [0..1].
968
+ donor_freting : array_like, optional, default 1
969
+ Fraction of donors participating in FRET. Range [0..1].
970
+ donor_bleedthrough : array_like, optional, default 0
971
+ Weight of donor fluorescence in acceptor channel
972
+ relative to fluorescence of fully sensitized acceptor.
973
+ A weight of 1 means the fluorescence from donor and fully sensitized
974
+ acceptor are equal.
975
+ The background in the donor channel does not bleed through.
976
+ acceptor_bleedthrough : array_like, optional, default 0
977
+ Weight of fluorescence from directly excited acceptor
978
+ relative to fluorescence of fully sensitized acceptor.
979
+ A weight of 1 means the fluorescence from directly excited acceptor
980
+ and fully sensitized acceptor are equal.
981
+ acceptor_background : array_like, optional, default 0
982
+ Weight of background fluorescence in acceptor channel
983
+ relative to fluorescence of fully sensitized acceptor.
984
+ A weight of 1 means the fluorescence of background and fully
985
+ sensitized acceptor are equal.
986
+ donor_background : array_like, optional, default 0
987
+ Weight of background fluorescence in donor channel
988
+ relative to fluorescence of donor without FRET.
989
+ A weight of 1 means the fluorescence of background and donor
990
+ without FRET are equal.
991
+ background_real : array_like, optional, default 0
992
+ Real component of background fluorescence phasor coordinate
993
+ at `frequency`.
994
+ background_imag : array_like, optional, default 0
995
+ Imaginary component of background fluorescence phasor coordinate
996
+ at `frequency`.
997
+ ax : matplotlib axes, optional
998
+ Matplotlib axes used for plotting.
999
+ By default, a new subplot axes is created.
1000
+ Cannot be used with `interactive` mode.
1001
+ interactive : bool, optional, default: False
1002
+ Use matplotlib slider widgets to interactively control parameters.
1003
+ **kwargs
1004
+ Additional parameters passed to :py:class:`phasorpy.plot.PhasorPlot`.
1005
+
1006
+ See Also
1007
+ --------
1008
+ phasorpy.phasor.phasor_from_fret_donor
1009
+ phasorpy.phasor.phasor_from_fret_acceptor
1010
+ :ref:`sphx_glr_tutorials_api_phasorpy_fret.py`
1011
+
1012
+ """
1013
+
1014
+ _fret_efficiencies: NDArray[Any]
1015
+
1016
+ _frequency_slider: Slider
1017
+ _donor_lifetime_slider: Slider
1018
+ _acceptor_lifetime_slider: Slider
1019
+ _fret_efficiency_slider: Slider
1020
+ _donor_freting_slider: Slider
1021
+ _donor_bleedthrough_slider: Slider
1022
+ _acceptor_bleedthrough_slider: Slider
1023
+ _acceptor_background_slider: Slider
1024
+ _donor_background_slider: Slider
1025
+ _background_real_slider: Slider
1026
+ _background_imag_slider: Slider
1027
+
1028
+ _donor_line: Line2D
1029
+ _donor_only_line: Line2D
1030
+ _donor_fret_line: Line2D
1031
+ _donor_trajectory_line: Line2D
1032
+ _donor_semicircle_line: Line2D
1033
+ _donor_donor_line: Line2D
1034
+ _donor_background_line: Line2D
1035
+ _acceptor_line: Line2D
1036
+ _acceptor_only_line: Line2D
1037
+ _acceptor_trajectory_line: Line2D
1038
+ _acceptor_semicircle_line: Line2D
1039
+ _acceptor_background_line: Line2D
1040
+ _background_line: Line2D
1041
+
1042
+ _donor_semicircle_ticks: SemicircleTicks | None
1043
+
1044
+ def __init__(
1045
+ self,
1046
+ *,
1047
+ frequency: float = 60.0,
1048
+ donor_lifetime: float = 4.2,
1049
+ acceptor_lifetime: float = 3.0,
1050
+ fret_efficiency: float = 0.5,
1051
+ donor_freting: float = 1.0,
1052
+ donor_bleedthrough: float = 0.0,
1053
+ acceptor_bleedthrough: float = 0.0,
1054
+ acceptor_background: float = 0.0,
1055
+ donor_background: float = 0.0,
1056
+ background_real: float = 0.0,
1057
+ background_imag: float = 0.0,
1058
+ ax: Axes | None = None,
1059
+ interactive: bool = False,
1060
+ **kwargs: Any,
1061
+ ) -> None:
1062
+ update_kwargs(
1063
+ kwargs,
1064
+ title='FRET phasor plot',
1065
+ xlim=[-0.2, 1.1],
1066
+ ylim=[-0.1, 0.8],
1067
+ )
1068
+ kwargs['allquadrants'] = False
1069
+ kwargs['grid'] = False
1070
+
1071
+ if ax is not None:
1072
+ interactive = False
1073
+ else:
1074
+ fig = pyplot.figure()
1075
+ ax = fig.add_subplot()
1076
+ if interactive:
1077
+ w, h = fig.get_size_inches()
1078
+ fig.set_size_inches(w, h * 1.66)
1079
+ fig.subplots_adjust(bottom=0.45)
1080
+ fcm = fig.canvas.manager
1081
+ if fcm is not None:
1082
+ fcm.set_window_title(kwargs['title'])
1083
+
1084
+ super().__init__(ax=ax, **kwargs)
1085
+
1086
+ self._fret_efficiencies = numpy.linspace(0.0, 1.0, 101)
1087
+
1088
+ donor_real, donor_imag = phasor_from_lifetime(
1089
+ frequency, donor_lifetime
1090
+ )
1091
+ donor_fret_real, donor_fret_imag = phasor_from_lifetime(
1092
+ frequency, donor_lifetime * (1.0 - fret_efficiency)
1093
+ )
1094
+ acceptor_real, acceptor_imag = phasor_from_lifetime(
1095
+ frequency, acceptor_lifetime
1096
+ )
1097
+ donor_trajectory_real, donor_trajectory_imag = phasor_from_fret_donor(
1098
+ frequency,
1099
+ donor_lifetime,
1100
+ fret_efficiency=self._fret_efficiencies,
1101
+ donor_freting=donor_freting,
1102
+ donor_background=donor_background,
1103
+ background_real=background_real,
1104
+ background_imag=background_imag,
1105
+ )
1106
+ (
1107
+ acceptor_trajectory_real,
1108
+ acceptor_trajectory_imag,
1109
+ ) = phasor_from_fret_acceptor(
1110
+ frequency,
1111
+ donor_lifetime,
1112
+ acceptor_lifetime,
1113
+ fret_efficiency=self._fret_efficiencies,
1114
+ donor_freting=donor_freting,
1115
+ donor_bleedthrough=donor_bleedthrough,
1116
+ acceptor_bleedthrough=acceptor_bleedthrough,
1117
+ acceptor_background=acceptor_background,
1118
+ background_real=background_real,
1119
+ background_imag=background_imag,
1120
+ )
1121
+
1122
+ # add plots
1123
+ lines = self.semicircle(frequency=frequency)
1124
+ self._donor_semicircle_line = lines[0]
1125
+ self._donor_semicircle_ticks = self._semicircle_ticks
1126
+
1127
+ lines = self.semicircle(
1128
+ phasor_reference=(float(acceptor_real), float(acceptor_imag)),
1129
+ use_lines=True,
1130
+ )
1131
+ self._acceptor_semicircle_line = lines[0]
1132
+
1133
+ if donor_freting < 1.0 and donor_background == 0.0:
1134
+ lines = self.line(
1135
+ [donor_real, donor_fret_real],
1136
+ [donor_imag, donor_fret_imag],
1137
+ )
1138
+ else:
1139
+ lines = self.line([0.0, 0.0], [0.0, 0.0])
1140
+ self._donor_donor_line = lines[0]
1141
+
1142
+ if acceptor_background > 0.0:
1143
+ lines = self.line(
1144
+ [float(acceptor_real), float(background_real)],
1145
+ [float(acceptor_imag), float(background_imag)],
1146
+ )
1147
+ else:
1148
+ lines = self.line([0.0, 0.0], [0.0, 0.0])
1149
+ self._acceptor_background_line = lines[0]
1150
+
1151
+ if donor_background > 0.0:
1152
+ lines = self.line(
1153
+ [float(donor_real), float(background_real)],
1154
+ [float(donor_imag), float(background_imag)],
1155
+ )
1156
+ else:
1157
+ lines = self.line([0.0, 0.0], [0.0, 0.0])
1158
+ self._donor_background_line = lines[0]
1159
+
1160
+ lines = self.plot(
1161
+ donor_trajectory_real,
1162
+ donor_trajectory_imag,
1163
+ '-',
1164
+ color='tab:green',
1165
+ )
1166
+ self._donor_trajectory_line = lines[0]
1167
+
1168
+ lines = self.plot(
1169
+ acceptor_trajectory_real,
1170
+ acceptor_trajectory_imag,
1171
+ '-',
1172
+ color='tab:red',
1173
+ )
1174
+ self._acceptor_trajectory_line = lines[0]
1175
+
1176
+ lines = self.plot(
1177
+ donor_real,
1178
+ donor_imag,
1179
+ '.',
1180
+ color='tab:green',
1181
+ )
1182
+ self._donor_only_line = lines[0]
1183
+
1184
+ lines = self.plot(
1185
+ donor_real,
1186
+ donor_imag,
1187
+ '.',
1188
+ color='tab:green',
1189
+ )
1190
+ self._donor_fret_line = lines[0]
1191
+
1192
+ lines = self.plot(
1193
+ acceptor_real,
1194
+ acceptor_imag,
1195
+ '.',
1196
+ color='tab:red',
1197
+ )
1198
+ self._acceptor_only_line = lines[0]
1199
+
1200
+ lines = self.plot(
1201
+ donor_trajectory_real[int(fret_efficiency * 100.0)],
1202
+ donor_trajectory_imag[int(fret_efficiency * 100.0)],
1203
+ 'o',
1204
+ color='tab:green',
1205
+ label='Donor',
1206
+ )
1207
+ self._donor_line = lines[0]
1208
+
1209
+ lines = self.plot(
1210
+ acceptor_trajectory_real[int(fret_efficiency * 100.0)],
1211
+ acceptor_trajectory_imag[int(fret_efficiency * 100.0)],
1212
+ 'o',
1213
+ color='tab:red',
1214
+ label='Acceptor',
1215
+ )
1216
+ self._acceptor_line = lines[0]
1217
+
1218
+ lines = self.plot(
1219
+ background_real,
1220
+ background_imag,
1221
+ 'o',
1222
+ color='black',
1223
+ label='Background',
1224
+ )
1225
+ self._background_line = lines[0]
1226
+
1227
+ if not interactive:
1228
+ return
1229
+
1230
+ # add sliders
1231
+ axes = []
1232
+ for i in range(11):
1233
+ axes.append(fig.add_axes((0.33, 0.05 + i * 0.03, 0.45, 0.01)))
1234
+
1235
+ self._frequency_slider = Slider(
1236
+ ax=axes[10],
1237
+ label='Frequency ',
1238
+ valfmt=' %.0f MHz',
1239
+ valmin=10,
1240
+ valmax=200,
1241
+ valstep=1,
1242
+ valinit=frequency,
1243
+ )
1244
+ self._frequency_slider.on_changed(self._on_semicircle_changed)
1245
+
1246
+ self._donor_lifetime_slider = Slider(
1247
+ ax=axes[9],
1248
+ label='Donor lifetime ',
1249
+ valfmt=' %.1f ns',
1250
+ valmin=0.1,
1251
+ valmax=16.0,
1252
+ valstep=0.1,
1253
+ valinit=donor_lifetime,
1254
+ # facecolor='tab:green',
1255
+ handle_style={'edgecolor': 'tab:green'},
1256
+ )
1257
+ self._donor_lifetime_slider.on_changed(self._on_changed)
1258
+
1259
+ self._acceptor_lifetime_slider = Slider(
1260
+ ax=axes[8],
1261
+ label='Acceptor lifetime ',
1262
+ valfmt=' %.1f ns',
1263
+ valmin=0.1,
1264
+ valmax=16.0,
1265
+ valstep=0.1,
1266
+ valinit=acceptor_lifetime,
1267
+ # facecolor='tab:red',
1268
+ handle_style={'edgecolor': 'tab:red'},
1269
+ )
1270
+ self._acceptor_lifetime_slider.on_changed(self._on_semicircle_changed)
1271
+
1272
+ self._fret_efficiency_slider = Slider(
1273
+ ax=axes[7],
1274
+ label='FRET efficiency ',
1275
+ valfmt=' %.2f',
1276
+ valmin=0.0,
1277
+ valmax=1.0,
1278
+ valstep=0.01,
1279
+ valinit=fret_efficiency,
1280
+ )
1281
+ self._fret_efficiency_slider.on_changed(self._on_changed)
1282
+
1283
+ self._donor_freting_slider = Slider(
1284
+ ax=axes[6],
1285
+ label='Donors FRETing ',
1286
+ valfmt=' %.2f',
1287
+ valmin=0.0,
1288
+ valmax=1.0,
1289
+ valstep=0.01,
1290
+ valinit=donor_freting,
1291
+ # facecolor='tab:green',
1292
+ handle_style={'edgecolor': 'tab:green'},
1293
+ )
1294
+ self._donor_freting_slider.on_changed(self._on_changed)
1295
+
1296
+ self._donor_bleedthrough_slider = Slider(
1297
+ ax=axes[5],
1298
+ label='Donor bleedthrough ',
1299
+ valfmt=' %.2f',
1300
+ valmin=0.0,
1301
+ valmax=5.0,
1302
+ valstep=0.01,
1303
+ valinit=donor_bleedthrough,
1304
+ # facecolor='tab:red',
1305
+ handle_style={'edgecolor': 'tab:red'},
1306
+ )
1307
+ self._donor_bleedthrough_slider.on_changed(self._on_changed)
1308
+
1309
+ self._acceptor_bleedthrough_slider = Slider(
1310
+ ax=axes[4],
1311
+ label='Acceptor bleedthrough ',
1312
+ valfmt=' %.2f',
1313
+ valmin=0.0,
1314
+ valmax=5.0,
1315
+ valstep=0.01,
1316
+ valinit=acceptor_bleedthrough,
1317
+ # facecolor='tab:red',
1318
+ handle_style={'edgecolor': 'tab:red'},
1319
+ )
1320
+ self._acceptor_bleedthrough_slider.on_changed(self._on_changed)
1321
+
1322
+ self._acceptor_background_slider = Slider(
1323
+ ax=axes[3],
1324
+ label='Acceptor background ',
1325
+ valfmt=' %.2f',
1326
+ valmin=0.0,
1327
+ valmax=5.0,
1328
+ valstep=0.01,
1329
+ valinit=acceptor_background,
1330
+ # facecolor='tab:red',
1331
+ handle_style={'edgecolor': 'tab:red'},
1332
+ )
1333
+ self._acceptor_background_slider.on_changed(self._on_changed)
1334
+
1335
+ self._donor_background_slider = Slider(
1336
+ ax=axes[2],
1337
+ label='Donor background ',
1338
+ valfmt=' %.2f',
1339
+ valmin=0.0,
1340
+ valmax=5.0,
1341
+ valstep=0.01,
1342
+ valinit=donor_background,
1343
+ # facecolor='tab:green',
1344
+ handle_style={'edgecolor': 'tab:green'},
1345
+ )
1346
+ self._donor_background_slider.on_changed(self._on_changed)
1347
+
1348
+ self._background_real_slider = Slider(
1349
+ ax=axes[1],
1350
+ label='Background real ',
1351
+ valfmt=' %.2f',
1352
+ valmin=0.0,
1353
+ valmax=1.0,
1354
+ valstep=0.01,
1355
+ valinit=background_real,
1356
+ )
1357
+ self._background_real_slider.on_changed(self._on_changed)
1358
+
1359
+ self._background_imag_slider = Slider(
1360
+ ax=axes[0],
1361
+ label='Background imag ',
1362
+ valfmt=' %.2f',
1363
+ valmin=0.0,
1364
+ valmax=0.6,
1365
+ valstep=0.01,
1366
+ valinit=background_imag,
1367
+ )
1368
+ self._background_imag_slider.on_changed(self._on_changed)
1369
+
1370
+ def _on_semicircle_changed(self, value: Any) -> None:
1371
+ """Callback function to update semicircles."""
1372
+ self._frequency = frequency = self._frequency_slider.val
1373
+ acceptor_lifetime = self._acceptor_lifetime_slider.val
1374
+ if self._donor_semicircle_ticks is not None:
1375
+ lifetime, labels = _semicircle_ticks(frequency)
1376
+ self._donor_semicircle_ticks.labels = labels
1377
+ self._donor_semicircle_line.set_data(
1378
+ *phasor_transform(*phasor_from_lifetime(frequency, lifetime))
1379
+ )
1380
+ self._acceptor_semicircle_line.set_data(
1381
+ *phasor_transform(
1382
+ *phasor_semicircle(),
1383
+ *phasor_to_polar(
1384
+ *phasor_from_lifetime(frequency, acceptor_lifetime)
1385
+ ),
1386
+ )
1387
+ )
1388
+ self._on_changed(value)
1389
+
1390
+ def _on_changed(self, value: Any) -> None:
1391
+ """Callback function to update plot with current slider values."""
1392
+ frequency = self._frequency_slider.val
1393
+ donor_lifetime = self._donor_lifetime_slider.val
1394
+ acceptor_lifetime = self._acceptor_lifetime_slider.val
1395
+ fret_efficiency = self._fret_efficiency_slider.val
1396
+ donor_freting = self._donor_freting_slider.val
1397
+ donor_bleedthrough = self._donor_bleedthrough_slider.val
1398
+ acceptor_bleedthrough = self._acceptor_bleedthrough_slider.val
1399
+ acceptor_background = self._acceptor_background_slider.val
1400
+ donor_background = self._donor_background_slider.val
1401
+ background_real = self._background_real_slider.val
1402
+ background_imag = self._background_imag_slider.val
1403
+ e = int(self._fret_efficiency_slider.val * 100)
1404
+
1405
+ donor_real, donor_imag = phasor_from_lifetime(
1406
+ frequency, donor_lifetime
1407
+ )
1408
+ donor_fret_real, donor_fret_imag = phasor_from_lifetime(
1409
+ frequency, donor_lifetime * (1.0 - fret_efficiency)
1410
+ )
1411
+ acceptor_real, acceptor_imag = phasor_from_lifetime(
1412
+ frequency, acceptor_lifetime
1413
+ )
1414
+ donor_trajectory_real, donor_trajectory_imag = phasor_from_fret_donor(
1415
+ frequency,
1416
+ donor_lifetime,
1417
+ fret_efficiency=self._fret_efficiencies,
1418
+ donor_freting=donor_freting,
1419
+ donor_background=donor_background,
1420
+ background_real=background_real,
1421
+ background_imag=background_imag,
1422
+ )
1423
+ (
1424
+ acceptor_trajectory_real,
1425
+ acceptor_trajectory_imag,
1426
+ ) = phasor_from_fret_acceptor(
1427
+ frequency,
1428
+ donor_lifetime,
1429
+ acceptor_lifetime,
1430
+ fret_efficiency=self._fret_efficiencies,
1431
+ donor_freting=donor_freting,
1432
+ donor_bleedthrough=donor_bleedthrough,
1433
+ acceptor_bleedthrough=acceptor_bleedthrough,
1434
+ acceptor_background=acceptor_background,
1435
+ background_real=background_real,
1436
+ background_imag=background_imag,
1437
+ )
1438
+
1439
+ if donor_background > 0.0:
1440
+ self._donor_background_line.set_data(
1441
+ [float(donor_real), float(background_real)],
1442
+ [float(donor_imag), float(background_imag)],
1443
+ )
1444
+ else:
1445
+ self._donor_background_line.set_data([0.0, 0.0], [0.0, 0.0])
1446
+
1447
+ if donor_freting < 1.0 and donor_background == 0.0:
1448
+ self._donor_donor_line.set_data(
1449
+ [donor_real, donor_fret_real],
1450
+ [donor_imag, donor_fret_imag],
1451
+ )
1452
+ else:
1453
+ self._donor_donor_line.set_data([0.0, 0.0], [0.0, 0.0])
1454
+
1455
+ if acceptor_background > 0.0:
1456
+ self._acceptor_background_line.set_data(
1457
+ [float(acceptor_real), float(background_real)],
1458
+ [float(acceptor_imag), float(background_imag)],
1459
+ )
1460
+ else:
1461
+ self._acceptor_background_line.set_data([0.0, 0.0], [0.0, 0.0])
1462
+
1463
+ self._background_line.set_data([background_real], [background_imag])
1464
+
1465
+ self._donor_only_line.set_data([donor_real], [donor_imag])
1466
+ self._donor_fret_line.set_data([donor_fret_real], [donor_fret_imag])
1467
+ self._donor_trajectory_line.set_data(
1468
+ donor_trajectory_real, donor_trajectory_imag
1469
+ )
1470
+ self._donor_line.set_data(
1471
+ [donor_trajectory_real[e]], [donor_trajectory_imag[e]]
1472
+ )
1473
+
1474
+ self._acceptor_only_line.set_data([acceptor_real], [acceptor_imag])
1475
+ self._acceptor_trajectory_line.set_data(
1476
+ acceptor_trajectory_real, acceptor_trajectory_imag
1477
+ )
1478
+ self._acceptor_line.set_data(
1479
+ [acceptor_trajectory_real[e]], [acceptor_trajectory_imag[e]]
1480
+ )
1481
+
1482
+
1483
+ class SemicircleTicks(AbstractPathEffect):
1484
+ """Draw ticks on universal semicircle.
1485
+
1486
+ Parameters
1487
+ ----------
1488
+ size : float, optional
1489
+ Length of tick in dots.
1490
+ The default is ``rcParams['xtick.major.size']``.
1491
+ labels : sequence of str, optional
1492
+ Tick labels for each vertex in path.
1493
+ **kwargs
1494
+ Extra keywords passed to matplotlib's
1495
+ :py:meth:`matplotlib.patheffects.AbstractPathEffect._update_gc`.
1496
+
1497
+ """
1498
+
1499
+ _size: float # tick length
1500
+ _labels: tuple[str, ...] # tick labels
1501
+ _gc: dict[str, Any] # keywords passed to _update_gc
1502
+
1503
+ def __init__(
1504
+ self,
1505
+ size: float | None = None,
1506
+ labels: Sequence[str] | None = None,
1507
+ **kwargs: Any,
1508
+ ) -> None:
1509
+ super().__init__((0.0, 0.0))
1510
+
1511
+ if size is None:
1512
+ self._size = pyplot.rcParams['xtick.major.size']
1513
+ else:
1514
+ self._size = size
1515
+ if labels is None or not labels:
1516
+ self._labels = ()
1517
+ else:
1518
+ self._labels = tuple(labels)
1519
+ self._gc = kwargs
1520
+
1521
+ @property
1522
+ def labels(self) -> tuple[str, ...]:
1523
+ """Tick labels."""
1524
+ return self._labels
1525
+
1526
+ @labels.setter
1527
+ def labels(self, value: Sequence[str] | None, /) -> None:
1528
+ if value is None or not value:
1529
+ self._labels = ()
1530
+ else:
1531
+ self._labels = tuple(value)
1532
+
1533
+ def draw_path(
1534
+ self,
1535
+ renderer: Any,
1536
+ gc: Any,
1537
+ tpath: Any,
1538
+ affine: Any,
1539
+ rgbFace: Any = None,
1540
+ ) -> None:
1541
+ """Draw path with updated gc."""
1542
+ gc0 = renderer.new_gc()
1543
+ gc0.copy_properties(gc)
1544
+
1545
+ # TODO: this uses private methods of the base class
1546
+ gc0 = self._update_gc(gc0, self._gc) # type: ignore[attr-defined]
1547
+ trans = affine
1548
+ trans += self._offset_transform(renderer) # type: ignore[attr-defined]
1549
+
1550
+ font = FontProperties()
1551
+ # approximate half size of 'x'
1552
+ fontsize = renderer.points_to_pixels(font.get_size_in_points()) / 4
1553
+ size = renderer.points_to_pixels(self._size)
1554
+ origin = affine.transform([[0.5, 0.0]])
1555
+
1556
+ transpath = affine.transform_path(tpath)
1557
+ polys = transpath.to_polygons(closed_only=False)
1558
+
1559
+ for p in polys:
1560
+ # coordinates of tick ends
1561
+ t = p - origin
1562
+ t /= numpy.hypot(t[:, 0], t[:, 1])[:, numpy.newaxis]
1563
+ d = t.copy()
1564
+ t *= size
1565
+ t += p
1566
+
1567
+ xyt = numpy.empty((2 * p.shape[0], 2))
1568
+ xyt[0::2] = p
1569
+ xyt[1::2] = t
1570
+
1571
+ renderer.draw_path(
1572
+ gc0,
1573
+ Path(xyt, numpy.tile([Path.MOVETO, Path.LINETO], p.shape[0])),
1574
+ affine.inverted() + trans,
1575
+ rgbFace,
1576
+ )
1577
+ if not self._labels:
1578
+ continue
1579
+ # coordinates of labels
1580
+ t = d * size * 2.5
1581
+ t += p
1582
+
1583
+ if renderer.flipy():
1584
+ h = renderer.get_canvas_width_height()[1]
1585
+ else:
1586
+ h = 0.0
1587
+
1588
+ for s, (x, y), (dx, _) in zip(self._labels, t, d):
1589
+ # TODO: get rendered text size from matplotlib.text.Text?
1590
+ # this did not work:
1591
+ # Text(d[i,0], h - d[i,1], label, ha='center', va='center')
1592
+ x = x + fontsize * len(s.split()[0]) * (dx - 1.0)
1593
+ y = h - y + fontsize
1594
+ renderer.draw_text(gc0, x, y, s, font, 0.0)
1595
+
1596
+ gc0.restore()
1597
+
1598
+
1599
+ def plot_phasor(
1600
+ real: ArrayLike,
1601
+ imag: ArrayLike,
1602
+ /,
1603
+ *,
1604
+ style: Literal['plot', 'hist2d', 'contour'] | None = None,
1605
+ allquadrants: bool | None = None,
1606
+ frequency: float | None = None,
1607
+ show: bool = True,
1608
+ **kwargs: Any,
1609
+ ) -> None:
1610
+ """Plot phasor coordinates.
1611
+
1612
+ A simplified interface to the :py:class:`PhasorPlot` class.
1613
+
1614
+ Parameters
1615
+ ----------
1616
+ real : array_like
1617
+ Real component of phasor coordinates.
1618
+ imag : array_like
1619
+ Imaginary component of phasor coordinates.
1620
+ Must be of same shape as `real`.
1621
+ style : {'plot', 'hist2d', 'contour'}, optional
1622
+ Method used to plot phasor coordinates.
1623
+ By default, if the number of coordinates are less than 65536
1624
+ and the arrays are less than three-dimensional, `'plot'` style is used,
1625
+ else `'hist2d'`.
1626
+ allquadrants : bool, optional
1627
+ Show all quadrants of phasor space.
1628
+ By default, only the first quadrant is shown.
1629
+ frequency : float, optional
1630
+ Frequency of phasor plot.
1631
+ If provided, the universal semicircle is labeled with reference
1632
+ lifetimes.
1633
+ show : bool, optional, default: True
1634
+ Display figure.
1635
+ **kwargs
1636
+ Additional parguments passed to :py:class:`PhasorPlot`,
1637
+ :py:meth:`PhasorPlot.plot`, :py:meth:`PhasorPlot.hist2d`, or
1638
+ :py:meth:`PhasorPlot.contour` depending on `style`.
1639
+
1640
+ See Also
1641
+ --------
1642
+ phasorpy.plot.PhasorPlot
1643
+ :ref:`sphx_glr_tutorials_api_phasorpy_phasorplot.py`
1644
+
1645
+ """
1646
+ init_kwargs = parse_kwargs(
1647
+ kwargs,
1648
+ 'ax',
1649
+ 'title',
1650
+ 'xlabel',
1651
+ 'ylabel',
1652
+ 'xlim',
1653
+ 'ylim',
1654
+ 'xticks',
1655
+ 'yticks',
1656
+ 'grid',
1657
+ )
1658
+
1659
+ real = numpy.asanyarray(real)
1660
+ imag = numpy.asanyarray(imag)
1661
+ plot = PhasorPlot(
1662
+ frequency=frequency, allquadrants=allquadrants, **init_kwargs
1663
+ )
1664
+ if style is None:
1665
+ style = 'plot' if real.size < 65536 and real.ndim < 3 else 'hist2d'
1666
+ if style == 'plot':
1667
+ plot.plot(real, imag, **kwargs)
1668
+ elif style == 'hist2d':
1669
+ plot.hist2d(real, imag, **kwargs)
1670
+ elif style == 'contour':
1671
+ plot.contour(real, imag, **kwargs)
1672
+ else:
1673
+ raise ValueError(f'invalid {style=}')
1674
+ if show:
1675
+ plot.show()
1676
+
1677
+
1678
+ def plot_phasor_image(
1679
+ mean: ArrayLike | None,
1680
+ real: ArrayLike,
1681
+ imag: ArrayLike,
1682
+ *,
1683
+ harmonics: int | None = None,
1684
+ percentile: float | None = None,
1685
+ title: str | None = None,
1686
+ show: bool = True,
1687
+ **kwargs: Any,
1688
+ ) -> None:
1689
+ """Plot phasor coordinates as images.
1690
+
1691
+ Preview phasor coordinates from time-resolved or hyperspectral
1692
+ image stacks as returned by :py:func:`phasorpy.phasor.phasor_from_signal`.
1693
+
1694
+ The last two axes are assumed to be the image axes.
1695
+ Harmonics, if any, are in the first axes of `real` and `imag`.
1696
+ Other axes are averaged for display.
1697
+
1698
+ Parameters
1699
+ ----------
1700
+ mean : array_like
1701
+ Image average. Must be two or more dimensional, or None.
1702
+ real : array_like
1703
+ Image of real component of phasor coordinates.
1704
+ The last dimensions must match shape of `mean`.
1705
+ imag : array_like
1706
+ Image of imaginary component of phasor coordinates.
1707
+ Must be same shape as `real`.
1708
+ harmonics : int, optional
1709
+ Number of harmonics to display.
1710
+ If `mean` is None, a nonzero value indicates the presence of harmonics
1711
+ in the first axes of `mean` and `real`. Else, the presence of harmonics
1712
+ is determined from the shapes of `mean` and `real`.
1713
+ By default, up to 4 harmonics are displayed.
1714
+ percentile : float, optional
1715
+ The (q, 100-q) percentiles of image data are covered by colormaps.
1716
+ By default, the complete value range of `mean` is covered,
1717
+ for `real` and `imag` the range [-1..1].
1718
+ title : str, optional
1719
+ Figure title.
1720
+ show : bool, optional, default: True
1721
+ Display figure.
1722
+ **kwargs
1723
+ Additional arguments passed to :func:`matplotlib.pyplot.imshow`.
1724
+
1725
+ Raises
1726
+ ------
1727
+ ValueError
1728
+ The shapes of `mean`, `real`, and `image` do not match.
1729
+ Percentile is out of range.
1730
+
1731
+ """
1732
+ update_kwargs(kwargs, interpolation='nearest')
1733
+ cmap = kwargs.pop('cmap', None)
1734
+ shape = None
1735
+
1736
+ if mean is not None:
1737
+ mean = numpy.asarray(mean)
1738
+ if mean.ndim < 2:
1739
+ raise ValueError(f'not an image {mean.ndim=} < 2')
1740
+ shape = mean.shape
1741
+ mean = mean.reshape(-1, *mean.shape[-2:])
1742
+ if mean.shape[0] == 1:
1743
+ mean = mean[0]
1744
+ else:
1745
+ mean = numpy.nanmean(mean, axis=0)
1746
+
1747
+ real = numpy.asarray(real)
1748
+ imag = numpy.asarray(imag)
1749
+ if real.shape != imag.shape:
1750
+ raise ValueError(f'{real.shape=} != {imag.shape=}')
1751
+ if real.ndim < 2:
1752
+ raise ValueError(f'not an image {real.ndim=} < 2')
1753
+
1754
+ if (shape is not None and real.shape[1:] == shape) or (
1755
+ shape is None and harmonics
1756
+ ):
1757
+ # first image dimension contains harmonics
1758
+ if real.ndim < 3:
1759
+ raise ValueError(f'not a multi-harmonic image {real.shape=}')
1760
+ nh = real.shape[0] # number harmonics
1761
+ elif shape is None or shape == real.shape:
1762
+ # single harmonic
1763
+ nh = 1
1764
+ else:
1765
+ raise ValueError(f'shape mismatch {real.shape[1:]=} != {shape}')
1766
+
1767
+ real = real.reshape(nh, -1, *real.shape[-2:])
1768
+ imag = imag.reshape(nh, -1, *imag.shape[-2:])
1769
+ if real.shape[1] == 1:
1770
+ real = real[:, 0]
1771
+ imag = imag[:, 0]
1772
+ else:
1773
+ real = numpy.nanmean(real, axis=1)
1774
+ imag = numpy.nanmean(imag, axis=1)
1775
+
1776
+ # for MyPy
1777
+ assert isinstance(mean, numpy.ndarray) or mean is None
1778
+ assert isinstance(real, numpy.ndarray)
1779
+ assert isinstance(imag, numpy.ndarray)
1780
+
1781
+ # limit number of displayed harmonics
1782
+ nh = min(4 if harmonics is None else harmonics, nh)
1783
+
1784
+ # create figure with size depending on image aspect and number of harmonics
1785
+ fig = pyplot.figure(layout='constrained')
1786
+ w, h = fig.get_size_inches()
1787
+ aspect = min(1.0, max(0.5, real.shape[-2] / real.shape[-1]))
1788
+ fig.set_size_inches(w, h * 0.4 * aspect * nh + h * 0.25 * aspect)
1789
+ gs = GridSpec(nh, 2 if mean is None else 3, figure=fig)
1790
+ if title:
1791
+ fig.suptitle(title)
1792
+
1793
+ if mean is not None:
1794
+ _imshow(
1795
+ fig.add_subplot(gs[0, 0]),
1796
+ mean,
1797
+ percentile=percentile,
1798
+ vmin=None,
1799
+ vmax=None,
1800
+ cmap=cmap,
1801
+ axis=True,
1802
+ title='mean',
1803
+ **kwargs,
1804
+ )
1805
+
1806
+ if percentile is None:
1807
+ vmin = -1.0
1808
+ vmax = 1.0
1809
+ if cmap is None:
1810
+ cmap = 'coolwarm_r'
1811
+ else:
1812
+ vmin = None
1813
+ vmax = None
1814
+
1815
+ for h in range(nh):
1816
+ axs = []
1817
+ ax = fig.add_subplot(gs[h, -2])
1818
+ axs.append(ax)
1819
+ _imshow(
1820
+ ax,
1821
+ real[h],
1822
+ percentile=percentile,
1823
+ vmin=vmin,
1824
+ vmax=vmax,
1825
+ cmap=cmap,
1826
+ axis=mean is None and h == 0,
1827
+ colorbar=percentile is not None,
1828
+ title=None if h else 'G, real',
1829
+ **kwargs,
1830
+ )
1831
+
1832
+ ax = fig.add_subplot(gs[h, -1])
1833
+ axs.append(ax)
1834
+ pos = _imshow(
1835
+ ax,
1836
+ imag[h],
1837
+ percentile=percentile,
1838
+ vmin=vmin,
1839
+ vmax=vmax,
1840
+ cmap=cmap,
1841
+ axis=False,
1842
+ colorbar=percentile is not None,
1843
+ title=None if h else 'S, imag',
1844
+ **kwargs,
1845
+ )
1846
+ if percentile is None and h == 0:
1847
+ fig.colorbar(pos, ax=axs, shrink=0.4, location='bottom')
1848
+
1849
+ if show:
1850
+ pyplot.show()
1851
+
1852
+
1853
+ def plot_signal_image(
1854
+ signal: ArrayLike,
1855
+ /,
1856
+ *,
1857
+ axis: int | None = None,
1858
+ percentile: float | Sequence[float] | None = None,
1859
+ title: str | None = None,
1860
+ show: bool = True,
1861
+ **kwargs: Any,
1862
+ ) -> None:
1863
+ """Plot average image and signal along axis.
1864
+
1865
+ Preview time-resolved or hyperspectral image stacks to be anayzed with
1866
+ :py:func:`phasorpy.phasor.phasor_from_signal`.
1867
+
1868
+ The last two axes, excluding `axis`, are assumed to be the image axes.
1869
+ Other axes are averaged for image display.
1870
+
1871
+ Parameters
1872
+ ----------
1873
+ signal : array_like
1874
+ Image stack. Must be three or more dimensional.
1875
+ axis : int, optional, default: -1
1876
+ Axis over which phasor coordinates would be computed.
1877
+ The default is the last axis (-1).
1878
+ percentile : float or [float, float], optional
1879
+ The [q, 100-q] percentiles of image data are covered by colormaps.
1880
+ By default, the complete value range of `mean` is covered,
1881
+ for `real` and `imag` the range [-1..1].
1882
+ title : str, optional
1883
+ Figure title.
1884
+ show : bool, optional, default: True
1885
+ Display figure.
1886
+ **kwargs
1887
+ Additional arguments passed to :func:`matplotlib.pyplot.imshow`.
1888
+
1889
+ Raises
1890
+ ------
1891
+ ValueError
1892
+ Signal is not an image stack.
1893
+ Percentile is out of range.
1894
+
1895
+ """
1896
+ # TODO: add option to separate channels?
1897
+ # TODO: add option to plot non-images?
1898
+ update_kwargs(kwargs, interpolation='nearest')
1899
+ signal = numpy.asarray(signal)
1900
+ if signal.ndim < 3:
1901
+ raise ValueError(f'not an image stack {signal.ndim=} < 3')
1902
+
1903
+ if axis is None:
1904
+ axis = -1
1905
+ axis %= signal.ndim
1906
+
1907
+ # for MyPy
1908
+ assert isinstance(signal, numpy.ndarray)
1909
+
1910
+ fig = pyplot.figure(layout='constrained')
1911
+ if title:
1912
+ fig.suptitle(title)
1913
+ w, h = fig.get_size_inches()
1914
+ fig.set_size_inches(w, h * 0.7)
1915
+ gs = GridSpec(1, 2, figure=fig, width_ratios=(1, 1))
1916
+
1917
+ # histogram
1918
+ axes = list(range(signal.ndim))
1919
+ del axes[axis]
1920
+ ax = fig.add_subplot(gs[0, 1])
1921
+ ax.set_title(f'mean, axis {axis}')
1922
+ ax.plot(numpy.nanmean(signal, axis=tuple(axes)))
1923
+
1924
+ # image
1925
+ axes = list(sorted(axes[:-2] + [axis]))
1926
+ ax = fig.add_subplot(gs[0, 0])
1927
+ _imshow(
1928
+ ax,
1929
+ numpy.nanmean(signal, axis=tuple(axes)),
1930
+ percentile=percentile,
1931
+ shrink=0.5,
1932
+ title='mean',
1933
+ )
1934
+
1935
+ if show:
1936
+ pyplot.show()
1937
+
1938
+
1939
+ def plot_polar_frequency(
1940
+ frequency: ArrayLike,
1941
+ phase: ArrayLike,
1942
+ modulation: ArrayLike,
1943
+ *,
1944
+ ax: Axes | None = None,
1945
+ title: str | None = None,
1946
+ show: bool = True,
1947
+ **kwargs: Any,
1948
+ ) -> None:
1949
+ """Plot phase and modulation verus frequency.
1950
+
1951
+ Parameters
1952
+ ----------
1953
+ frequency : array_like, shape (n, )
1954
+ Laser pulse or modulation frequency in MHz.
1955
+ phase : array_like
1956
+ Angular component of polar coordinates in radians.
1957
+ modulation : array_like
1958
+ Radial component of polar coordinates.
1959
+ ax : matplotlib axes, optional
1960
+ Matplotlib axes used for plotting.
1961
+ By default, a new subplot axes is created.
1962
+ title : str, optional
1963
+ Figure title. The default is "Multi-frequency plot".
1964
+ show : bool, optional, default: True
1965
+ Display figure.
1966
+ **kwargs
1967
+ Additional arguments passed to :py:func:`matplotlib.pyplot.plot`.
1968
+
1969
+ """
1970
+ # TODO: make this customizable: labels, colors, ...
1971
+ if ax is None:
1972
+ ax = pyplot.subplots()[1]
1973
+ if title is None:
1974
+ title = 'Multi-frequency plot'
1975
+ if title:
1976
+ ax.set_title(title)
1977
+ ax.set_xscale('log', base=10)
1978
+ ax.set_xlabel('Frequency (MHz)')
1979
+
1980
+ phase = numpy.asarray(phase)
1981
+ if phase.ndim < 2:
1982
+ phase = phase.reshape(-1, 1)
1983
+ modulation = numpy.asarray(modulation)
1984
+ if modulation.ndim < 2:
1985
+ modulation = modulation.reshape(-1, 1)
1986
+
1987
+ ax.set_ylabel('Phase (°)', color='tab:blue')
1988
+ ax.set_yticks([0.0, 30.0, 60.0, 90.0])
1989
+ for phi in phase.T:
1990
+ ax.plot(frequency, numpy.rad2deg(phi), color='tab:blue', **kwargs)
1991
+ ax = ax.twinx()
1992
+
1993
+ ax.set_ylabel('Modulation (%)', color='tab:red')
1994
+ ax.set_yticks([0.0, 25.0, 50.0, 75.0, 100.0])
1995
+ for mod in modulation.T:
1996
+ ax.plot(frequency, mod * 100, color='tab:red', **kwargs)
1997
+ if show:
1998
+ pyplot.show()
1999
+
2000
+
2001
+ def _imshow(
2002
+ ax: Axes,
2003
+ image: NDArray[Any],
2004
+ /,
2005
+ *,
2006
+ percentile: float | Sequence[float] | None = None,
2007
+ vmin: float | None = None,
2008
+ vmax: float | None = None,
2009
+ colorbar: bool = True,
2010
+ shrink: float | None = None,
2011
+ axis: bool = True,
2012
+ title: str | None = None,
2013
+ **kwargs: Any,
2014
+ ) -> AxesImage:
2015
+ """Plot image array.
2016
+
2017
+ Convenience wrapper around :py:func:`matplotlib.pyplot.imshow`.
2018
+
2019
+ """
2020
+ update_kwargs(kwargs, interpolation='none')
2021
+ if percentile is not None:
2022
+ if isinstance(percentile, Sequence):
2023
+ percentile = percentile[0], percentile[1]
2024
+ else:
2025
+ # percentile = max(0.0, min(50, percentile))
2026
+ percentile = percentile, 100.0 - percentile
2027
+ if (
2028
+ percentile[0] >= percentile[1]
2029
+ or percentile[0] < 0
2030
+ or percentile[1] > 100
2031
+ ):
2032
+ raise ValueError(f'{percentile=} out of range')
2033
+ vmin, vmax = numpy.percentile(image, percentile)
2034
+ pos = ax.imshow(image, vmin=vmin, vmax=vmax, **kwargs)
2035
+ if colorbar:
2036
+ if percentile is not None and vmin is not None and vmax is not None:
2037
+ ticks = vmin, vmax
2038
+ else:
2039
+ ticks = None
2040
+ fig = ax.get_figure()
2041
+ if fig is not None:
2042
+ if shrink is None:
2043
+ shrink = 0.8
2044
+ fig.colorbar(pos, shrink=shrink, location='bottom', ticks=ticks)
2045
+ if title:
2046
+ ax.set_title(title)
2047
+ if not axis:
2048
+ ax.set_axis_off()
2049
+ # ax.set_anchor('C')
2050
+ return pos
2051
+
2052
+
2053
+ def _semicircle_ticks(
2054
+ frequency: float,
2055
+ lifetime: Sequence[float] | None = None,
2056
+ labels: Sequence[str] | None = None,
2057
+ ) -> tuple[tuple[float, ...], tuple[str, ...]]:
2058
+ """Return semicircle tick lifetimes and labels at frequency."""
2059
+ if lifetime is None:
2060
+ lifetime = [0.0] + [
2061
+ 2**t
2062
+ for t in range(-8, 32)
2063
+ if phasor_from_lifetime(frequency, 2**t)[1] >= 0.18
2064
+ ]
2065
+ unit = 'ns'
2066
+ else:
2067
+ unit = ''
2068
+ if labels is None:
2069
+ labels = [f'{tau:g}' for tau in lifetime]
2070
+ try:
2071
+ labels[2] = f'{labels[2]} {unit}'
2072
+ except IndexError:
2073
+ pass
2074
+ return tuple(lifetime), tuple(labels)