phasorpy 0.6__cp311-cp311-win_amd64.whl → 0.7__cp311-cp311-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.
@@ -10,7 +10,7 @@ from collections.abc import Sequence
10
10
  from typing import TYPE_CHECKING
11
11
 
12
12
  if TYPE_CHECKING:
13
- from .._typing import Any, ArrayLike, NDArray, IO
13
+ from .._typing import Any, ArrayLike, Literal, NDArray, IO
14
14
 
15
15
  from matplotlib.axes import Axes
16
16
  from matplotlib.figure import Figure
@@ -18,8 +18,16 @@ if TYPE_CHECKING:
18
18
  import numpy
19
19
  from matplotlib import pyplot
20
20
  from matplotlib.font_manager import FontProperties
21
+ from matplotlib.legend import Legend
21
22
  from matplotlib.lines import Line2D
22
- from matplotlib.patches import Arc, Circle, Ellipse, FancyArrowPatch, Polygon
23
+ from matplotlib.patches import (
24
+ Arc,
25
+ Circle,
26
+ Ellipse,
27
+ FancyArrowPatch,
28
+ Polygon,
29
+ Rectangle,
30
+ )
23
31
  from matplotlib.path import Path
24
32
  from matplotlib.patheffects import AbstractPathEffect
25
33
 
@@ -32,19 +40,20 @@ from .._utils import (
32
40
  sort_coordinates,
33
41
  update_kwargs,
34
42
  )
35
- from ..phasor import (
43
+ from ..lifetime import (
36
44
  phasor_from_lifetime,
37
45
  phasor_semicircle,
38
46
  phasor_to_apparent_lifetime,
39
- phasor_transform,
40
47
  )
48
+ from ..phasor import phasor_to_polar, phasor_transform
41
49
 
42
50
  GRID_COLOR = '0.5'
43
51
  GRID_LINESTYLE = ':'
44
52
  GRID_LINESTYLE_MAJOR = '-'
45
- GRID_LINEWIDH = 1.0
46
- GRID_LINEWIDH_MINOR = 0.5
53
+ GRID_LINEWIDTH = 1.0
54
+ GRID_LINEWIDTH_MINOR = 0.6
47
55
  GRID_FILL = False
56
+ GRID_ZORDER = 2
48
57
 
49
58
 
50
59
  class PhasorPlot:
@@ -57,13 +66,18 @@ class PhasorPlot:
57
66
  allquadrants : bool, optional
58
67
  Show all quadrants of phasor space.
59
68
  By default, only the first quadrant with universal semicircle is shown.
60
- ax : matplotlib axes, optional
69
+ ax : matplotlib.axes.Axes, optional
61
70
  Matplotlib axes used for plotting.
62
71
  By default, a new subplot axes is created.
63
72
  frequency : float, optional
64
73
  Laser pulse or modulation frequency in MHz.
65
- grid : bool, optional, default: True
66
- Display polar grid or universal semicircle.
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`.
67
81
  **kwargs
68
82
  Additional properties to set on `ax`.
69
83
 
@@ -77,14 +91,17 @@ class PhasorPlot:
77
91
  _ax: Axes
78
92
  """Matplotlib axes."""
79
93
 
80
- _limits: tuple[tuple[float, float], tuple[float, float]]
81
- """Axes limits (xmin, xmax), (ymin, ymax)."""
82
-
83
94
  _full: bool
84
95
  """Show all quadrants of phasor space."""
85
96
 
86
- _semicircle_ticks: SemicircleTicks | None
87
- """Last SemicircleTicks instance created."""
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."""
88
105
 
89
106
  _frequency: float
90
107
  """Laser pulse or modulation frequency in MHz."""
@@ -96,7 +113,8 @@ class PhasorPlot:
96
113
  ax: Axes | None = None,
97
114
  *,
98
115
  frequency: float | None = None,
99
- grid: bool = True,
116
+ grid: dict[str, Any] | bool | None = None,
117
+ pad: float | None = None,
100
118
  **kwargs: Any,
101
119
  ) -> None:
102
120
  # initialize empty phasor plot
@@ -104,24 +122,38 @@ class PhasorPlot:
104
122
  self._ax.format_coord = ( # type: ignore[method-assign]
105
123
  self._on_format_coord
106
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)
107
136
 
108
137
  self._semicircle_ticks = None
138
+ self._unitcircle_ticks = None
109
139
 
110
140
  self._full = bool(allquadrants)
111
141
  if self._full:
112
- xlim = (-1.05, 1.05)
113
- ylim = (-1.05, 1.05)
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)
114
145
  xticks: tuple[float, ...] = (-1.0, -0.5, 0.0, 0.5, 1.0)
115
146
  yticks: tuple[float, ...] = (-1.0, -0.5, 0.0, 0.5, 1.0)
116
147
  if grid:
117
- self.polar_grid()
148
+ self.polar_grid(**grid_kwargs)
118
149
  else:
119
- xlim = (-0.05, 1.05)
120
- ylim = (-0.05, 0.7)
150
+ pad = 0.05 if pad is None else float(abs(pad))
151
+ xlim = (-pad, 1.0 + pad)
152
+ ylim = (-pad, 0.65 + pad)
121
153
  xticks = (0.0, 0.2, 0.4, 0.6, 0.8, 1.0)
122
154
  yticks = (0.0, 0.2, 0.4, 0.6)
123
155
  if grid:
124
- self.semicircle(frequency=frequency)
156
+ self.semicircle(frequency=frequency, **grid_kwargs)
125
157
 
126
158
  title = 'Phasor plot'
127
159
  if frequency is not None:
@@ -135,14 +167,21 @@ class PhasorPlot:
135
167
  title=title,
136
168
  xlabel='G, real',
137
169
  ylabel='S, imag',
138
- aspect='equal',
139
170
  xlim=xlim,
140
171
  ylim=ylim,
141
172
  xticks=xticks,
142
173
  yticks=yticks,
174
+ aspect='equal',
143
175
  )
144
- self._limits = (kwargs['xlim'], kwargs['ylim'])
176
+ for key in ('xlim', 'ylim', 'xticks', 'yticks', 'title'):
177
+ if kwargs[key] is None:
178
+ del kwargs[key]
145
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'])
146
185
 
147
186
  @property
148
187
  def ax(self) -> Axes:
@@ -169,9 +208,22 @@ class PhasorPlot:
169
208
 
170
209
  def show(self) -> None:
171
210
  """Display all open figures. Call :py:func:`matplotlib.pyplot.show`."""
211
+ if self._labels:
212
+ self._ax.legend()
172
213
  # self.fig.show()
173
214
  pyplot.show()
174
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
+
175
227
  def save(
176
228
  self,
177
229
  file: str | os.PathLike[Any] | IO[bytes] | None,
@@ -251,12 +303,11 @@ class PhasorPlot:
251
303
  if label is not None:
252
304
  try:
253
305
  lbl = label[i]
306
+ if lbl is not None:
307
+ self._labels = True
254
308
  except IndexError:
255
309
  pass
256
310
  lines = ax.plot(re, im, *args, label=lbl, **kwargs)
257
- if label is not None:
258
- ax.legend()
259
- self._reset_limits()
260
311
  return lines
261
312
 
262
313
  def _histogram2d(
@@ -267,7 +318,7 @@ class PhasorPlot:
267
318
  **kwargs: Any,
268
319
  ) -> tuple[NDArray[Any], NDArray[Any], NDArray[Any]]:
269
320
  """Return two-dimensional histogram of imag versus real coordinates."""
270
- update_kwargs(kwargs, range=self._limits)
321
+ update_kwargs(kwargs, range=(self._ax.get_xlim(), self._ax.get_ylim()))
271
322
  (xmin, xmax), (ymin, ymax) = kwargs['range']
272
323
  assert xmax > xmin and ymax > ymin
273
324
  bins = kwargs.get('bins', 128)
@@ -285,13 +336,6 @@ class PhasorPlot:
285
336
  **kwargs,
286
337
  )
287
338
 
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
339
  def hist2d(
296
340
  self,
297
341
  real: ArrayLike,
@@ -326,7 +370,10 @@ class PhasorPlot:
326
370
  if cmax is not None:
327
371
  h[h > cmax] = None
328
372
  self._ax.pcolormesh(xedges, yedges, h.T, **kwargs)
329
- self._reset_limits()
373
+
374
+ # TODO: create custom labels for pcolormesh?
375
+ # if 'label' in kwargs:
376
+ # self._labels = True
330
377
 
331
378
  def contour(
332
379
  self,
@@ -359,7 +406,10 @@ class PhasorPlot:
359
406
  xedges = xedges[:-1] + ((xedges[1] - xedges[0]) / 2.0)
360
407
  yedges = yedges[:-1] + ((yedges[1] - yedges[0]) / 2.0)
361
408
  self._ax.contour(xedges, yedges, h.T, **kwargs)
362
- self._reset_limits()
409
+
410
+ # TODO: create custom labels for contour?
411
+ # if 'label' in kwargs:
412
+ # self._labels = True
363
413
 
364
414
  def imshow(
365
415
  self,
@@ -369,6 +419,8 @@ class PhasorPlot:
369
419
  ) -> None:
370
420
  """Plot an image, for example, a 2D histogram (not implemented).
371
421
 
422
+ This method is not yet implemented and raises NotImplementedError.
423
+
372
424
  Parameters
373
425
  ----------
374
426
  image : array_like
@@ -452,14 +504,16 @@ class PhasorPlot:
452
504
  )
453
505
 
454
506
  if fraction is None:
507
+ linestyle = kwargs.pop('ls', GRID_LINESTYLE)
508
+ linewidth = kwargs.pop('lw', GRID_LINEWIDTH)
455
509
  update_kwargs(
456
510
  kwargs,
457
511
  edgecolor=GRID_COLOR if color is None else color,
458
- linestyle=GRID_LINESTYLE,
459
- linewidth=GRID_LINEWIDH,
512
+ linestyle=linestyle,
513
+ linewidth=linewidth,
460
514
  fill=GRID_FILL,
461
515
  )
462
- self._ax.add_patch(Polygon(numpy.vstack((real, imag)).T, **kwargs))
516
+ self._ax.add_patch(Polygon(numpy.vstack([real, imag]).T, **kwargs))
463
517
  if marker is not None:
464
518
  self._ax.plot(
465
519
  real,
@@ -470,18 +524,20 @@ class PhasorPlot:
470
524
  label=label_,
471
525
  )
472
526
  if label_ is not None:
473
- self._ax.legend()
527
+ self._labels = True
474
528
  return
475
529
 
476
530
  fraction = numpy.asarray(fraction)[indices]
531
+ linestyle = kwargs.pop('ls', GRID_LINESTYLE)
532
+ linewidth = kwargs.pop('lw', GRID_LINEWIDTH)
477
533
  update_kwargs(
478
534
  kwargs,
479
535
  color=GRID_COLOR if color is None else color,
480
- linestyle=GRID_LINESTYLE,
481
- linewidth=GRID_LINEWIDH,
536
+ linestyle=linestyle,
537
+ linewidth=linewidth,
482
538
  )
483
539
  center_re, center_im = numpy.average(
484
- numpy.vstack((real, imag)), axis=-1, weights=fraction
540
+ numpy.vstack([real, imag]), axis=-1, weights=fraction
485
541
  )
486
542
  for re, im in zip(real, imag):
487
543
  self._ax.add_line(
@@ -498,7 +554,7 @@ class PhasorPlot:
498
554
  label=label_,
499
555
  )
500
556
  if label_ is not None:
501
- self._ax.legend()
557
+ self._labels = True
502
558
 
503
559
  def line(
504
560
  self,
@@ -525,11 +581,10 @@ class PhasorPlot:
525
581
  List containing plotted line.
526
582
 
527
583
  """
584
+ linestyle = kwargs.pop('ls', GRID_LINESTYLE)
585
+ linewidth = kwargs.pop('lw', GRID_LINEWIDTH)
528
586
  update_kwargs(
529
- kwargs,
530
- color=GRID_COLOR,
531
- linestyle=GRID_LINESTYLE,
532
- linewidth=GRID_LINEWIDH,
587
+ kwargs, color=GRID_COLOR, linestyle=linestyle, linewidth=linewidth
533
588
  )
534
589
  return [self._ax.add_line(Line2D(real, imag, **kwargs))]
535
590
 
@@ -556,11 +611,13 @@ class PhasorPlot:
556
611
  :py:class:`matplotlib.patches.Circle`.
557
612
 
558
613
  """
614
+ linestyle = kwargs.pop('ls', GRID_LINESTYLE)
615
+ linewidth = kwargs.pop('lw', GRID_LINEWIDTH)
559
616
  update_kwargs(
560
617
  kwargs,
561
618
  color=GRID_COLOR,
562
- linestyle=GRID_LINESTYLE,
563
- linewidth=GRID_LINEWIDH,
619
+ linestyle=linestyle,
620
+ linewidth=linewidth,
564
621
  fill=GRID_FILL,
565
622
  )
566
623
  self._ax.add_patch(Circle((real, imag), radius, **kwargs))
@@ -576,7 +633,7 @@ class PhasorPlot:
576
633
  ) -> None:
577
634
  """Draw arrow between points.
578
635
 
579
- By default, draw a straight arrow with a `'-|>'` style, a mutation
636
+ By default, draw a straight arrow with a ``'-|>'`` style, a mutation
580
637
  scale of 20, and a miter join style.
581
638
 
582
639
  Parameters
@@ -612,43 +669,55 @@ class PhasorPlot:
612
669
 
613
670
  def cursor(
614
671
  self,
615
- real: float,
616
- imag: float,
672
+ real: ArrayLike,
673
+ imag: ArrayLike,
674
+ real_limit: ArrayLike | None = None,
675
+ imag_limit: ArrayLike | None = None,
617
676
  /,
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,
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,
624
685
  **kwargs: Any,
625
686
  ) -> None:
626
- """Plot phase and modulation grid lines and arcs at phasor coordinates.
687
+ """Draw cursor(s) at phasor coordinates.
627
688
 
628
689
  Parameters
629
690
  ----------
630
- real : float
691
+ real : array_like
631
692
  Real component of phasor coordinate.
632
- imag : float
693
+ imag : array_like
633
694
  Imaginary component of phasor coordinate.
634
- real_limit : float, optional
695
+ real_limit : array_like, optional
635
696
  Real component of limiting phasor coordinate.
636
- imag_limit : float, optional
697
+ imag_limit : array_like, optional
637
698
  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
699
+ radius : array_like, optional
700
+ Radius of circular cursor.
701
+ radius_minor : array_like, optional
641
702
  Radius of elliptic cursor along semi-minor axis.
642
703
  By default, `radius_minor` is equal to `radius`, that is,
643
704
  the ellipse is circular.
644
- angle : float, optional
705
+ angle : array_like or {'phase', 'semicircle'}, optional
645
706
  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.
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.
652
721
  **kwargs
653
722
  Additional parameters passed to
654
723
  :py:class:`matplotlib.lines.Line2D`,
@@ -663,64 +732,83 @@ class PhasorPlot:
663
732
  """
664
733
  if real_limit is not None and imag_limit is not None:
665
734
  return self.polar_cursor(
666
- *phasor_to_polar_scalar(real, imag),
667
- *phasor_to_polar_scalar(real_limit, imag_limit),
735
+ *phasor_to_polar(real, imag),
736
+ *phasor_to_polar(real_limit, imag_limit),
668
737
  radius=radius,
669
738
  radius_minor=radius_minor,
670
739
  angle=angle,
671
- align_semicircle=align_semicircle,
740
+ color=color,
741
+ label=label,
742
+ crosshair=crosshair,
743
+ polar=polar,
672
744
  **kwargs,
673
745
  )
674
746
  return self.polar_cursor(
675
- *phasor_to_polar_scalar(real, imag),
747
+ *phasor_to_polar(real, imag),
676
748
  radius=radius,
677
749
  radius_minor=radius_minor,
678
750
  angle=angle,
679
- align_semicircle=align_semicircle,
680
- # _circle_only=True,
751
+ color=color,
752
+ label=label,
753
+ crosshair=crosshair,
754
+ polar=polar,
681
755
  **kwargs,
682
756
  )
683
757
 
684
758
  def polar_cursor(
685
759
  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,
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,
694
772
  **kwargs: Any,
695
773
  ) -> None:
696
- """Plot phase and modulation grid lines and arcs.
774
+ """Draw cursor(s) at polar coordinates.
697
775
 
698
776
  Parameters
699
777
  ----------
700
- phase : float, optional
778
+ phase : array_like, optional
701
779
  Angular component of polar coordinate in radians.
702
- modulation : float, optional
780
+ modulation : array_like, optional
703
781
  Radial component of polar coordinate.
704
- phase_limit : float, optional
782
+ phase_limit : array_like, optional
705
783
  Angular component of limiting polar coordinate (in radians).
706
- Modulation grid arcs are drawn between `phase` and `phase_limit`.
707
- modulation_limit : float, optional
784
+ Modulation arcs are drawn between `phase` and `phase_limit`
785
+ if `polar` is true.
786
+ modulation_limit : array_like, optional
708
787
  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
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
713
793
  Radius of elliptic cursor along semi-minor axis.
714
794
  By default, `radius_minor` is equal to `radius`, that is,
715
795
  the ellipse is circular.
716
- angle : float, optional
796
+ angle : array_like or {'phase', 'semicircle'}, optional
717
797
  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.
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.
724
812
  **kwargs
725
813
  Additional parameters passed to
726
814
  :py:class:`matplotlib.lines.Line2D`,
@@ -733,42 +821,169 @@ class PhasorPlot:
733
821
  phasorpy.plot.PhasorPlot.cursor
734
822
 
735
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)
736
911
  update_kwargs(
737
912
  kwargs,
738
913
  color=GRID_COLOR,
739
- linestyle=GRID_LINESTYLE,
740
- linewidth=GRID_LINEWIDH,
914
+ linestyle=linestyle,
915
+ linewidth=linewidth,
741
916
  fill=GRID_FILL,
917
+ zorder=GRID_ZORDER,
742
918
  )
743
- _circle_only = kwargs.pop('_circle_only', False)
919
+
744
920
  ax = self._ax
745
921
  if radius is not None and phase is not None and modulation is not None:
746
922
  x = modulation * math.cos(phase)
747
923
  y = modulation * math.sin(phase)
748
924
  if radius_minor is not None and radius_minor != radius:
749
925
  if angle is None:
750
- if align_semicircle:
926
+ angle = phase
927
+ elif isinstance(angle, str):
928
+ if angle == 'phase':
929
+ angle = phase
930
+ elif angle == 'semicircle':
751
931
  angle = math.atan2(y, x - 0.5)
752
932
  else:
753
- angle = phase
933
+ raise ValueError(f'invalid {angle=}')
754
934
  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,
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
+ )
762
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
763
966
  )
764
- # TODO: implement gridlines intersecting with ellipse
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))
765
975
  return None
766
- ax.add_patch(Circle((x, y), radius, **kwargs))
767
- if _circle_only:
976
+
977
+ if abs(x) < 1e-6 and abs(y) < 1e-6:
978
+ # phase and modulation not defined at origin
768
979
  return None
769
- del kwargs['fill']
980
+
981
+ # draw crosshair phase line and modulation arc limited by circle
770
982
  x0, y0, x1, y1 = _intersect_circle_line(x, y, radius, 0, 0, x, y)
771
- ax.add_line(Line2D((x0, x1), (y0, y1), **kwargs))
983
+ ax.add_line(Line2D([x0, x1], [y0, y1], **kwargs))
984
+ if 'label' in kwargs:
985
+ self._labels = True
986
+ del kwargs['label']
772
987
  x0, y0, x1, y1 = _intersect_circle_circle(
773
988
  0, 0, modulation, x, y, radius
774
989
  )
@@ -785,6 +1000,30 @@ class PhasorPlot:
785
1000
  )
786
1001
  return None
787
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
788
1027
  del kwargs['fill']
789
1028
  for phi in (phase, phase_limit):
790
1029
  if phi is not None:
@@ -796,9 +1035,12 @@ class PhasorPlot:
796
1035
  else:
797
1036
  x0 = 0
798
1037
  y0 = 0
799
- x1 = math.cos(phi)
800
- y1 = math.sin(phi)
801
- ax.add_line(Line2D((x0, x1), (y0, y1), **kwargs))
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']
802
1044
  for mod in (modulation, modulation_limit):
803
1045
  if mod is not None:
804
1046
  if phase is not None and phase_limit is not None:
@@ -806,7 +1048,8 @@ class PhasorPlot:
806
1048
  theta2 = math.degrees(max(phase, phase_limit))
807
1049
  else:
808
1050
  theta1 = 0.0
809
- theta2 = 360.0 if self._full else 90.0
1051
+ theta2 = 360.0 # if self._full else 90.0
1052
+ # TODO: filling arc objects is not supported
810
1053
  ax.add_patch(
811
1054
  Arc(
812
1055
  (0, 0),
@@ -814,51 +1057,176 @@ class PhasorPlot:
814
1057
  mod * 2,
815
1058
  theta1=theta1,
816
1059
  theta2=theta2,
817
- fill=False, # filling arc objects is not supported
1060
+ fill=False,
818
1061
  **kwargs,
819
1062
  )
820
1063
  )
1064
+ if 'label' in kwargs:
1065
+ self._labels = True
1066
+ del kwargs['label']
821
1067
  return None
822
1068
 
823
- def polar_grid(self, **kwargs: Any) -> None:
824
- """Draw polar coordinate system.
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.
825
1081
 
826
1082
  Parameters
827
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 "{}".
828
1112
  **kwargs
829
1113
  Parameters passed to
830
1114
  :py:class:`matplotlib.patches.Circle` and
831
1115
  :py:class:`matplotlib.lines.Line2D`.
832
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
+
833
1133
  """
834
1134
  ax = self._ax
835
- # major gridlines
836
- kwargs_copy = kwargs.copy()
1135
+ minor_kwargs = kwargs.copy()
1136
+ linestyle = minor_kwargs.pop('ls', GRID_LINESTYLE)
1137
+ linewidth = minor_kwargs.pop('lw', GRID_LINEWIDTH_MINOR)
837
1138
  update_kwargs(
838
- kwargs,
1139
+ minor_kwargs,
839
1140
  color=GRID_COLOR,
840
- linestyle=GRID_LINESTYLE_MAJOR,
841
- linewidth=GRID_LINEWIDH,
842
- # fill=GRID_FILL,
1141
+ linestyle=linestyle,
1142
+ linewidth=linewidth,
1143
+ zorder=GRID_ZORDER,
843
1144
  )
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
1145
+ linestyle = kwargs.pop('ls', GRID_LINESTYLE_MAJOR)
1146
+ linewidth = kwargs.pop('lw', GRID_LINEWIDTH)
849
1147
  update_kwargs(
850
1148
  kwargs,
851
1149
  color=GRID_COLOR,
852
- linestyle=GRID_LINESTYLE,
853
- linewidth=GRID_LINEWIDH_MINOR,
1150
+ linestyle=linestyle,
1151
+ linewidth=linewidth,
1152
+ zorder=GRID_ZORDER,
1153
+ # fill=GRID_FILL,
854
1154
  )
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))
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)
862
1230
 
863
1231
  def semicircle(
864
1232
  self,
@@ -901,19 +1269,25 @@ class PhasorPlot:
901
1269
 
902
1270
  Returns
903
1271
  -------
904
- list[matplotlib.lines.Line2D]
1272
+ list of matplotlib.lines.Line2D
905
1273
  Lines representing plotted semicircle and ticks.
906
1274
 
907
1275
  """
908
1276
  if frequency is not None:
909
1277
  self._frequency = float(frequency)
910
1278
 
1279
+ linestyle = kwargs.pop('ls', GRID_LINESTYLE_MAJOR)
1280
+ linewidth = kwargs.pop('lw', GRID_LINEWIDTH)
911
1281
  update_kwargs(
912
1282
  kwargs,
1283
+ linestyle=linestyle,
1284
+ linewidth=linewidth,
913
1285
  color=GRID_COLOR,
914
- linestyle=GRID_LINESTYLE_MAJOR,
915
- linewidth=GRID_LINEWIDH,
1286
+ zorder=GRID_ZORDER,
916
1287
  )
1288
+ if 'label' in kwargs:
1289
+ self._labels = True
1290
+
917
1291
  if phasor_reference is not None:
918
1292
  polar_reference = phasor_to_polar_scalar(*phasor_reference)
919
1293
  if polar_reference is None:
@@ -949,10 +1323,13 @@ class PhasorPlot:
949
1323
  )
950
1324
  )
951
1325
 
1326
+ kwargs.pop('label', None) # don't pass label to ticks
1327
+ kwargs.pop('capstyle', None)
1328
+
952
1329
  if frequency is not None and polar_reference == (0.0, 1.0):
953
1330
  # draw ticks and labels
954
1331
  lifetime, labels = _semicircle_ticks(frequency, lifetime, labels)
955
- self._semicircle_ticks = SemicircleTicks(labels=labels)
1332
+ self._semicircle_ticks = CircleTicks((0.5, 0.0), labels=labels)
956
1333
  lines.extend(
957
1334
  ax.plot(
958
1335
  *phasor_transform(
@@ -963,7 +1340,6 @@ class PhasorPlot:
963
1340
  **kwargs,
964
1341
  )
965
1342
  )
966
- self._reset_limits()
967
1343
  return lines
968
1344
 
969
1345
  def _on_format_coord(self, x: float, y: float) -> str:
@@ -979,11 +1355,13 @@ class PhasorPlot:
979
1355
  return ' '.join(reversed(ret))
980
1356
 
981
1357
 
982
- class SemicircleTicks(AbstractPathEffect):
983
- """Draw ticks on universal semicircle.
1358
+ class CircleTicks(AbstractPathEffect):
1359
+ """Draw ticks on unit circle or universal semicircle.
984
1360
 
985
1361
  Parameters
986
1362
  ----------
1363
+ origin : (float, float), optional
1364
+ Origin of circle.
987
1365
  size : float, optional
988
1366
  Length of tick in dots.
989
1367
  The default is ``rcParams['xtick.major.size']``.
@@ -995,23 +1373,31 @@ class SemicircleTicks(AbstractPathEffect):
995
1373
 
996
1374
  """
997
1375
 
1376
+ _origin: tuple[float, float] # origin of circle
998
1377
  _size: float # tick length
999
1378
  _labels: tuple[str, ...] # tick labels
1000
1379
  _gc: dict[str, Any] # keywords passed to _update_gc
1001
1380
 
1002
1381
  def __init__(
1003
1382
  self,
1383
+ origin: tuple[float, float] | None = None,
1384
+ /,
1004
1385
  size: float | None = None,
1005
1386
  labels: Sequence[str] | None = None,
1006
1387
  **kwargs: Any,
1007
1388
  ) -> None:
1008
1389
  super().__init__((0.0, 0.0))
1009
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
+
1010
1396
  if size is None:
1011
1397
  self._size = pyplot.rcParams['xtick.major.size']
1012
1398
  else:
1013
1399
  self._size = size
1014
- if labels is None or not labels:
1400
+ if labels is None or len(labels) == 0:
1015
1401
  self._labels = ()
1016
1402
  else:
1017
1403
  self._labels = tuple(labels)
@@ -1024,7 +1410,7 @@ class SemicircleTicks(AbstractPathEffect):
1024
1410
 
1025
1411
  @labels.setter
1026
1412
  def labels(self, value: Sequence[str] | None, /) -> None:
1027
- if value is None or not value:
1413
+ if value is None:
1028
1414
  self._labels = ()
1029
1415
  else:
1030
1416
  self._labels = tuple(value)
@@ -1050,7 +1436,7 @@ class SemicircleTicks(AbstractPathEffect):
1050
1436
  # approximate half size of 'x'
1051
1437
  fontsize = renderer.points_to_pixels(font.get_size_in_points()) / 4
1052
1438
  size = renderer.points_to_pixels(self._size)
1053
- origin = affine.transform([[0.5, 0.0]])
1439
+ origin = affine.transform((self._origin,))
1054
1440
 
1055
1441
  transpath = affine.transform_path(tpath)
1056
1442
  polys = transpath.to_polygons(closed_only=False)
@@ -1088,6 +1474,8 @@ class SemicircleTicks(AbstractPathEffect):
1088
1474
  # TODO: get rendered text size from matplotlib.text.Text?
1089
1475
  # this did not work:
1090
1476
  # Text(d[i,0], h - d[i,1], label, ha='center', va='center')
1477
+ if not s:
1478
+ continue
1091
1479
  x = x + fontsize * len(s.split()[0]) * (dx - 1.0)
1092
1480
  y = h - y + fontsize
1093
1481
  renderer.draw_text(gc0, x, y, s, font, 0.0)