phasorpy 0.5__cp313-cp313-win_amd64.whl → 0.6__cp313-cp313-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.
@@ -0,0 +1,1119 @@
1
+ """PhasorPlot class."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__ = ['PhasorPlot']
6
+
7
+ import math
8
+ import os
9
+ from collections.abc import Sequence
10
+ from typing import TYPE_CHECKING
11
+
12
+ if TYPE_CHECKING:
13
+ from .._typing import Any, ArrayLike, NDArray, IO
14
+
15
+ from matplotlib.axes import Axes
16
+ from matplotlib.figure import Figure
17
+
18
+ import numpy
19
+ from matplotlib import pyplot
20
+ from matplotlib.font_manager import FontProperties
21
+ from matplotlib.lines import Line2D
22
+ from matplotlib.patches import Arc, Circle, Ellipse, FancyArrowPatch, Polygon
23
+ from matplotlib.path import Path
24
+ from matplotlib.patheffects import AbstractPathEffect
25
+
26
+ from .._phasorpy import _intersect_circle_circle, _intersect_circle_line
27
+ from .._utils import (
28
+ dilate_coordinates,
29
+ parse_kwargs,
30
+ phasor_from_polar_scalar,
31
+ phasor_to_polar_scalar,
32
+ sort_coordinates,
33
+ update_kwargs,
34
+ )
35
+ from ..phasor import (
36
+ phasor_from_lifetime,
37
+ phasor_semicircle,
38
+ phasor_to_apparent_lifetime,
39
+ phasor_transform,
40
+ )
41
+
42
+ GRID_COLOR = '0.5'
43
+ GRID_LINESTYLE = ':'
44
+ GRID_LINESTYLE_MAJOR = '-'
45
+ GRID_LINEWIDH = 1.0
46
+ GRID_LINEWIDH_MINOR = 0.5
47
+ GRID_FILL = False
48
+
49
+
50
+ class PhasorPlot:
51
+ """Phasor plot.
52
+
53
+ Create publication quality visualizations of phasor coordinates.
54
+
55
+ Parameters
56
+ ----------
57
+ allquadrants : bool, optional
58
+ Show all quadrants of phasor space.
59
+ By default, only the first quadrant with universal semicircle is shown.
60
+ ax : matplotlib axes, optional
61
+ Matplotlib axes used for plotting.
62
+ By default, a new subplot axes is created.
63
+ frequency : float, optional
64
+ Laser pulse or modulation frequency in MHz.
65
+ grid : bool, optional, default: True
66
+ Display polar grid or universal semicircle.
67
+ **kwargs
68
+ Additional properties to set on `ax`.
69
+
70
+ See Also
71
+ --------
72
+ phasorpy.plot.plot_phasor
73
+ :ref:`sphx_glr_tutorials_api_phasorpy_phasorplot.py`
74
+
75
+ """
76
+
77
+ _ax: Axes
78
+ """Matplotlib axes."""
79
+
80
+ _limits: tuple[tuple[float, float], tuple[float, float]]
81
+ """Axes limits (xmin, xmax), (ymin, ymax)."""
82
+
83
+ _full: bool
84
+ """Show all quadrants of phasor space."""
85
+
86
+ _semicircle_ticks: SemicircleTicks | None
87
+ """Last SemicircleTicks instance created."""
88
+
89
+ _frequency: float
90
+ """Laser pulse or modulation frequency in MHz."""
91
+
92
+ def __init__(
93
+ self,
94
+ /,
95
+ allquadrants: bool | None = None,
96
+ ax: Axes | None = None,
97
+ *,
98
+ frequency: float | None = None,
99
+ grid: bool = True,
100
+ **kwargs: Any,
101
+ ) -> None:
102
+ # initialize empty phasor plot
103
+ self._ax = pyplot.subplots()[1] if ax is None else ax
104
+ self._ax.format_coord = ( # type: ignore[method-assign]
105
+ self._on_format_coord
106
+ )
107
+
108
+ self._semicircle_ticks = None
109
+
110
+ self._full = bool(allquadrants)
111
+ if self._full:
112
+ xlim = (-1.05, 1.05)
113
+ ylim = (-1.05, 1.05)
114
+ xticks: tuple[float, ...] = (-1.0, -0.5, 0.0, 0.5, 1.0)
115
+ yticks: tuple[float, ...] = (-1.0, -0.5, 0.0, 0.5, 1.0)
116
+ if grid:
117
+ self.polar_grid()
118
+ else:
119
+ xlim = (-0.05, 1.05)
120
+ ylim = (-0.05, 0.7)
121
+ xticks = (0.0, 0.2, 0.4, 0.6, 0.8, 1.0)
122
+ yticks = (0.0, 0.2, 0.4, 0.6)
123
+ if grid:
124
+ self.semicircle(frequency=frequency)
125
+
126
+ title = 'Phasor plot'
127
+ if frequency is not None:
128
+ self._frequency = float(frequency)
129
+ title += f' ({frequency:g} MHz)'
130
+ else:
131
+ self._frequency = 0.0
132
+
133
+ update_kwargs(
134
+ kwargs,
135
+ title=title,
136
+ xlabel='G, real',
137
+ ylabel='S, imag',
138
+ aspect='equal',
139
+ xlim=xlim,
140
+ ylim=ylim,
141
+ xticks=xticks,
142
+ yticks=yticks,
143
+ )
144
+ self._limits = (kwargs['xlim'], kwargs['ylim'])
145
+ self._ax.set(**kwargs)
146
+
147
+ @property
148
+ def ax(self) -> Axes:
149
+ """Matplotlib :py:class:`matplotlib.axes.Axes`."""
150
+ return self._ax
151
+
152
+ @property
153
+ def fig(self) -> Figure | None:
154
+ """Matplotlib :py:class:`matplotlib.figure.Figure`."""
155
+ try:
156
+ # matplotlib >= 3.10.0
157
+ return self._ax.get_figure(root=True)
158
+ except TypeError:
159
+ return self._ax.get_figure() # type: ignore[return-value]
160
+
161
+ @property
162
+ def dataunit_to_point(self) -> float:
163
+ """Factor to convert data to point unit."""
164
+ fig = self.fig
165
+ assert fig is not None
166
+ length = fig.bbox_inches.height * self._ax.get_position().height * 72.0
167
+ vrange: float = numpy.diff(self._ax.get_ylim()).item()
168
+ return length / vrange
169
+
170
+ def show(self) -> None:
171
+ """Display all open figures. Call :py:func:`matplotlib.pyplot.show`."""
172
+ # self.fig.show()
173
+ pyplot.show()
174
+
175
+ def save(
176
+ self,
177
+ file: str | os.PathLike[Any] | IO[bytes] | None,
178
+ /,
179
+ **kwargs: Any,
180
+ ) -> None:
181
+ """Save current figure to file.
182
+
183
+ Parameters
184
+ ----------
185
+ file : str, path-like, or binary file-like
186
+ Path or Python file-like object to write the current figure to.
187
+ **kwargs
188
+ Additional keyword arguments passed to
189
+ :py:func:`matplotlib:pyplot.savefig`.
190
+
191
+ """
192
+ pyplot.savefig(file, **kwargs)
193
+
194
+ def plot(
195
+ self,
196
+ real: ArrayLike,
197
+ imag: ArrayLike,
198
+ /,
199
+ fmt: str = 'o',
200
+ *,
201
+ label: Sequence[str] | None = None,
202
+ **kwargs: Any,
203
+ ) -> list[Line2D]:
204
+ """Plot imaginary versus real coordinates as markers or lines.
205
+
206
+ Parameters
207
+ ----------
208
+ real : array_like
209
+ Real component of phasor coordinates.
210
+ Must be one or two dimensional.
211
+ imag : array_like
212
+ Imaginary component of phasor coordinates.
213
+ Must be of same shape as `real`.
214
+ fmt : str, optional, default: 'o'
215
+ Matplotlib style format string.
216
+ label : sequence of str, optional
217
+ Plot label.
218
+ May be a sequence if phasor coordinates are two dimensional arrays.
219
+ **kwargs
220
+ Additional parameters passed to
221
+ :py:meth:`matplotlib.axes.Axes.plot`.
222
+
223
+ Returns
224
+ -------
225
+ list[matplotlib.lines.Line2D]
226
+ Lines representing data plotted last.
227
+
228
+ """
229
+ lines = []
230
+ if fmt == 'o':
231
+ if 'marker' in kwargs:
232
+ fmt = ''
233
+ if 'linestyle' not in kwargs and 'ls' not in kwargs:
234
+ kwargs['linestyle'] = ''
235
+ args = (fmt,) if fmt else ()
236
+ ax = self._ax
237
+ if label is not None and (
238
+ isinstance(label, str) or not isinstance(label, Sequence)
239
+ ):
240
+ label = (label,)
241
+ for (
242
+ i,
243
+ (re, im),
244
+ ) in enumerate(
245
+ zip(
246
+ numpy.atleast_2d(numpy.asarray(real)),
247
+ numpy.atleast_2d(numpy.asarray(imag)),
248
+ )
249
+ ):
250
+ lbl = None
251
+ if label is not None:
252
+ try:
253
+ lbl = label[i]
254
+ except IndexError:
255
+ pass
256
+ lines = ax.plot(re, im, *args, label=lbl, **kwargs)
257
+ if label is not None:
258
+ ax.legend()
259
+ self._reset_limits()
260
+ return lines
261
+
262
+ def _histogram2d(
263
+ self,
264
+ real: ArrayLike,
265
+ imag: ArrayLike,
266
+ /,
267
+ **kwargs: Any,
268
+ ) -> tuple[NDArray[Any], NDArray[Any], NDArray[Any]]:
269
+ """Return two-dimensional histogram of imag versus real coordinates."""
270
+ update_kwargs(kwargs, range=self._limits)
271
+ (xmin, xmax), (ymin, ymax) = kwargs['range']
272
+ assert xmax > xmin and ymax > ymin
273
+ bins = kwargs.get('bins', 128)
274
+ if isinstance(bins, int):
275
+ assert bins > 0
276
+ aspect = (xmax - xmin) / (ymax - ymin)
277
+ if aspect > 1:
278
+ bins = (bins, max(int(bins / aspect), 1))
279
+ else:
280
+ bins = (max(int(bins * aspect), 1), bins)
281
+ kwargs['bins'] = bins
282
+ return numpy.histogram2d(
283
+ numpy.asanyarray(real).reshape(-1),
284
+ numpy.asanyarray(imag).reshape(-1),
285
+ **kwargs,
286
+ )
287
+
288
+ def _reset_limits(self) -> None:
289
+ """Reset axes limits."""
290
+ try:
291
+ self._ax.set(xlim=self._limits[0], ylim=self._limits[1])
292
+ except AttributeError:
293
+ pass
294
+
295
+ def hist2d(
296
+ self,
297
+ real: ArrayLike,
298
+ imag: ArrayLike,
299
+ /,
300
+ **kwargs: Any,
301
+ ) -> None:
302
+ """Plot two-dimensional histogram of imag versus real coordinates.
303
+
304
+ Parameters
305
+ ----------
306
+ real : array_like
307
+ Real component of phasor coordinates.
308
+ imag : array_like
309
+ Imaginary component of phasor coordinates.
310
+ Must be of same shape as `real`.
311
+ **kwargs
312
+ Additional parameters passed to :py:meth:`numpy.histogram2d`
313
+ and :py:meth:`matplotlib.axes.Axes.pcolormesh`.
314
+
315
+ """
316
+ kwargs_hist2d = parse_kwargs(
317
+ kwargs, 'bins', 'range', 'density', 'weights'
318
+ )
319
+ h, xedges, yedges = self._histogram2d(real, imag, **kwargs_hist2d)
320
+
321
+ update_kwargs(kwargs, cmap='Blues', norm='log')
322
+ cmin = kwargs.pop('cmin', 1)
323
+ cmax = kwargs.pop('cmax', None)
324
+ if cmin is not None:
325
+ h[h < cmin] = None
326
+ if cmax is not None:
327
+ h[h > cmax] = None
328
+ self._ax.pcolormesh(xedges, yedges, h.T, **kwargs)
329
+ self._reset_limits()
330
+
331
+ def contour(
332
+ self,
333
+ real: ArrayLike,
334
+ imag: ArrayLike,
335
+ /,
336
+ **kwargs: Any,
337
+ ) -> None:
338
+ """Plot contours of imag versus real coordinates (not implemented).
339
+
340
+ Parameters
341
+ ----------
342
+ real : array_like
343
+ Real component of phasor coordinates.
344
+ imag : array_like
345
+ Imaginary component of phasor coordinates.
346
+ Must be of same shape as `real`.
347
+ **kwargs
348
+ Additional parameters passed to :py:func:`numpy.histogram2d`
349
+ and :py:meth:`matplotlib.axes.Axes.contour`.
350
+
351
+ """
352
+ if 'cmap' not in kwargs and 'colors' not in kwargs:
353
+ kwargs['cmap'] = 'Blues'
354
+ update_kwargs(kwargs, norm='log')
355
+ kwargs_hist2d = parse_kwargs(
356
+ kwargs, 'bins', 'range', 'density', 'weights'
357
+ )
358
+ h, xedges, yedges = self._histogram2d(real, imag, **kwargs_hist2d)
359
+ xedges = xedges[:-1] + ((xedges[1] - xedges[0]) / 2.0)
360
+ yedges = yedges[:-1] + ((yedges[1] - yedges[0]) / 2.0)
361
+ self._ax.contour(xedges, yedges, h.T, **kwargs)
362
+ self._reset_limits()
363
+
364
+ def imshow(
365
+ self,
366
+ image: ArrayLike,
367
+ /,
368
+ **kwargs: Any,
369
+ ) -> None:
370
+ """Plot an image, for example, a 2D histogram (not implemented).
371
+
372
+ Parameters
373
+ ----------
374
+ image : array_like
375
+ Image to display.
376
+ **kwargs
377
+ Additional parameters passed to
378
+ :py:meth:`matplotlib.axes.Axes.imshow`.
379
+
380
+ """
381
+ raise NotImplementedError
382
+
383
+ def components(
384
+ self,
385
+ real: ArrayLike,
386
+ imag: ArrayLike,
387
+ /,
388
+ fraction: ArrayLike | None = None,
389
+ labels: Sequence[str] | None = None,
390
+ label_offset: float | None = None,
391
+ **kwargs: Any,
392
+ ) -> None:
393
+ """Plot linear combinations of phasor coordinates or ranges thereof.
394
+
395
+ Parameters
396
+ ----------
397
+ real : (N,) array_like
398
+ Real component of phasor coordinates.
399
+ imag : (N,) array_like
400
+ Imaginary component of phasor coordinates.
401
+ fraction : (N,) array_like, optional
402
+ Weight associated with each component.
403
+ If None (default), outline the polygon area of possible linear
404
+ combinations of components.
405
+ Else, draw lines from the component coordinates to the weighted
406
+ average.
407
+ labels : Sequence of str, optional
408
+ Text label for each component.
409
+ label_offset : float, optional
410
+ Distance of text label to component coordinate.
411
+ **kwargs
412
+ Additional parameters passed to
413
+ :py:class:`matplotlib.patches.Polygon`,
414
+ :py:class:`matplotlib.lines.Line2D`, or
415
+ :py:class:`matplotlib.axes.Axes.annotate`
416
+
417
+ """
418
+ # TODO: use convex hull for outline
419
+ # TODO: improve automatic placement of labels
420
+ # TODO: catch more annotate properties?
421
+ real, imag, indices = sort_coordinates(real, imag)
422
+
423
+ label_ = kwargs.pop('label', None)
424
+ marker = kwargs.pop('marker', None)
425
+ color = kwargs.pop('color', None)
426
+ fontsize = kwargs.pop('fontsize', 12)
427
+ fontweight = kwargs.pop('fontweight', 'bold')
428
+ horizontalalignment = kwargs.pop('horizontalalignment', 'center')
429
+ verticalalignment = kwargs.pop('verticalalignment', 'center')
430
+ if label_offset is None:
431
+ label_offset = numpy.diff(self._ax.get_xlim()).item() * 0.04
432
+
433
+ if labels is not None:
434
+ if len(labels) != real.size:
435
+ raise ValueError(
436
+ f'number labels={len(labels)} != components={real.size}'
437
+ )
438
+ labels = [labels[i] for i in indices]
439
+ textposition = dilate_coordinates(real, imag, label_offset)
440
+ for label, re, im, x, y in zip(labels, real, imag, *textposition):
441
+ if not label:
442
+ continue
443
+ self._ax.annotate(
444
+ label,
445
+ (re, im),
446
+ xytext=(x, y),
447
+ color=color,
448
+ fontsize=fontsize,
449
+ fontweight=fontweight,
450
+ horizontalalignment=horizontalalignment,
451
+ verticalalignment=verticalalignment,
452
+ )
453
+
454
+ if fraction is None:
455
+ update_kwargs(
456
+ kwargs,
457
+ edgecolor=GRID_COLOR if color is None else color,
458
+ linestyle=GRID_LINESTYLE,
459
+ linewidth=GRID_LINEWIDH,
460
+ fill=GRID_FILL,
461
+ )
462
+ self._ax.add_patch(Polygon(numpy.vstack((real, imag)).T, **kwargs))
463
+ if marker is not None:
464
+ self._ax.plot(
465
+ real,
466
+ imag,
467
+ marker=marker,
468
+ linestyle='',
469
+ color=color,
470
+ label=label_,
471
+ )
472
+ if label_ is not None:
473
+ self._ax.legend()
474
+ return
475
+
476
+ fraction = numpy.asarray(fraction)[indices]
477
+ update_kwargs(
478
+ kwargs,
479
+ color=GRID_COLOR if color is None else color,
480
+ linestyle=GRID_LINESTYLE,
481
+ linewidth=GRID_LINEWIDH,
482
+ )
483
+ center_re, center_im = numpy.average(
484
+ numpy.vstack((real, imag)), axis=-1, weights=fraction
485
+ )
486
+ for re, im in zip(real, imag):
487
+ self._ax.add_line(
488
+ Line2D([center_re, re], [center_im, im], **kwargs)
489
+ )
490
+ if marker is not None:
491
+ self._ax.plot(real, imag, marker=marker, linestyle='', color=color)
492
+ self._ax.plot(
493
+ center_re,
494
+ center_im,
495
+ marker=marker,
496
+ linestyle='',
497
+ color=color,
498
+ label=label_,
499
+ )
500
+ if label_ is not None:
501
+ self._ax.legend()
502
+
503
+ def line(
504
+ self,
505
+ real: ArrayLike,
506
+ imag: ArrayLike,
507
+ /,
508
+ **kwargs: Any,
509
+ ) -> list[Line2D]:
510
+ """Draw grid line.
511
+
512
+ Parameters
513
+ ----------
514
+ real : array_like, shape (n, )
515
+ Real components of line start and end coordinates.
516
+ imag : array_like, shape (n, )
517
+ Imaginary components of line start and end coordinates.
518
+ **kwargs
519
+ Additional parameters passed to
520
+ :py:class:`matplotlib.lines.Line2D`.
521
+
522
+ Returns
523
+ -------
524
+ list[matplotlib.lines.Line2D]
525
+ List containing plotted line.
526
+
527
+ """
528
+ update_kwargs(
529
+ kwargs,
530
+ color=GRID_COLOR,
531
+ linestyle=GRID_LINESTYLE,
532
+ linewidth=GRID_LINEWIDH,
533
+ )
534
+ return [self._ax.add_line(Line2D(real, imag, **kwargs))]
535
+
536
+ def circle(
537
+ self,
538
+ real: float,
539
+ imag: float,
540
+ /,
541
+ radius: float,
542
+ **kwargs: Any,
543
+ ) -> None:
544
+ """Draw grid circle of radius around center.
545
+
546
+ Parameters
547
+ ----------
548
+ real : float
549
+ Real component of circle center coordinate.
550
+ imag : float
551
+ Imaginary component of circle center coordinate.
552
+ radius : float
553
+ Circle radius.
554
+ **kwargs
555
+ Additional parameters passed to
556
+ :py:class:`matplotlib.patches.Circle`.
557
+
558
+ """
559
+ update_kwargs(
560
+ kwargs,
561
+ color=GRID_COLOR,
562
+ linestyle=GRID_LINESTYLE,
563
+ linewidth=GRID_LINEWIDH,
564
+ fill=GRID_FILL,
565
+ )
566
+ self._ax.add_patch(Circle((real, imag), radius, **kwargs))
567
+
568
+ def arrow(
569
+ self,
570
+ point0: ArrayLike,
571
+ point1: ArrayLike,
572
+ /,
573
+ *,
574
+ angle: float | None = None,
575
+ **kwargs: Any,
576
+ ) -> None:
577
+ """Draw arrow between points.
578
+
579
+ By default, draw a straight arrow with a `'-|>'` style, a mutation
580
+ scale of 20, and a miter join style.
581
+
582
+ Parameters
583
+ ----------
584
+ point0 : array_like
585
+ X and y coordinates of start point of arrow.
586
+ point1 : array_like
587
+ X and y coordinates of end point of arrow.
588
+ angle : float, optional
589
+ Angle in radians, controlling curvature of line between points.
590
+ If None (default), draw a straight line.
591
+ **kwargs
592
+ Additional parameters passed to
593
+ :py:class:`matplotlib.patches.FancyArrowPatch`.
594
+
595
+ """
596
+ arrowstyle = kwargs.pop('arrowstyle', '-|>')
597
+ mutation_scale = kwargs.pop('mutation_scale', 20)
598
+ joinstyle = kwargs.pop('joinstyle', 'miter')
599
+ if angle is not None:
600
+ kwargs['connectionstyle'] = f'arc3,rad={math.tan(angle / 4.0)}'
601
+
602
+ patch = FancyArrowPatch(
603
+ point0, # type: ignore[arg-type]
604
+ point1, # type: ignore[arg-type]
605
+ arrowstyle=arrowstyle,
606
+ mutation_scale=mutation_scale,
607
+ # capstyle='projecting',
608
+ joinstyle=joinstyle,
609
+ **kwargs,
610
+ )
611
+ self._ax.add_patch(patch)
612
+
613
+ def cursor(
614
+ self,
615
+ real: float,
616
+ imag: float,
617
+ /,
618
+ real_limit: float | None = None,
619
+ imag_limit: float | None = None,
620
+ radius: float | None = None,
621
+ radius_minor: float | None = None,
622
+ angle: float | None = None,
623
+ align_semicircle: bool = False,
624
+ **kwargs: Any,
625
+ ) -> None:
626
+ """Plot phase and modulation grid lines and arcs at phasor coordinates.
627
+
628
+ Parameters
629
+ ----------
630
+ real : float
631
+ Real component of phasor coordinate.
632
+ imag : float
633
+ Imaginary component of phasor coordinate.
634
+ real_limit : float, optional
635
+ Real component of limiting phasor coordinate.
636
+ imag_limit : float, optional
637
+ Imaginary component of limiting phasor coordinate.
638
+ radius : float, optional
639
+ Radius of circle limiting phase and modulation grid lines and arcs.
640
+ radius_minor : float, optional
641
+ Radius of elliptic cursor along semi-minor axis.
642
+ By default, `radius_minor` is equal to `radius`, that is,
643
+ the ellipse is circular.
644
+ angle : float, optional
645
+ Rotation angle of semi-major axis of elliptic cursor in radians.
646
+ If None (default), orient ellipse cursor according to
647
+ `align_semicircle`.
648
+ align_semicircle : bool, optional
649
+ Determines elliptic cursor orientation if `angle` is not provided.
650
+ If true, align the minor axis of the ellipse with the closest
651
+ tangent on the universal semicircle, else align to the unit circle.
652
+ **kwargs
653
+ Additional parameters passed to
654
+ :py:class:`matplotlib.lines.Line2D`,
655
+ :py:class:`matplotlib.patches.Circle`,
656
+ :py:class:`matplotlib.patches.Ellipse`, or
657
+ :py:class:`matplotlib.patches.Arc`.
658
+
659
+ See Also
660
+ --------
661
+ phasorpy.plot.PhasorPlot.polar_cursor
662
+
663
+ """
664
+ if real_limit is not None and imag_limit is not None:
665
+ return self.polar_cursor(
666
+ *phasor_to_polar_scalar(real, imag),
667
+ *phasor_to_polar_scalar(real_limit, imag_limit),
668
+ radius=radius,
669
+ radius_minor=radius_minor,
670
+ angle=angle,
671
+ align_semicircle=align_semicircle,
672
+ **kwargs,
673
+ )
674
+ return self.polar_cursor(
675
+ *phasor_to_polar_scalar(real, imag),
676
+ radius=radius,
677
+ radius_minor=radius_minor,
678
+ angle=angle,
679
+ align_semicircle=align_semicircle,
680
+ # _circle_only=True,
681
+ **kwargs,
682
+ )
683
+
684
+ def polar_cursor(
685
+ self,
686
+ phase: float | None = None,
687
+ modulation: float | None = None,
688
+ phase_limit: float | None = None,
689
+ modulation_limit: float | None = None,
690
+ radius: float | None = None,
691
+ radius_minor: float | None = None,
692
+ angle: float | None = None,
693
+ align_semicircle: bool = False,
694
+ **kwargs: Any,
695
+ ) -> None:
696
+ """Plot phase and modulation grid lines and arcs.
697
+
698
+ Parameters
699
+ ----------
700
+ phase : float, optional
701
+ Angular component of polar coordinate in radians.
702
+ modulation : float, optional
703
+ Radial component of polar coordinate.
704
+ phase_limit : float, optional
705
+ Angular component of limiting polar coordinate (in radians).
706
+ Modulation grid arcs are drawn between `phase` and `phase_limit`.
707
+ modulation_limit : float, optional
708
+ Radial component of limiting polar coordinate.
709
+ Phase grid lines are drawn from `modulation` to `modulation_limit`.
710
+ radius : float, optional
711
+ Radius of circle limiting phase and modulation grid lines and arcs.
712
+ radius_minor : float, optional
713
+ Radius of elliptic cursor along semi-minor axis.
714
+ By default, `radius_minor` is equal to `radius`, that is,
715
+ the ellipse is circular.
716
+ angle : float, optional
717
+ Rotation angle of semi-major axis of elliptic cursor in radians.
718
+ If None (default), orient ellipse cursor according to
719
+ `align_semicircle`.
720
+ align_semicircle : bool, optional
721
+ Determines elliptic cursor orientation if `angle` is not provided.
722
+ If true, align the minor axis of the ellipse with the closest
723
+ tangent on the universal semicircle, else align to the unit circle.
724
+ **kwargs
725
+ Additional parameters passed to
726
+ :py:class:`matplotlib.lines.Line2D`,
727
+ :py:class:`matplotlib.patches.Circle`,
728
+ :py:class:`matplotlib.patches.Ellipse`, or
729
+ :py:class:`matplotlib.patches.Arc`.
730
+
731
+ See Also
732
+ --------
733
+ phasorpy.plot.PhasorPlot.cursor
734
+
735
+ """
736
+ update_kwargs(
737
+ kwargs,
738
+ color=GRID_COLOR,
739
+ linestyle=GRID_LINESTYLE,
740
+ linewidth=GRID_LINEWIDH,
741
+ fill=GRID_FILL,
742
+ )
743
+ _circle_only = kwargs.pop('_circle_only', False)
744
+ ax = self._ax
745
+ if radius is not None and phase is not None and modulation is not None:
746
+ x = modulation * math.cos(phase)
747
+ y = modulation * math.sin(phase)
748
+ if radius_minor is not None and radius_minor != radius:
749
+ if angle is None:
750
+ if align_semicircle:
751
+ angle = math.atan2(y, x - 0.5)
752
+ else:
753
+ angle = phase
754
+ angle = math.degrees(angle)
755
+ ax.add_patch(
756
+ Ellipse(
757
+ (x, y),
758
+ radius * 2,
759
+ radius_minor * 2,
760
+ angle=angle,
761
+ **kwargs,
762
+ )
763
+ )
764
+ # TODO: implement gridlines intersecting with ellipse
765
+ return None
766
+ ax.add_patch(Circle((x, y), radius, **kwargs))
767
+ if _circle_only:
768
+ return None
769
+ del kwargs['fill']
770
+ x0, y0, x1, y1 = _intersect_circle_line(x, y, radius, 0, 0, x, y)
771
+ ax.add_line(Line2D((x0, x1), (y0, y1), **kwargs))
772
+ x0, y0, x1, y1 = _intersect_circle_circle(
773
+ 0, 0, modulation, x, y, radius
774
+ )
775
+ ax.add_patch(
776
+ Arc(
777
+ (0, 0),
778
+ modulation * 2,
779
+ modulation * 2,
780
+ theta1=math.degrees(math.atan2(y0, x0)),
781
+ theta2=math.degrees(math.atan2(y1, x1)),
782
+ fill=False,
783
+ **kwargs,
784
+ )
785
+ )
786
+ return None
787
+
788
+ del kwargs['fill']
789
+ for phi in (phase, phase_limit):
790
+ if phi is not None:
791
+ if modulation is not None and modulation_limit is not None:
792
+ x0 = modulation * math.cos(phi)
793
+ y0 = modulation * math.sin(phi)
794
+ x1 = modulation_limit * math.cos(phi)
795
+ y1 = modulation_limit * math.sin(phi)
796
+ else:
797
+ x0 = 0
798
+ y0 = 0
799
+ x1 = math.cos(phi)
800
+ y1 = math.sin(phi)
801
+ ax.add_line(Line2D((x0, x1), (y0, y1), **kwargs))
802
+ for mod in (modulation, modulation_limit):
803
+ if mod is not None:
804
+ if phase is not None and phase_limit is not None:
805
+ theta1 = math.degrees(min(phase, phase_limit))
806
+ theta2 = math.degrees(max(phase, phase_limit))
807
+ else:
808
+ theta1 = 0.0
809
+ theta2 = 360.0 if self._full else 90.0
810
+ ax.add_patch(
811
+ Arc(
812
+ (0, 0),
813
+ mod * 2,
814
+ mod * 2,
815
+ theta1=theta1,
816
+ theta2=theta2,
817
+ fill=False, # filling arc objects is not supported
818
+ **kwargs,
819
+ )
820
+ )
821
+ return None
822
+
823
+ def polar_grid(self, **kwargs: Any) -> None:
824
+ """Draw polar coordinate system.
825
+
826
+ Parameters
827
+ ----------
828
+ **kwargs
829
+ Parameters passed to
830
+ :py:class:`matplotlib.patches.Circle` and
831
+ :py:class:`matplotlib.lines.Line2D`.
832
+
833
+ """
834
+ ax = self._ax
835
+ # major gridlines
836
+ kwargs_copy = kwargs.copy()
837
+ update_kwargs(
838
+ kwargs,
839
+ color=GRID_COLOR,
840
+ linestyle=GRID_LINESTYLE_MAJOR,
841
+ linewidth=GRID_LINEWIDH,
842
+ # fill=GRID_FILL,
843
+ )
844
+ ax.add_line(Line2D([-1, 1], [0, 0], **kwargs))
845
+ ax.add_line(Line2D([0, 0], [-1, 1], **kwargs))
846
+ ax.add_patch(Circle((0, 0), 1, fill=False, **kwargs))
847
+ # minor gridlines
848
+ kwargs = kwargs_copy
849
+ update_kwargs(
850
+ kwargs,
851
+ color=GRID_COLOR,
852
+ linestyle=GRID_LINESTYLE,
853
+ linewidth=GRID_LINEWIDH_MINOR,
854
+ )
855
+ for r in (1 / 3, 2 / 3):
856
+ ax.add_patch(Circle((0, 0), r, fill=False, **kwargs))
857
+ for a in (3, 6):
858
+ x = math.cos(math.pi / a)
859
+ y = math.sin(math.pi / a)
860
+ ax.add_line(Line2D([-x, x], [-y, y], **kwargs))
861
+ ax.add_line(Line2D([-x, x], [y, -y], **kwargs))
862
+
863
+ def semicircle(
864
+ self,
865
+ frequency: float | None = None,
866
+ *,
867
+ polar_reference: tuple[float, float] | None = None,
868
+ phasor_reference: tuple[float, float] | None = None,
869
+ lifetime: Sequence[float] | None = None,
870
+ labels: Sequence[str] | None = None,
871
+ show_circle: bool = True,
872
+ use_lines: bool = False,
873
+ **kwargs: Any,
874
+ ) -> list[Line2D]:
875
+ """Draw universal semicircle.
876
+
877
+ Parameters
878
+ ----------
879
+ frequency : float, optional
880
+ Laser pulse or modulation frequency in MHz.
881
+ polar_reference : (float, float), optional, default: (0, 1)
882
+ Polar coordinates of zero lifetime.
883
+ phasor_reference : (float, float), optional, default: (1, 0)
884
+ Phasor coordinates of zero lifetime.
885
+ Alternative to `polar_reference`.
886
+ lifetime : sequence of float, optional
887
+ Single component lifetimes at which to draw ticks and labels.
888
+ Only applies when `frequency` is specified.
889
+ labels : sequence of str, optional
890
+ Tick labels. By default, the values of `lifetime`.
891
+ Only applies when `frequency` and `lifetime` are specified.
892
+ show_circle : bool, optional, default: True
893
+ Draw universal semicircle.
894
+ use_lines : bool, optional, default: False
895
+ Draw universal semicircle using lines instead of arc.
896
+ **kwargs
897
+ Additional parameters passed to
898
+ :py:class:`matplotlib.lines.Line2D` or
899
+ :py:class:`matplotlib.patches.Arc` and
900
+ :py:meth:`matplotlib.axes.Axes.plot`.
901
+
902
+ Returns
903
+ -------
904
+ list[matplotlib.lines.Line2D]
905
+ Lines representing plotted semicircle and ticks.
906
+
907
+ """
908
+ if frequency is not None:
909
+ self._frequency = float(frequency)
910
+
911
+ update_kwargs(
912
+ kwargs,
913
+ color=GRID_COLOR,
914
+ linestyle=GRID_LINESTYLE_MAJOR,
915
+ linewidth=GRID_LINEWIDH,
916
+ )
917
+ if phasor_reference is not None:
918
+ polar_reference = phasor_to_polar_scalar(*phasor_reference)
919
+ if polar_reference is None:
920
+ polar_reference = (0.0, 1.0)
921
+ if phasor_reference is None:
922
+ phasor_reference = phasor_from_polar_scalar(*polar_reference)
923
+ ax = self._ax
924
+
925
+ lines = []
926
+
927
+ if show_circle:
928
+ if use_lines:
929
+ lines = [
930
+ ax.add_line(
931
+ Line2D(
932
+ *phasor_transform(
933
+ *phasor_semicircle(), *polar_reference
934
+ ),
935
+ **kwargs,
936
+ )
937
+ )
938
+ ]
939
+ else:
940
+ ax.add_patch(
941
+ Arc(
942
+ (phasor_reference[0] / 2, phasor_reference[1] / 2),
943
+ polar_reference[1],
944
+ polar_reference[1],
945
+ theta1=math.degrees(polar_reference[0]),
946
+ theta2=math.degrees(polar_reference[0]) + 180.0,
947
+ fill=False,
948
+ **kwargs,
949
+ )
950
+ )
951
+
952
+ if frequency is not None and polar_reference == (0.0, 1.0):
953
+ # draw ticks and labels
954
+ lifetime, labels = _semicircle_ticks(frequency, lifetime, labels)
955
+ self._semicircle_ticks = SemicircleTicks(labels=labels)
956
+ lines.extend(
957
+ ax.plot(
958
+ *phasor_transform(
959
+ *phasor_from_lifetime(frequency, lifetime),
960
+ *polar_reference,
961
+ ),
962
+ path_effects=[self._semicircle_ticks],
963
+ **kwargs,
964
+ )
965
+ )
966
+ self._reset_limits()
967
+ return lines
968
+
969
+ def _on_format_coord(self, x: float, y: float) -> str:
970
+ """Callback function to update coordinates displayed in toolbar."""
971
+ phi, mod = phasor_to_polar_scalar(x, y)
972
+ ret = [
973
+ f'[{x:4.2f}, {y:4.2f}]',
974
+ f'[{math.degrees(phi):.0f}°, {mod * 100:.0f}%]',
975
+ ]
976
+ if x > 0.0 and y > 0.0 and self._frequency > 0.0:
977
+ tp, tm = phasor_to_apparent_lifetime(x, y, self._frequency)
978
+ ret.append(f'[{tp:.2f}, {tm:.2f} ns]')
979
+ return ' '.join(reversed(ret))
980
+
981
+
982
+ class SemicircleTicks(AbstractPathEffect):
983
+ """Draw ticks on universal semicircle.
984
+
985
+ Parameters
986
+ ----------
987
+ size : float, optional
988
+ Length of tick in dots.
989
+ The default is ``rcParams['xtick.major.size']``.
990
+ labels : sequence of str, optional
991
+ Tick labels for each vertex in path.
992
+ **kwargs
993
+ Extra keywords passed to matplotlib's
994
+ :py:meth:`matplotlib.patheffects.AbstractPathEffect._update_gc`.
995
+
996
+ """
997
+
998
+ _size: float # tick length
999
+ _labels: tuple[str, ...] # tick labels
1000
+ _gc: dict[str, Any] # keywords passed to _update_gc
1001
+
1002
+ def __init__(
1003
+ self,
1004
+ size: float | None = None,
1005
+ labels: Sequence[str] | None = None,
1006
+ **kwargs: Any,
1007
+ ) -> None:
1008
+ super().__init__((0.0, 0.0))
1009
+
1010
+ if size is None:
1011
+ self._size = pyplot.rcParams['xtick.major.size']
1012
+ else:
1013
+ self._size = size
1014
+ if labels is None or not labels:
1015
+ self._labels = ()
1016
+ else:
1017
+ self._labels = tuple(labels)
1018
+ self._gc = kwargs
1019
+
1020
+ @property
1021
+ def labels(self) -> tuple[str, ...]:
1022
+ """Tick labels."""
1023
+ return self._labels
1024
+
1025
+ @labels.setter
1026
+ def labels(self, value: Sequence[str] | None, /) -> None:
1027
+ if value is None or not value:
1028
+ self._labels = ()
1029
+ else:
1030
+ self._labels = tuple(value)
1031
+
1032
+ def draw_path(
1033
+ self,
1034
+ renderer: Any,
1035
+ gc: Any,
1036
+ tpath: Any,
1037
+ affine: Any,
1038
+ rgbFace: Any = None,
1039
+ ) -> None:
1040
+ """Draw path with updated gc."""
1041
+ gc0 = renderer.new_gc()
1042
+ gc0.copy_properties(gc)
1043
+
1044
+ # TODO: this uses private methods of the base class
1045
+ gc0 = self._update_gc(gc0, self._gc) # type: ignore[attr-defined]
1046
+ trans = affine
1047
+ trans += self._offset_transform(renderer) # type: ignore[attr-defined]
1048
+
1049
+ font = FontProperties()
1050
+ # approximate half size of 'x'
1051
+ fontsize = renderer.points_to_pixels(font.get_size_in_points()) / 4
1052
+ size = renderer.points_to_pixels(self._size)
1053
+ origin = affine.transform([[0.5, 0.0]])
1054
+
1055
+ transpath = affine.transform_path(tpath)
1056
+ polys = transpath.to_polygons(closed_only=False)
1057
+
1058
+ for p in polys:
1059
+ # coordinates of tick ends
1060
+ t = p - origin
1061
+ t /= numpy.hypot(t[:, 0], t[:, 1])[:, numpy.newaxis]
1062
+ d = t.copy()
1063
+ t *= size
1064
+ t += p
1065
+
1066
+ xyt = numpy.empty((2 * p.shape[0], 2))
1067
+ xyt[0::2] = p
1068
+ xyt[1::2] = t
1069
+
1070
+ renderer.draw_path(
1071
+ gc0,
1072
+ Path(xyt, numpy.tile([Path.MOVETO, Path.LINETO], p.shape[0])),
1073
+ affine.inverted() + trans,
1074
+ rgbFace,
1075
+ )
1076
+ if not self._labels:
1077
+ continue
1078
+ # coordinates of labels
1079
+ t = d * size * 2.5
1080
+ t += p
1081
+
1082
+ if renderer.flipy():
1083
+ h = renderer.get_canvas_width_height()[1]
1084
+ else:
1085
+ h = 0.0
1086
+
1087
+ for s, (x, y), (dx, _) in zip(self._labels, t, d):
1088
+ # TODO: get rendered text size from matplotlib.text.Text?
1089
+ # this did not work:
1090
+ # Text(d[i,0], h - d[i,1], label, ha='center', va='center')
1091
+ x = x + fontsize * len(s.split()[0]) * (dx - 1.0)
1092
+ y = h - y + fontsize
1093
+ renderer.draw_text(gc0, x, y, s, font, 0.0)
1094
+
1095
+ gc0.restore()
1096
+
1097
+
1098
+ def _semicircle_ticks(
1099
+ frequency: float,
1100
+ lifetime: Sequence[float] | None = None,
1101
+ labels: Sequence[str] | None = None,
1102
+ ) -> tuple[tuple[float, ...], tuple[str, ...]]:
1103
+ """Return semicircle tick lifetimes and labels at frequency."""
1104
+ if lifetime is None:
1105
+ lifetime = [0.0] + [
1106
+ 2**t
1107
+ for t in range(-8, 32)
1108
+ if phasor_from_lifetime(frequency, 2**t)[1] >= 0.18
1109
+ ]
1110
+ unit = 'ns'
1111
+ else:
1112
+ unit = ''
1113
+ if labels is None:
1114
+ labels = [f'{tau:g}' for tau in lifetime]
1115
+ try:
1116
+ labels[2] = f'{labels[2]} {unit}'
1117
+ except IndexError:
1118
+ pass
1119
+ return tuple(lifetime), tuple(labels)