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