phasorpy 0.7__cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.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,1507 @@
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, Literal, 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.legend import Legend
22
+ from matplotlib.lines import Line2D
23
+ from matplotlib.patches import (
24
+ Arc,
25
+ Circle,
26
+ Ellipse,
27
+ FancyArrowPatch,
28
+ Polygon,
29
+ Rectangle,
30
+ )
31
+ from matplotlib.path import Path
32
+ from matplotlib.patheffects import AbstractPathEffect
33
+
34
+ from .._phasorpy import _intersect_circle_circle, _intersect_circle_line
35
+ from .._utils import (
36
+ dilate_coordinates,
37
+ parse_kwargs,
38
+ phasor_from_polar_scalar,
39
+ phasor_to_polar_scalar,
40
+ sort_coordinates,
41
+ update_kwargs,
42
+ )
43
+ from ..lifetime import (
44
+ phasor_from_lifetime,
45
+ phasor_semicircle,
46
+ phasor_to_apparent_lifetime,
47
+ )
48
+ from ..phasor import phasor_to_polar, phasor_transform
49
+
50
+ GRID_COLOR = '0.5'
51
+ GRID_LINESTYLE = ':'
52
+ GRID_LINESTYLE_MAJOR = '-'
53
+ GRID_LINEWIDTH = 1.0
54
+ GRID_LINEWIDTH_MINOR = 0.6
55
+ GRID_FILL = False
56
+ GRID_ZORDER = 2
57
+
58
+
59
+ class PhasorPlot:
60
+ """Phasor plot.
61
+
62
+ Create publication quality visualizations of phasor coordinates.
63
+
64
+ Parameters
65
+ ----------
66
+ allquadrants : bool, optional
67
+ Show all quadrants of phasor space.
68
+ By default, only the first quadrant with universal semicircle is shown.
69
+ ax : matplotlib.axes.Axes, optional
70
+ Matplotlib axes used for plotting.
71
+ By default, a new subplot axes is created.
72
+ frequency : float, optional
73
+ Laser pulse or modulation frequency in MHz.
74
+ pad : float, optional
75
+ Padding around the plot. The default is 0.05.
76
+ grid : dict or bool, optional
77
+ Display universal semicircle (default) or polar grid (allquadrants).
78
+ If False, no grid is displayed.
79
+ If a dictionary, it is passed to :py:meth:`PhasorPlot.polar_grid`
80
+ or :py:meth:`PhasorPlot.semicircle`.
81
+ **kwargs
82
+ Additional properties to set on `ax`.
83
+
84
+ See Also
85
+ --------
86
+ phasorpy.plot.plot_phasor
87
+ :ref:`sphx_glr_tutorials_api_phasorpy_phasorplot.py`
88
+
89
+ """
90
+
91
+ _ax: Axes
92
+ """Matplotlib axes."""
93
+
94
+ _full: bool
95
+ """Show all quadrants of phasor space."""
96
+
97
+ _labels: bool
98
+ """Plot has labels attached."""
99
+
100
+ _semicircle_ticks: CircleTicks | None
101
+ """Last CircleTicks instance created for semicircle."""
102
+
103
+ _unitcircle_ticks: CircleTicks | None
104
+ """Last CircleTicks instance created for unit circle."""
105
+
106
+ _frequency: float
107
+ """Laser pulse or modulation frequency in MHz."""
108
+
109
+ def __init__(
110
+ self,
111
+ /,
112
+ allquadrants: bool | None = None,
113
+ ax: Axes | None = None,
114
+ *,
115
+ frequency: float | None = None,
116
+ grid: dict[str, Any] | bool | None = None,
117
+ pad: float | None = None,
118
+ **kwargs: Any,
119
+ ) -> None:
120
+ # initialize empty phasor plot
121
+ self._ax = pyplot.subplots()[1] if ax is None else ax
122
+ self._ax.format_coord = ( # type: ignore[method-assign]
123
+ self._on_format_coord
124
+ )
125
+ self._labels = False
126
+
127
+ if grid is None:
128
+ grid_kwargs = {}
129
+ grid = True
130
+ if isinstance(grid, dict):
131
+ grid_kwargs = grid
132
+ grid = True
133
+ else:
134
+ grid_kwargs = {}
135
+ grid = bool(grid)
136
+
137
+ self._semicircle_ticks = None
138
+ self._unitcircle_ticks = None
139
+
140
+ self._full = bool(allquadrants)
141
+ if self._full:
142
+ pad = 0.1 if pad is None else float(abs(pad))
143
+ xlim = (-1.0 - pad, 1.0 + pad)
144
+ ylim = (-1.0 - pad, 1.0 + pad)
145
+ xticks: tuple[float, ...] = (-1.0, -0.5, 0.0, 0.5, 1.0)
146
+ yticks: tuple[float, ...] = (-1.0, -0.5, 0.0, 0.5, 1.0)
147
+ if grid:
148
+ self.polar_grid(**grid_kwargs)
149
+ else:
150
+ pad = 0.05 if pad is None else float(abs(pad))
151
+ xlim = (-pad, 1.0 + pad)
152
+ ylim = (-pad, 0.65 + pad)
153
+ xticks = (0.0, 0.2, 0.4, 0.6, 0.8, 1.0)
154
+ yticks = (0.0, 0.2, 0.4, 0.6)
155
+ if grid:
156
+ self.semicircle(frequency=frequency, **grid_kwargs)
157
+
158
+ title = 'Phasor plot'
159
+ if frequency is not None:
160
+ self._frequency = float(frequency)
161
+ title += f' ({frequency:g} MHz)'
162
+ else:
163
+ self._frequency = 0.0
164
+
165
+ update_kwargs(
166
+ kwargs,
167
+ title=title,
168
+ xlabel='G, real',
169
+ ylabel='S, imag',
170
+ xlim=xlim,
171
+ ylim=ylim,
172
+ xticks=xticks,
173
+ yticks=yticks,
174
+ aspect='equal',
175
+ )
176
+ for key in ('xlim', 'ylim', 'xticks', 'yticks', 'title'):
177
+ if kwargs[key] is None:
178
+ del kwargs[key]
179
+ self._ax.set(**kwargs)
180
+ # set axis limits after ticks
181
+ if 'xlim' in kwargs:
182
+ self._ax.set_xlim(kwargs['xlim'])
183
+ if 'ylim' in kwargs:
184
+ self._ax.set_ylim(kwargs['ylim'])
185
+
186
+ @property
187
+ def ax(self) -> Axes:
188
+ """Matplotlib :py:class:`matplotlib.axes.Axes`."""
189
+ return self._ax
190
+
191
+ @property
192
+ def fig(self) -> Figure | None:
193
+ """Matplotlib :py:class:`matplotlib.figure.Figure`."""
194
+ try:
195
+ # matplotlib >= 3.10.0
196
+ return self._ax.get_figure(root=True)
197
+ except TypeError:
198
+ return self._ax.get_figure() # type: ignore[return-value]
199
+
200
+ @property
201
+ def dataunit_to_point(self) -> float:
202
+ """Factor to convert data to point unit."""
203
+ fig = self.fig
204
+ assert fig is not None
205
+ length = fig.bbox_inches.height * self._ax.get_position().height * 72.0
206
+ vrange: float = numpy.diff(self._ax.get_ylim()).item()
207
+ return length / vrange
208
+
209
+ def show(self) -> None:
210
+ """Display all open figures. Call :py:func:`matplotlib.pyplot.show`."""
211
+ if self._labels:
212
+ self._ax.legend()
213
+ # self.fig.show()
214
+ pyplot.show()
215
+
216
+ def legend(self, **kwargs: Any) -> Legend:
217
+ """Add legend to plot.
218
+
219
+ Parameters
220
+ ----------
221
+ **kwargs
222
+ Keyword arguments passed to :py:func:`matplotlib.axes.Axes.legend`.
223
+
224
+ """
225
+ return self._ax.legend(**kwargs)
226
+
227
+ def save(
228
+ self,
229
+ file: str | os.PathLike[Any] | IO[bytes] | None,
230
+ /,
231
+ **kwargs: Any,
232
+ ) -> None:
233
+ """Save current figure to file.
234
+
235
+ Parameters
236
+ ----------
237
+ file : str, path-like, or binary file-like
238
+ Path or Python file-like object to write the current figure to.
239
+ **kwargs
240
+ Additional keyword arguments passed to
241
+ :py:func:`matplotlib:pyplot.savefig`.
242
+
243
+ """
244
+ pyplot.savefig(file, **kwargs)
245
+
246
+ def plot(
247
+ self,
248
+ real: ArrayLike,
249
+ imag: ArrayLike,
250
+ /,
251
+ fmt: str = 'o',
252
+ *,
253
+ label: Sequence[str] | None = None,
254
+ **kwargs: Any,
255
+ ) -> list[Line2D]:
256
+ """Plot imaginary versus real coordinates as markers or lines.
257
+
258
+ Parameters
259
+ ----------
260
+ real : array_like
261
+ Real component of phasor coordinates.
262
+ Must be one or two dimensional.
263
+ imag : array_like
264
+ Imaginary component of phasor coordinates.
265
+ Must be of same shape as `real`.
266
+ fmt : str, optional, default: 'o'
267
+ Matplotlib style format string.
268
+ label : sequence of str, optional
269
+ Plot label.
270
+ May be a sequence if phasor coordinates are two dimensional arrays.
271
+ **kwargs
272
+ Additional parameters passed to
273
+ :py:meth:`matplotlib.axes.Axes.plot`.
274
+
275
+ Returns
276
+ -------
277
+ list[matplotlib.lines.Line2D]
278
+ Lines representing data plotted last.
279
+
280
+ """
281
+ lines = []
282
+ if fmt == 'o':
283
+ if 'marker' in kwargs:
284
+ fmt = ''
285
+ if 'linestyle' not in kwargs and 'ls' not in kwargs:
286
+ kwargs['linestyle'] = ''
287
+ args = (fmt,) if fmt else ()
288
+ ax = self._ax
289
+ if label is not None and (
290
+ isinstance(label, str) or not isinstance(label, Sequence)
291
+ ):
292
+ label = (label,)
293
+ for (
294
+ i,
295
+ (re, im),
296
+ ) in enumerate(
297
+ zip(
298
+ numpy.atleast_2d(numpy.asarray(real)),
299
+ numpy.atleast_2d(numpy.asarray(imag)),
300
+ )
301
+ ):
302
+ lbl = None
303
+ if label is not None:
304
+ try:
305
+ lbl = label[i]
306
+ if lbl is not None:
307
+ self._labels = True
308
+ except IndexError:
309
+ pass
310
+ lines = ax.plot(re, im, *args, label=lbl, **kwargs)
311
+ return lines
312
+
313
+ def _histogram2d(
314
+ self,
315
+ real: ArrayLike,
316
+ imag: ArrayLike,
317
+ /,
318
+ **kwargs: Any,
319
+ ) -> tuple[NDArray[Any], NDArray[Any], NDArray[Any]]:
320
+ """Return two-dimensional histogram of imag versus real coordinates."""
321
+ update_kwargs(kwargs, range=(self._ax.get_xlim(), self._ax.get_ylim()))
322
+ (xmin, xmax), (ymin, ymax) = kwargs['range']
323
+ assert xmax > xmin and ymax > ymin
324
+ bins = kwargs.get('bins', 128)
325
+ if isinstance(bins, int):
326
+ assert bins > 0
327
+ aspect = (xmax - xmin) / (ymax - ymin)
328
+ if aspect > 1:
329
+ bins = (bins, max(int(bins / aspect), 1))
330
+ else:
331
+ bins = (max(int(bins * aspect), 1), bins)
332
+ kwargs['bins'] = bins
333
+ return numpy.histogram2d(
334
+ numpy.asanyarray(real).reshape(-1),
335
+ numpy.asanyarray(imag).reshape(-1),
336
+ **kwargs,
337
+ )
338
+
339
+ def hist2d(
340
+ self,
341
+ real: ArrayLike,
342
+ imag: ArrayLike,
343
+ /,
344
+ **kwargs: Any,
345
+ ) -> None:
346
+ """Plot two-dimensional histogram of imag versus real coordinates.
347
+
348
+ Parameters
349
+ ----------
350
+ real : array_like
351
+ Real component of phasor coordinates.
352
+ imag : array_like
353
+ Imaginary component of phasor coordinates.
354
+ Must be of same shape as `real`.
355
+ **kwargs
356
+ Additional parameters passed to :py:meth:`numpy.histogram2d`
357
+ and :py:meth:`matplotlib.axes.Axes.pcolormesh`.
358
+
359
+ """
360
+ kwargs_hist2d = parse_kwargs(
361
+ kwargs, 'bins', 'range', 'density', 'weights'
362
+ )
363
+ h, xedges, yedges = self._histogram2d(real, imag, **kwargs_hist2d)
364
+
365
+ update_kwargs(kwargs, cmap='Blues', norm='log')
366
+ cmin = kwargs.pop('cmin', 1)
367
+ cmax = kwargs.pop('cmax', None)
368
+ if cmin is not None:
369
+ h[h < cmin] = None
370
+ if cmax is not None:
371
+ h[h > cmax] = None
372
+ self._ax.pcolormesh(xedges, yedges, h.T, **kwargs)
373
+
374
+ # TODO: create custom labels for pcolormesh?
375
+ # if 'label' in kwargs:
376
+ # self._labels = True
377
+
378
+ def contour(
379
+ self,
380
+ real: ArrayLike,
381
+ imag: ArrayLike,
382
+ /,
383
+ **kwargs: Any,
384
+ ) -> None:
385
+ """Plot contours of imag versus real coordinates (not implemented).
386
+
387
+ Parameters
388
+ ----------
389
+ real : array_like
390
+ Real component of phasor coordinates.
391
+ imag : array_like
392
+ Imaginary component of phasor coordinates.
393
+ Must be of same shape as `real`.
394
+ **kwargs
395
+ Additional parameters passed to :py:func:`numpy.histogram2d`
396
+ and :py:meth:`matplotlib.axes.Axes.contour`.
397
+
398
+ """
399
+ if 'cmap' not in kwargs and 'colors' not in kwargs:
400
+ kwargs['cmap'] = 'Blues'
401
+ update_kwargs(kwargs, norm='log')
402
+ kwargs_hist2d = parse_kwargs(
403
+ kwargs, 'bins', 'range', 'density', 'weights'
404
+ )
405
+ h, xedges, yedges = self._histogram2d(real, imag, **kwargs_hist2d)
406
+ xedges = xedges[:-1] + ((xedges[1] - xedges[0]) / 2.0)
407
+ yedges = yedges[:-1] + ((yedges[1] - yedges[0]) / 2.0)
408
+ self._ax.contour(xedges, yedges, h.T, **kwargs)
409
+
410
+ # TODO: create custom labels for contour?
411
+ # if 'label' in kwargs:
412
+ # self._labels = True
413
+
414
+ def imshow(
415
+ self,
416
+ image: ArrayLike,
417
+ /,
418
+ **kwargs: Any,
419
+ ) -> None:
420
+ """Plot an image, for example, a 2D histogram (not implemented).
421
+
422
+ This method is not yet implemented and raises NotImplementedError.
423
+
424
+ Parameters
425
+ ----------
426
+ image : array_like
427
+ Image to display.
428
+ **kwargs
429
+ Additional parameters passed to
430
+ :py:meth:`matplotlib.axes.Axes.imshow`.
431
+
432
+ """
433
+ raise NotImplementedError
434
+
435
+ def components(
436
+ self,
437
+ real: ArrayLike,
438
+ imag: ArrayLike,
439
+ /,
440
+ fraction: ArrayLike | None = None,
441
+ labels: Sequence[str] | None = None,
442
+ label_offset: float | None = None,
443
+ **kwargs: Any,
444
+ ) -> None:
445
+ """Plot linear combinations of phasor coordinates or ranges thereof.
446
+
447
+ Parameters
448
+ ----------
449
+ real : (N,) array_like
450
+ Real component of phasor coordinates.
451
+ imag : (N,) array_like
452
+ Imaginary component of phasor coordinates.
453
+ fraction : (N,) array_like, optional
454
+ Weight associated with each component.
455
+ If None (default), outline the polygon area of possible linear
456
+ combinations of components.
457
+ Else, draw lines from the component coordinates to the weighted
458
+ average.
459
+ labels : Sequence of str, optional
460
+ Text label for each component.
461
+ label_offset : float, optional
462
+ Distance of text label to component coordinate.
463
+ **kwargs
464
+ Additional parameters passed to
465
+ :py:class:`matplotlib.patches.Polygon`,
466
+ :py:class:`matplotlib.lines.Line2D`, or
467
+ :py:class:`matplotlib.axes.Axes.annotate`
468
+
469
+ """
470
+ # TODO: use convex hull for outline
471
+ # TODO: improve automatic placement of labels
472
+ # TODO: catch more annotate properties?
473
+ real, imag, indices = sort_coordinates(real, imag)
474
+
475
+ label_ = kwargs.pop('label', None)
476
+ marker = kwargs.pop('marker', None)
477
+ color = kwargs.pop('color', None)
478
+ fontsize = kwargs.pop('fontsize', 12)
479
+ fontweight = kwargs.pop('fontweight', 'bold')
480
+ horizontalalignment = kwargs.pop('horizontalalignment', 'center')
481
+ verticalalignment = kwargs.pop('verticalalignment', 'center')
482
+ if label_offset is None:
483
+ label_offset = numpy.diff(self._ax.get_xlim()).item() * 0.04
484
+
485
+ if labels is not None:
486
+ if len(labels) != real.size:
487
+ raise ValueError(
488
+ f'number labels={len(labels)} != components={real.size}'
489
+ )
490
+ labels = [labels[i] for i in indices]
491
+ textposition = dilate_coordinates(real, imag, label_offset)
492
+ for label, re, im, x, y in zip(labels, real, imag, *textposition):
493
+ if not label:
494
+ continue
495
+ self._ax.annotate(
496
+ label,
497
+ (re, im),
498
+ xytext=(x, y),
499
+ color=color,
500
+ fontsize=fontsize,
501
+ fontweight=fontweight,
502
+ horizontalalignment=horizontalalignment,
503
+ verticalalignment=verticalalignment,
504
+ )
505
+
506
+ if fraction is None:
507
+ linestyle = kwargs.pop('ls', GRID_LINESTYLE)
508
+ linewidth = kwargs.pop('lw', GRID_LINEWIDTH)
509
+ update_kwargs(
510
+ kwargs,
511
+ edgecolor=GRID_COLOR if color is None else color,
512
+ linestyle=linestyle,
513
+ linewidth=linewidth,
514
+ fill=GRID_FILL,
515
+ )
516
+ self._ax.add_patch(Polygon(numpy.vstack([real, imag]).T, **kwargs))
517
+ if marker is not None:
518
+ self._ax.plot(
519
+ real,
520
+ imag,
521
+ marker=marker,
522
+ linestyle='',
523
+ color=color,
524
+ label=label_,
525
+ )
526
+ if label_ is not None:
527
+ self._labels = True
528
+ return
529
+
530
+ fraction = numpy.asarray(fraction)[indices]
531
+ linestyle = kwargs.pop('ls', GRID_LINESTYLE)
532
+ linewidth = kwargs.pop('lw', GRID_LINEWIDTH)
533
+ update_kwargs(
534
+ kwargs,
535
+ color=GRID_COLOR if color is None else color,
536
+ linestyle=linestyle,
537
+ linewidth=linewidth,
538
+ )
539
+ center_re, center_im = numpy.average(
540
+ numpy.vstack([real, imag]), axis=-1, weights=fraction
541
+ )
542
+ for re, im in zip(real, imag):
543
+ self._ax.add_line(
544
+ Line2D([center_re, re], [center_im, im], **kwargs)
545
+ )
546
+ if marker is not None:
547
+ self._ax.plot(real, imag, marker=marker, linestyle='', color=color)
548
+ self._ax.plot(
549
+ center_re,
550
+ center_im,
551
+ marker=marker,
552
+ linestyle='',
553
+ color=color,
554
+ label=label_,
555
+ )
556
+ if label_ is not None:
557
+ self._labels = True
558
+
559
+ def line(
560
+ self,
561
+ real: ArrayLike,
562
+ imag: ArrayLike,
563
+ /,
564
+ **kwargs: Any,
565
+ ) -> list[Line2D]:
566
+ """Draw grid line.
567
+
568
+ Parameters
569
+ ----------
570
+ real : array_like, shape (n, )
571
+ Real components of line start and end coordinates.
572
+ imag : array_like, shape (n, )
573
+ Imaginary components of line start and end coordinates.
574
+ **kwargs
575
+ Additional parameters passed to
576
+ :py:class:`matplotlib.lines.Line2D`.
577
+
578
+ Returns
579
+ -------
580
+ list[matplotlib.lines.Line2D]
581
+ List containing plotted line.
582
+
583
+ """
584
+ linestyle = kwargs.pop('ls', GRID_LINESTYLE)
585
+ linewidth = kwargs.pop('lw', GRID_LINEWIDTH)
586
+ update_kwargs(
587
+ kwargs, color=GRID_COLOR, linestyle=linestyle, linewidth=linewidth
588
+ )
589
+ return [self._ax.add_line(Line2D(real, imag, **kwargs))]
590
+
591
+ def circle(
592
+ self,
593
+ real: float,
594
+ imag: float,
595
+ /,
596
+ radius: float,
597
+ **kwargs: Any,
598
+ ) -> None:
599
+ """Draw grid circle of radius around center.
600
+
601
+ Parameters
602
+ ----------
603
+ real : float
604
+ Real component of circle center coordinate.
605
+ imag : float
606
+ Imaginary component of circle center coordinate.
607
+ radius : float
608
+ Circle radius.
609
+ **kwargs
610
+ Additional parameters passed to
611
+ :py:class:`matplotlib.patches.Circle`.
612
+
613
+ """
614
+ linestyle = kwargs.pop('ls', GRID_LINESTYLE)
615
+ linewidth = kwargs.pop('lw', GRID_LINEWIDTH)
616
+ update_kwargs(
617
+ kwargs,
618
+ color=GRID_COLOR,
619
+ linestyle=linestyle,
620
+ linewidth=linewidth,
621
+ fill=GRID_FILL,
622
+ )
623
+ self._ax.add_patch(Circle((real, imag), radius, **kwargs))
624
+
625
+ def arrow(
626
+ self,
627
+ point0: ArrayLike,
628
+ point1: ArrayLike,
629
+ /,
630
+ *,
631
+ angle: float | None = None,
632
+ **kwargs: Any,
633
+ ) -> None:
634
+ """Draw arrow between points.
635
+
636
+ By default, draw a straight arrow with a ``'-|>'`` style, a mutation
637
+ scale of 20, and a miter join style.
638
+
639
+ Parameters
640
+ ----------
641
+ point0 : array_like
642
+ X and y coordinates of start point of arrow.
643
+ point1 : array_like
644
+ X and y coordinates of end point of arrow.
645
+ angle : float, optional
646
+ Angle in radians, controlling curvature of line between points.
647
+ If None (default), draw a straight line.
648
+ **kwargs
649
+ Additional parameters passed to
650
+ :py:class:`matplotlib.patches.FancyArrowPatch`.
651
+
652
+ """
653
+ arrowstyle = kwargs.pop('arrowstyle', '-|>')
654
+ mutation_scale = kwargs.pop('mutation_scale', 20)
655
+ joinstyle = kwargs.pop('joinstyle', 'miter')
656
+ if angle is not None:
657
+ kwargs['connectionstyle'] = f'arc3,rad={math.tan(angle / 4.0)}'
658
+
659
+ patch = FancyArrowPatch(
660
+ point0, # type: ignore[arg-type]
661
+ point1, # type: ignore[arg-type]
662
+ arrowstyle=arrowstyle,
663
+ mutation_scale=mutation_scale,
664
+ # capstyle='projecting',
665
+ joinstyle=joinstyle,
666
+ **kwargs,
667
+ )
668
+ self._ax.add_patch(patch)
669
+
670
+ def cursor(
671
+ self,
672
+ real: ArrayLike,
673
+ imag: ArrayLike,
674
+ real_limit: ArrayLike | None = None,
675
+ imag_limit: ArrayLike | None = None,
676
+ /,
677
+ *,
678
+ radius: ArrayLike | None = None,
679
+ radius_minor: ArrayLike | None = None,
680
+ angle: ArrayLike | Literal['phase', 'semicircle'] | str | None = None,
681
+ color: ArrayLike | None = None,
682
+ label: ArrayLike | None = None,
683
+ crosshair: bool = False,
684
+ polar: bool = False,
685
+ **kwargs: Any,
686
+ ) -> None:
687
+ """Draw cursor(s) at phasor coordinates.
688
+
689
+ Parameters
690
+ ----------
691
+ real : array_like
692
+ Real component of phasor coordinate.
693
+ imag : array_like
694
+ Imaginary component of phasor coordinate.
695
+ real_limit : array_like, optional
696
+ Real component of limiting phasor coordinate.
697
+ imag_limit : array_like, optional
698
+ Imaginary component of limiting phasor coordinate.
699
+ radius : array_like, optional
700
+ Radius of circular cursor.
701
+ radius_minor : array_like, optional
702
+ Radius of elliptic cursor along semi-minor axis.
703
+ By default, `radius_minor` is equal to `radius`, that is,
704
+ the ellipse is circular.
705
+ angle : array_like or {'phase', 'semicircle'}, optional
706
+ Rotation angle of semi-major axis of elliptic cursor in radians.
707
+ If None or 'phase', align the minor axis of the ellipse with
708
+ the closest tangent on the unit circle.
709
+ If 'semicircle', align the ellipse with the universal semicircle.
710
+ color : array_like, optional
711
+ Color of cursor.
712
+ label : array_like, optional
713
+ String label for cursor.
714
+ crosshair : bool, optional
715
+ If true, draw polar or Cartesian lines or arcs limited by radius.
716
+ Else, draw circle or ellipse (default).
717
+ Only applies if `radius` is provided.
718
+ polar : bool, optional
719
+ If true, draw phase line and modulation arc.
720
+ Else, draw Cartesian lines.
721
+ **kwargs
722
+ Additional parameters passed to
723
+ :py:class:`matplotlib.lines.Line2D`,
724
+ :py:class:`matplotlib.patches.Circle`,
725
+ :py:class:`matplotlib.patches.Ellipse`, or
726
+ :py:class:`matplotlib.patches.Arc`.
727
+
728
+ See Also
729
+ --------
730
+ phasorpy.plot.PhasorPlot.polar_cursor
731
+
732
+ """
733
+ if real_limit is not None and imag_limit is not None:
734
+ return self.polar_cursor(
735
+ *phasor_to_polar(real, imag),
736
+ *phasor_to_polar(real_limit, imag_limit),
737
+ radius=radius,
738
+ radius_minor=radius_minor,
739
+ angle=angle,
740
+ color=color,
741
+ label=label,
742
+ crosshair=crosshair,
743
+ polar=polar,
744
+ **kwargs,
745
+ )
746
+ return self.polar_cursor(
747
+ *phasor_to_polar(real, imag),
748
+ radius=radius,
749
+ radius_minor=radius_minor,
750
+ angle=angle,
751
+ color=color,
752
+ label=label,
753
+ crosshair=crosshair,
754
+ polar=polar,
755
+ **kwargs,
756
+ )
757
+
758
+ def polar_cursor(
759
+ self,
760
+ phase: ArrayLike | None = None,
761
+ modulation: ArrayLike | None = None,
762
+ phase_limit: ArrayLike | None = None,
763
+ modulation_limit: ArrayLike | None = None,
764
+ *,
765
+ radius: ArrayLike | None = None,
766
+ radius_minor: ArrayLike | None = None,
767
+ angle: ArrayLike | Literal['phase', 'semicircle'] | str | None = None,
768
+ color: ArrayLike | None = None,
769
+ label: ArrayLike | None = None,
770
+ crosshair: bool = False,
771
+ polar: bool = True,
772
+ **kwargs: Any,
773
+ ) -> None:
774
+ """Draw cursor(s) at polar coordinates.
775
+
776
+ Parameters
777
+ ----------
778
+ phase : array_like, optional
779
+ Angular component of polar coordinate in radians.
780
+ modulation : array_like, optional
781
+ Radial component of polar coordinate.
782
+ phase_limit : array_like, optional
783
+ Angular component of limiting polar coordinate (in radians).
784
+ Modulation arcs are drawn between `phase` and `phase_limit`
785
+ if `polar` is true.
786
+ modulation_limit : array_like, optional
787
+ Radial component of limiting polar coordinate.
788
+ Phase lines are drawn from `modulation` to `modulation_limit`
789
+ if `polar` is true.
790
+ radius : array_like, optional
791
+ Radius of circular cursor.
792
+ radius_minor : array_like, optional
793
+ Radius of elliptic cursor along semi-minor axis.
794
+ By default, `radius_minor` is equal to `radius`, that is,
795
+ the ellipse is circular.
796
+ angle : array_like or {'phase', 'semicircle'}, optional
797
+ Rotation angle of semi-major axis of elliptic cursor in radians.
798
+ If None or 'phase', align the minor axis of the ellipse with
799
+ the closest tangent on the unit circle.
800
+ If 'semicircle', align the ellipse with the universal semicircle.
801
+ color : array_like, optional
802
+ Color of cursor.
803
+ label : array_like, optional
804
+ String label for cursor.
805
+ crosshair : bool, optional
806
+ If true, draw polar or Cartesian lines or arcs limited by radius.
807
+ Else, draw circle or ellipse (default).
808
+ Only applies if `radius` is provided.
809
+ polar : bool, optional
810
+ If true, draw phase line and modulation arc.
811
+ Else, draw Cartesian lines.
812
+ **kwargs
813
+ Additional parameters passed to
814
+ :py:class:`matplotlib.lines.Line2D`,
815
+ :py:class:`matplotlib.patches.Circle`,
816
+ :py:class:`matplotlib.patches.Ellipse`, or
817
+ :py:class:`matplotlib.patches.Arc`.
818
+
819
+ See Also
820
+ --------
821
+ phasorpy.plot.PhasorPlot.cursor
822
+
823
+ """
824
+ shape = None
825
+ if phase is not None:
826
+ phase = numpy.atleast_1d(phase)
827
+ if phase.ndim != 1:
828
+ raise ValueError(f'invalid {phase.ndim=} != 1')
829
+ shape = phase.shape
830
+ if modulation is not None:
831
+ if shape is not None:
832
+ modulation = numpy.broadcast_to(modulation, shape)
833
+ else:
834
+ modulation = numpy.atleast_1d(modulation)
835
+ if modulation.ndim != 1:
836
+ raise ValueError(f'invalid {modulation.ndim=} != 1')
837
+ shape = modulation.shape
838
+ if shape is None:
839
+ return
840
+
841
+ if phase_limit is not None:
842
+ phase_limit = numpy.broadcast_to(phase_limit, shape)
843
+ if modulation_limit is not None:
844
+ modulation_limit = numpy.broadcast_to(modulation_limit, shape)
845
+ if radius is not None:
846
+ radius = numpy.broadcast_to(radius, shape)
847
+ if radius_minor is not None:
848
+ radius_minor = numpy.broadcast_to(radius_minor, shape)
849
+ if angle is not None and not isinstance(angle, str):
850
+ angle = numpy.broadcast_to(angle, shape)
851
+ if label is not None:
852
+ label = numpy.broadcast_to(label, shape)
853
+ label = [str(c) for c in label]
854
+ if color is not None:
855
+ color = numpy.atleast_1d(color)
856
+ if color.dtype.kind == 'U':
857
+ color = numpy.broadcast_to(color, shape)
858
+ color = [str(c) for c in color]
859
+ else:
860
+ color = numpy.broadcast_to(color, (shape[0], color.shape[-1]))
861
+
862
+ for i in range(shape[0]):
863
+
864
+ if color is not None:
865
+ kwargs['color'] = color[i]
866
+ if label is not None:
867
+ kwargs['label'] = label[i]
868
+
869
+ self._cursor(
870
+ phase if phase is None else float(phase[i]),
871
+ modulation if modulation is None else float(modulation[i]),
872
+ phase_limit if phase_limit is None else float(phase_limit[i]),
873
+ (
874
+ modulation_limit
875
+ if modulation_limit is None
876
+ else float(modulation_limit[i])
877
+ ),
878
+ radius=radius if radius is None else float(radius[i]),
879
+ radius_minor=(
880
+ radius_minor
881
+ if radius_minor is None
882
+ else float(radius_minor[i])
883
+ ),
884
+ angle=(
885
+ angle
886
+ if (angle is None or isinstance(angle, str))
887
+ else float(angle[i])
888
+ ),
889
+ crosshair=crosshair,
890
+ polar=polar,
891
+ **kwargs,
892
+ )
893
+
894
+ def _cursor(
895
+ self,
896
+ phase: float | None = None,
897
+ modulation: float | None = None,
898
+ phase_limit: float | None = None,
899
+ modulation_limit: float | None = None,
900
+ *,
901
+ radius: float | None = None,
902
+ radius_minor: float | None = None,
903
+ angle: float | Literal['phase', 'semicircle'] | str | None = None,
904
+ crosshair: bool = False,
905
+ polar: bool = True,
906
+ **kwargs: Any,
907
+ ) -> None:
908
+ """Draw single cursor at polar coordinate."""
909
+ linestyle = kwargs.pop('ls', GRID_LINESTYLE_MAJOR)
910
+ linewidth = kwargs.pop('lw', GRID_LINEWIDTH)
911
+ update_kwargs(
912
+ kwargs,
913
+ color=GRID_COLOR,
914
+ linestyle=linestyle,
915
+ linewidth=linewidth,
916
+ fill=GRID_FILL,
917
+ zorder=GRID_ZORDER,
918
+ )
919
+
920
+ ax = self._ax
921
+ if radius is not None and phase is not None and modulation is not None:
922
+ x = modulation * math.cos(phase)
923
+ y = modulation * math.sin(phase)
924
+ if radius_minor is not None and radius_minor != radius:
925
+ if angle is None:
926
+ angle = phase
927
+ elif isinstance(angle, str):
928
+ if angle == 'phase':
929
+ angle = phase
930
+ elif angle == 'semicircle':
931
+ angle = math.atan2(y, x - 0.5)
932
+ else:
933
+ raise ValueError(f'invalid {angle=}')
934
+ angle = math.degrees(angle)
935
+
936
+ if not crosshair:
937
+ # draw elliptical cursor
938
+ ax.add_patch(
939
+ Ellipse(
940
+ (x, y),
941
+ radius * 2,
942
+ radius_minor * 2,
943
+ angle=angle,
944
+ **kwargs,
945
+ )
946
+ )
947
+ if 'label' in kwargs:
948
+ self._labels = True
949
+ return None
950
+
951
+ # TODO: implement crosshair intersecting with ellipse?
952
+ raise ValueError('crosshair not implemented with ellipse')
953
+
954
+ if not crosshair:
955
+ # draw circlar cursor
956
+ ax.add_patch(Circle((x, y), radius, **kwargs))
957
+ if 'label' in kwargs:
958
+ self._labels = True
959
+ return None
960
+
961
+ del kwargs['fill']
962
+ if not polar:
963
+ # draw Cartesian crosshair lines limited by radius
964
+ x0, y0, x1, y1 = _intersect_circle_line(
965
+ x, y, radius, x, y, x + 1, y
966
+ )
967
+ ax.add_line(Line2D([x0, x1], [y0, y1], **kwargs))
968
+ if 'label' in kwargs:
969
+ self._labels = True
970
+ del kwargs['label']
971
+ x0, y0, x1, y1 = _intersect_circle_line(
972
+ x, y, radius, x, y, x, y + 1
973
+ )
974
+ ax.add_line(Line2D([x0, x1], [y0, y1], **kwargs))
975
+ return None
976
+
977
+ if abs(x) < 1e-6 and abs(y) < 1e-6:
978
+ # phase and modulation not defined at origin
979
+ return None
980
+
981
+ # draw crosshair phase line and modulation arc limited by circle
982
+ x0, y0, x1, y1 = _intersect_circle_line(x, y, radius, 0, 0, x, y)
983
+ ax.add_line(Line2D([x0, x1], [y0, y1], **kwargs))
984
+ if 'label' in kwargs:
985
+ self._labels = True
986
+ del kwargs['label']
987
+ x0, y0, x1, y1 = _intersect_circle_circle(
988
+ 0, 0, modulation, x, y, radius
989
+ )
990
+ ax.add_patch(
991
+ Arc(
992
+ (0, 0),
993
+ modulation * 2,
994
+ modulation * 2,
995
+ theta1=math.degrees(math.atan2(y0, x0)),
996
+ theta2=math.degrees(math.atan2(y1, x1)),
997
+ fill=False,
998
+ **kwargs,
999
+ )
1000
+ )
1001
+ return None
1002
+
1003
+ if not polar:
1004
+ if phase is None or modulation is None:
1005
+ return None
1006
+
1007
+ x0 = modulation * math.cos(phase)
1008
+ y0 = modulation * math.sin(phase)
1009
+ if phase_limit is None or modulation_limit is None:
1010
+ # draw Cartesian crosshair lines
1011
+ del kwargs['fill']
1012
+ ax.add_line(Line2D([x0, x0], [-2, 2], **kwargs))
1013
+ if 'label' in kwargs:
1014
+ self._labels = True
1015
+ del kwargs['label']
1016
+ ax.add_line(Line2D([-2, 2], [y0, y0], **kwargs))
1017
+ else:
1018
+ # draw rectangle
1019
+ x1 = modulation_limit * math.cos(phase_limit)
1020
+ y1 = modulation_limit * math.sin(phase_limit)
1021
+ ax.add_patch(Rectangle((x0, y0), x1 - x0, y1 - y0, **kwargs))
1022
+ if 'label' in kwargs:
1023
+ self._labels = True
1024
+ return None
1025
+
1026
+ # TODO: implement filled polar region/rectangle
1027
+ del kwargs['fill']
1028
+ for phi in (phase, phase_limit):
1029
+ if phi is not None:
1030
+ if modulation is not None and modulation_limit is not None:
1031
+ x0 = modulation * math.cos(phi)
1032
+ y0 = modulation * math.sin(phi)
1033
+ x1 = modulation_limit * math.cos(phi)
1034
+ y1 = modulation_limit * math.sin(phi)
1035
+ else:
1036
+ x0 = 0
1037
+ y0 = 0
1038
+ x1 = math.cos(phi) * 2
1039
+ y1 = math.sin(phi) * 2
1040
+ ax.add_line(Line2D([x0, x1], [y0, y1], **kwargs))
1041
+ if 'label' in kwargs:
1042
+ self._labels = True
1043
+ del kwargs['label']
1044
+ for mod in (modulation, modulation_limit):
1045
+ if mod is not None:
1046
+ if phase is not None and phase_limit is not None:
1047
+ theta1 = math.degrees(min(phase, phase_limit))
1048
+ theta2 = math.degrees(max(phase, phase_limit))
1049
+ else:
1050
+ theta1 = 0.0
1051
+ theta2 = 360.0 # if self._full else 90.0
1052
+ # TODO: filling arc objects is not supported
1053
+ ax.add_patch(
1054
+ Arc(
1055
+ (0, 0),
1056
+ mod * 2,
1057
+ mod * 2,
1058
+ theta1=theta1,
1059
+ theta2=theta2,
1060
+ fill=False,
1061
+ **kwargs,
1062
+ )
1063
+ )
1064
+ if 'label' in kwargs:
1065
+ self._labels = True
1066
+ del kwargs['label']
1067
+ return None
1068
+
1069
+ def polar_grid(
1070
+ self,
1071
+ radii: int | Sequence[float] | None = None,
1072
+ angles: int | Sequence[float] | None = None,
1073
+ samples: int | None = None,
1074
+ labels: Sequence[str] | None = None,
1075
+ ticks: ArrayLike | None = None,
1076
+ tick_space: ArrayLike | None = None,
1077
+ tick_format: str | None = None,
1078
+ **kwargs: Any,
1079
+ ) -> None:
1080
+ r"""Draw polar coordinate system.
1081
+
1082
+ Parameters
1083
+ ----------
1084
+ radii : int or sequence of float, optional
1085
+ Position of radial gridlines in range (0, 1].
1086
+ If an integer, the number of equidistant radial gridlines.
1087
+ By default, three equidistant radial gridlines are drawn.
1088
+ The unit circle (radius 1), if included, is drawn in major style.
1089
+ angles : int or sequence of float, optional
1090
+ Position of angular gridlines in range [0, 2 pi].
1091
+ If an integer, the number of equidistant angular gridlines.
1092
+ By default, 12 equidistant angular gridlines are drawn.
1093
+ samples : int, optional
1094
+ Number of vertices of polygon inscribed in unit circle.
1095
+ By default, no inscribed polygon is drawn.
1096
+ labels : sequence of str, optional
1097
+ Tick labels on unit circle.
1098
+ Labels are placed at equidistant angles if `ticks` are not
1099
+ provided.
1100
+ ticks : array_like, optional
1101
+ Values at which to place tick labels on unit circle.
1102
+ If `labels` are not provided, `ticks` values formatted with
1103
+ `tick_format` are used as labels.
1104
+ If `tick_space` is not provided, tick values are angles in radians.
1105
+ tick_space : array_like, optional
1106
+ Values used to convert `ticks` to angles.
1107
+ For example, the wavelengths used to calculate spectral phasors
1108
+ or the minimum and maximum wavelengths of a sine-cosine filter.
1109
+ tick_format : str, optional
1110
+ Format string for tick values if `labels` is None.
1111
+ By default, the tick format is "{}".
1112
+ **kwargs
1113
+ Parameters passed to
1114
+ :py:class:`matplotlib.patches.Circle` and
1115
+ :py:class:`matplotlib.lines.Line2D`.
1116
+
1117
+ Raises
1118
+ ------
1119
+ ValueError
1120
+ If number of ticks doesn't match number of labels.
1121
+ If `tick_space` has less than two values.
1122
+
1123
+ Notes
1124
+ -----
1125
+ Use ``radii=1, angles=4`` to draw major gridlines only.
1126
+
1127
+ The values of ticks (:math:`v`) are converted to angles
1128
+ (:math:`\theta`) using `tick_space` (:math:`s`) according to:
1129
+
1130
+ .. math::
1131
+ \theta = \frac{v - s_0}{s_{-1} + s_1 - 2 s_0} \cdot 2 \pi
1132
+
1133
+ """
1134
+ ax = self._ax
1135
+ minor_kwargs = kwargs.copy()
1136
+ linestyle = minor_kwargs.pop('ls', GRID_LINESTYLE)
1137
+ linewidth = minor_kwargs.pop('lw', GRID_LINEWIDTH_MINOR)
1138
+ update_kwargs(
1139
+ minor_kwargs,
1140
+ color=GRID_COLOR,
1141
+ linestyle=linestyle,
1142
+ linewidth=linewidth,
1143
+ zorder=GRID_ZORDER,
1144
+ )
1145
+ linestyle = kwargs.pop('ls', GRID_LINESTYLE_MAJOR)
1146
+ linewidth = kwargs.pop('lw', GRID_LINEWIDTH)
1147
+ update_kwargs(
1148
+ kwargs,
1149
+ color=GRID_COLOR,
1150
+ linestyle=linestyle,
1151
+ linewidth=linewidth,
1152
+ zorder=GRID_ZORDER,
1153
+ # fill=GRID_FILL,
1154
+ )
1155
+
1156
+ if samples is not None and samples > 1:
1157
+ angle = numpy.linspace(0, 2 * math.pi, samples, endpoint=False)
1158
+ xy = numpy.vstack([numpy.cos(angle), numpy.sin(angle)]).T
1159
+ ax.add_patch(Polygon(xy, fill=False, **kwargs))
1160
+
1161
+ if radii is None:
1162
+ radii = [1 / 3, 2 / 3, 1.0]
1163
+ elif isinstance(radii, int):
1164
+ radii = numpy.linspace(0, 1, radii + 1, endpoint=True)[1:].tolist()
1165
+ for r in radii: # type: ignore[union-attr]
1166
+ if r < 1e-3:
1167
+ # skip zero radius
1168
+ continue
1169
+ if abs(r - 1.0) < 1e-3:
1170
+ # unit circle
1171
+ circle = Circle((0, 0), 1, fill=False, **kwargs)
1172
+ elif r > 1.0:
1173
+ continue
1174
+ else:
1175
+ # minor circle
1176
+ circle = Circle((0, 0), r, fill=False, **minor_kwargs)
1177
+ ax.add_patch(circle)
1178
+
1179
+ if angles is None:
1180
+ angles = 12
1181
+ if isinstance(angles, int):
1182
+ angles = numpy.linspace(
1183
+ 0, 2 * math.pi, angles, endpoint=False
1184
+ ).tolist()
1185
+ for a in angles: # type: ignore[union-attr]
1186
+ if a < 0 or a > 2 * math.pi:
1187
+ # skip angles out of range
1188
+ continue
1189
+ x = math.cos(a)
1190
+ y = math.sin(a)
1191
+ ax.add_line(Line2D([0.0, x], [0.0, y], **minor_kwargs))
1192
+
1193
+ if labels is None and ticks is None:
1194
+ # no labels
1195
+ return
1196
+ if ticks is None:
1197
+ # equidistant labels
1198
+ assert labels is not None
1199
+ ticks = numpy.linspace(0, 2 * math.pi, len(labels), endpoint=False)
1200
+ tick_space = None
1201
+ elif labels is None:
1202
+ # use tick values as labels
1203
+ assert ticks is not None
1204
+ ticks = numpy.array(ticks, copy=True, ndmin=1)
1205
+ if tick_format is None:
1206
+ tick_format = '{}'
1207
+ labels = [tick_format.format(t) for t in ticks]
1208
+ ticks = ticks.astype(numpy.float64)
1209
+ else:
1210
+ # ticks and labels
1211
+ ticks = numpy.array(ticks, dtype=numpy.float64, copy=True, ndmin=1)
1212
+ if ticks.size != len(labels):
1213
+ raise ValueError(f'{ticks.size=} != {len(labels)=}')
1214
+
1215
+ if tick_space is not None:
1216
+ tick_space = numpy.asarray(tick_space, dtype=numpy.float64)
1217
+ if tick_space.ndim != 1 or tick_space.size < 2:
1218
+ raise ValueError(
1219
+ f'invalid {tick_space.ndim=} or {tick_space.size=} < 2'
1220
+ )
1221
+ assert isinstance(ticks, numpy.ndarray) # for mypy
1222
+ ticks -= tick_space[0]
1223
+ ticks /= tick_space[-1] + tick_space[1] - 2 * tick_space[0]
1224
+ ticks *= 2 * math.pi
1225
+
1226
+ real = numpy.cos(ticks)
1227
+ imag = numpy.sin(ticks)
1228
+ self._unitcircle_ticks = CircleTicks(labels=labels)
1229
+ ax.plot(real, imag, path_effects=[self._unitcircle_ticks], **kwargs)
1230
+
1231
+ def semicircle(
1232
+ self,
1233
+ frequency: float | None = None,
1234
+ *,
1235
+ polar_reference: tuple[float, float] | None = None,
1236
+ phasor_reference: tuple[float, float] | None = None,
1237
+ lifetime: Sequence[float] | None = None,
1238
+ labels: Sequence[str] | None = None,
1239
+ show_circle: bool = True,
1240
+ use_lines: bool = False,
1241
+ **kwargs: Any,
1242
+ ) -> list[Line2D]:
1243
+ """Draw universal semicircle.
1244
+
1245
+ Parameters
1246
+ ----------
1247
+ frequency : float, optional
1248
+ Laser pulse or modulation frequency in MHz.
1249
+ polar_reference : (float, float), optional, default: (0, 1)
1250
+ Polar coordinates of zero lifetime.
1251
+ phasor_reference : (float, float), optional, default: (1, 0)
1252
+ Phasor coordinates of zero lifetime.
1253
+ Alternative to `polar_reference`.
1254
+ lifetime : sequence of float, optional
1255
+ Single component lifetimes at which to draw ticks and labels.
1256
+ Only applies when `frequency` is specified.
1257
+ labels : sequence of str, optional
1258
+ Tick labels. By default, the values of `lifetime`.
1259
+ Only applies when `frequency` and `lifetime` are specified.
1260
+ show_circle : bool, optional, default: True
1261
+ Draw universal semicircle.
1262
+ use_lines : bool, optional, default: False
1263
+ Draw universal semicircle using lines instead of arc.
1264
+ **kwargs
1265
+ Additional parameters passed to
1266
+ :py:class:`matplotlib.lines.Line2D` or
1267
+ :py:class:`matplotlib.patches.Arc` and
1268
+ :py:meth:`matplotlib.axes.Axes.plot`.
1269
+
1270
+ Returns
1271
+ -------
1272
+ list of matplotlib.lines.Line2D
1273
+ Lines representing plotted semicircle and ticks.
1274
+
1275
+ """
1276
+ if frequency is not None:
1277
+ self._frequency = float(frequency)
1278
+
1279
+ linestyle = kwargs.pop('ls', GRID_LINESTYLE_MAJOR)
1280
+ linewidth = kwargs.pop('lw', GRID_LINEWIDTH)
1281
+ update_kwargs(
1282
+ kwargs,
1283
+ linestyle=linestyle,
1284
+ linewidth=linewidth,
1285
+ color=GRID_COLOR,
1286
+ zorder=GRID_ZORDER,
1287
+ )
1288
+ if 'label' in kwargs:
1289
+ self._labels = True
1290
+
1291
+ if phasor_reference is not None:
1292
+ polar_reference = phasor_to_polar_scalar(*phasor_reference)
1293
+ if polar_reference is None:
1294
+ polar_reference = (0.0, 1.0)
1295
+ if phasor_reference is None:
1296
+ phasor_reference = phasor_from_polar_scalar(*polar_reference)
1297
+ ax = self._ax
1298
+
1299
+ lines = []
1300
+
1301
+ if show_circle:
1302
+ if use_lines:
1303
+ lines = [
1304
+ ax.add_line(
1305
+ Line2D(
1306
+ *phasor_transform(
1307
+ *phasor_semicircle(), *polar_reference
1308
+ ),
1309
+ **kwargs,
1310
+ )
1311
+ )
1312
+ ]
1313
+ else:
1314
+ ax.add_patch(
1315
+ Arc(
1316
+ (phasor_reference[0] / 2, phasor_reference[1] / 2),
1317
+ polar_reference[1],
1318
+ polar_reference[1],
1319
+ theta1=math.degrees(polar_reference[0]),
1320
+ theta2=math.degrees(polar_reference[0]) + 180.0,
1321
+ fill=False,
1322
+ **kwargs,
1323
+ )
1324
+ )
1325
+
1326
+ kwargs.pop('label', None) # don't pass label to ticks
1327
+ kwargs.pop('capstyle', None)
1328
+
1329
+ if frequency is not None and polar_reference == (0.0, 1.0):
1330
+ # draw ticks and labels
1331
+ lifetime, labels = _semicircle_ticks(frequency, lifetime, labels)
1332
+ self._semicircle_ticks = CircleTicks((0.5, 0.0), labels=labels)
1333
+ lines.extend(
1334
+ ax.plot(
1335
+ *phasor_transform(
1336
+ *phasor_from_lifetime(frequency, lifetime),
1337
+ *polar_reference,
1338
+ ),
1339
+ path_effects=[self._semicircle_ticks],
1340
+ **kwargs,
1341
+ )
1342
+ )
1343
+ return lines
1344
+
1345
+ def _on_format_coord(self, x: float, y: float) -> str:
1346
+ """Callback function to update coordinates displayed in toolbar."""
1347
+ phi, mod = phasor_to_polar_scalar(x, y)
1348
+ ret = [
1349
+ f'[{x:4.2f}, {y:4.2f}]',
1350
+ f'[{math.degrees(phi):.0f}°, {mod * 100:.0f}%]',
1351
+ ]
1352
+ if x > 0.0 and y > 0.0 and self._frequency > 0.0:
1353
+ tp, tm = phasor_to_apparent_lifetime(x, y, self._frequency)
1354
+ ret.append(f'[{tp:.2f}, {tm:.2f} ns]')
1355
+ return ' '.join(reversed(ret))
1356
+
1357
+
1358
+ class CircleTicks(AbstractPathEffect):
1359
+ """Draw ticks on unit circle or universal semicircle.
1360
+
1361
+ Parameters
1362
+ ----------
1363
+ origin : (float, float), optional
1364
+ Origin of circle.
1365
+ size : float, optional
1366
+ Length of tick in dots.
1367
+ The default is ``rcParams['xtick.major.size']``.
1368
+ labels : sequence of str, optional
1369
+ Tick labels for each vertex in path.
1370
+ **kwargs
1371
+ Extra keywords passed to matplotlib's
1372
+ :py:meth:`matplotlib.patheffects.AbstractPathEffect._update_gc`.
1373
+
1374
+ """
1375
+
1376
+ _origin: tuple[float, float] # origin of circle
1377
+ _size: float # tick length
1378
+ _labels: tuple[str, ...] # tick labels
1379
+ _gc: dict[str, Any] # keywords passed to _update_gc
1380
+
1381
+ def __init__(
1382
+ self,
1383
+ origin: tuple[float, float] | None = None,
1384
+ /,
1385
+ size: float | None = None,
1386
+ labels: Sequence[str] | None = None,
1387
+ **kwargs: Any,
1388
+ ) -> None:
1389
+ super().__init__((0.0, 0.0))
1390
+
1391
+ if origin is None:
1392
+ self._origin = 0.0, 0.0
1393
+ else:
1394
+ self._origin = float(origin[0]), float(origin[1])
1395
+
1396
+ if size is None:
1397
+ self._size = pyplot.rcParams['xtick.major.size']
1398
+ else:
1399
+ self._size = size
1400
+ if labels is None or len(labels) == 0:
1401
+ self._labels = ()
1402
+ else:
1403
+ self._labels = tuple(labels)
1404
+ self._gc = kwargs
1405
+
1406
+ @property
1407
+ def labels(self) -> tuple[str, ...]:
1408
+ """Tick labels."""
1409
+ return self._labels
1410
+
1411
+ @labels.setter
1412
+ def labels(self, value: Sequence[str] | None, /) -> None:
1413
+ if value is None:
1414
+ self._labels = ()
1415
+ else:
1416
+ self._labels = tuple(value)
1417
+
1418
+ def draw_path(
1419
+ self,
1420
+ renderer: Any,
1421
+ gc: Any,
1422
+ tpath: Any,
1423
+ affine: Any,
1424
+ rgbFace: Any = None,
1425
+ ) -> None:
1426
+ """Draw path with updated gc."""
1427
+ gc0 = renderer.new_gc()
1428
+ gc0.copy_properties(gc)
1429
+
1430
+ # TODO: this uses private methods of the base class
1431
+ gc0 = self._update_gc(gc0, self._gc) # type: ignore[attr-defined]
1432
+ trans = affine
1433
+ trans += self._offset_transform(renderer) # type: ignore[attr-defined]
1434
+
1435
+ font = FontProperties()
1436
+ # approximate half size of 'x'
1437
+ fontsize = renderer.points_to_pixels(font.get_size_in_points()) / 4
1438
+ size = renderer.points_to_pixels(self._size)
1439
+ origin = affine.transform((self._origin,))
1440
+
1441
+ transpath = affine.transform_path(tpath)
1442
+ polys = transpath.to_polygons(closed_only=False)
1443
+
1444
+ for p in polys:
1445
+ # coordinates of tick ends
1446
+ t = p - origin
1447
+ t /= numpy.hypot(t[:, 0], t[:, 1])[:, numpy.newaxis]
1448
+ d = t.copy()
1449
+ t *= size
1450
+ t += p
1451
+
1452
+ xyt = numpy.empty((2 * p.shape[0], 2))
1453
+ xyt[0::2] = p
1454
+ xyt[1::2] = t
1455
+
1456
+ renderer.draw_path(
1457
+ gc0,
1458
+ Path(xyt, numpy.tile([Path.MOVETO, Path.LINETO], p.shape[0])),
1459
+ affine.inverted() + trans,
1460
+ rgbFace,
1461
+ )
1462
+ if not self._labels:
1463
+ continue
1464
+ # coordinates of labels
1465
+ t = d * size * 2.5
1466
+ t += p
1467
+
1468
+ if renderer.flipy():
1469
+ h = renderer.get_canvas_width_height()[1]
1470
+ else:
1471
+ h = 0.0
1472
+
1473
+ for s, (x, y), (dx, _) in zip(self._labels, t, d):
1474
+ # TODO: get rendered text size from matplotlib.text.Text?
1475
+ # this did not work:
1476
+ # Text(d[i,0], h - d[i,1], label, ha='center', va='center')
1477
+ if not s:
1478
+ continue
1479
+ x = x + fontsize * len(s.split()[0]) * (dx - 1.0)
1480
+ y = h - y + fontsize
1481
+ renderer.draw_text(gc0, x, y, s, font, 0.0)
1482
+
1483
+ gc0.restore()
1484
+
1485
+
1486
+ def _semicircle_ticks(
1487
+ frequency: float,
1488
+ lifetime: Sequence[float] | None = None,
1489
+ labels: Sequence[str] | None = None,
1490
+ ) -> tuple[tuple[float, ...], tuple[str, ...]]:
1491
+ """Return semicircle tick lifetimes and labels at frequency."""
1492
+ if lifetime is None:
1493
+ lifetime = [0.0] + [
1494
+ 2**t
1495
+ for t in range(-8, 32)
1496
+ if phasor_from_lifetime(frequency, 2**t)[1] >= 0.18
1497
+ ]
1498
+ unit = 'ns'
1499
+ else:
1500
+ unit = ''
1501
+ if labels is None:
1502
+ labels = [f'{tau:g}' for tau in lifetime]
1503
+ try:
1504
+ labels[2] = f'{labels[2]} {unit}'
1505
+ except IndexError:
1506
+ pass
1507
+ return tuple(lifetime), tuple(labels)