phasorpy 0.6__cp313-cp313-win_amd64.whl → 0.8__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.
@@ -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,23 @@ 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
+ Optional arguments passed to
223
+ :py:func:`matplotlib.axes.Axes.legend`.
224
+
225
+ """
226
+ return self._ax.legend(**kwargs)
227
+
175
228
  def save(
176
229
  self,
177
230
  file: str | os.PathLike[Any] | IO[bytes] | None,
@@ -185,8 +238,7 @@ class PhasorPlot:
185
238
  file : str, path-like, or binary file-like
186
239
  Path or Python file-like object to write the current figure to.
187
240
  **kwargs
188
- Additional keyword arguments passed to
189
- :py:func:`matplotlib:pyplot.savefig`.
241
+ Optional arguments passed to :py:func:`matplotlib:pyplot.savefig`.
190
242
 
191
243
  """
192
244
  pyplot.savefig(file, **kwargs)
@@ -217,8 +269,7 @@ class PhasorPlot:
217
269
  Plot label.
218
270
  May be a sequence if phasor coordinates are two dimensional arrays.
219
271
  **kwargs
220
- Additional parameters passed to
221
- :py:meth:`matplotlib.axes.Axes.plot`.
272
+ Optional arguments passed to :py:meth:`matplotlib.axes.Axes.plot`.
222
273
 
223
274
  Returns
224
275
  -------
@@ -251,12 +302,11 @@ class PhasorPlot:
251
302
  if label is not None:
252
303
  try:
253
304
  lbl = label[i]
305
+ if lbl is not None:
306
+ self._labels = True
254
307
  except IndexError:
255
308
  pass
256
309
  lines = ax.plot(re, im, *args, label=lbl, **kwargs)
257
- if label is not None:
258
- ax.legend()
259
- self._reset_limits()
260
310
  return lines
261
311
 
262
312
  def _histogram2d(
@@ -267,7 +317,7 @@ class PhasorPlot:
267
317
  **kwargs: Any,
268
318
  ) -> tuple[NDArray[Any], NDArray[Any], NDArray[Any]]:
269
319
  """Return two-dimensional histogram of imag versus real coordinates."""
270
- update_kwargs(kwargs, range=self._limits)
320
+ update_kwargs(kwargs, range=(self._ax.get_xlim(), self._ax.get_ylim()))
271
321
  (xmin, xmax), (ymin, ymax) = kwargs['range']
272
322
  assert xmax > xmin and ymax > ymin
273
323
  bins = kwargs.get('bins', 128)
@@ -285,13 +335,6 @@ class PhasorPlot:
285
335
  **kwargs,
286
336
  )
287
337
 
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
338
  def hist2d(
296
339
  self,
297
340
  real: ArrayLike,
@@ -309,7 +352,7 @@ class PhasorPlot:
309
352
  Imaginary component of phasor coordinates.
310
353
  Must be of same shape as `real`.
311
354
  **kwargs
312
- Additional parameters passed to :py:meth:`numpy.histogram2d`
355
+ Optional arguments passed to :py:meth:`numpy.histogram2d`
313
356
  and :py:meth:`matplotlib.axes.Axes.pcolormesh`.
314
357
 
315
358
  """
@@ -326,7 +369,10 @@ class PhasorPlot:
326
369
  if cmax is not None:
327
370
  h[h > cmax] = None
328
371
  self._ax.pcolormesh(xedges, yedges, h.T, **kwargs)
329
- self._reset_limits()
372
+
373
+ # TODO: create custom labels for pcolormesh?
374
+ # if 'label' in kwargs:
375
+ # self._labels = True
330
376
 
331
377
  def contour(
332
378
  self,
@@ -345,7 +391,7 @@ class PhasorPlot:
345
391
  Imaginary component of phasor coordinates.
346
392
  Must be of same shape as `real`.
347
393
  **kwargs
348
- Additional parameters passed to :py:func:`numpy.histogram2d`
394
+ Optional arguments passed to :py:func:`numpy.histogram2d`
349
395
  and :py:meth:`matplotlib.axes.Axes.contour`.
350
396
 
351
397
  """
@@ -359,7 +405,10 @@ class PhasorPlot:
359
405
  xedges = xedges[:-1] + ((xedges[1] - xedges[0]) / 2.0)
360
406
  yedges = yedges[:-1] + ((yedges[1] - yedges[0]) / 2.0)
361
407
  self._ax.contour(xedges, yedges, h.T, **kwargs)
362
- self._reset_limits()
408
+
409
+ # TODO: create custom labels for contour?
410
+ # if 'label' in kwargs:
411
+ # self._labels = True
363
412
 
364
413
  def imshow(
365
414
  self,
@@ -369,12 +418,14 @@ class PhasorPlot:
369
418
  ) -> None:
370
419
  """Plot an image, for example, a 2D histogram (not implemented).
371
420
 
421
+ This method is not yet implemented and raises NotImplementedError.
422
+
372
423
  Parameters
373
424
  ----------
374
425
  image : array_like
375
426
  Image to display.
376
427
  **kwargs
377
- Additional parameters passed to
428
+ Optional arguments passed to
378
429
  :py:meth:`matplotlib.axes.Axes.imshow`.
379
430
 
380
431
  """
@@ -409,7 +460,7 @@ class PhasorPlot:
409
460
  label_offset : float, optional
410
461
  Distance of text label to component coordinate.
411
462
  **kwargs
412
- Additional parameters passed to
463
+ Optional arguments passed to
413
464
  :py:class:`matplotlib.patches.Polygon`,
414
465
  :py:class:`matplotlib.lines.Line2D`, or
415
466
  :py:class:`matplotlib.axes.Axes.annotate`
@@ -452,14 +503,16 @@ class PhasorPlot:
452
503
  )
453
504
 
454
505
  if fraction is None:
506
+ linestyle = kwargs.pop('ls', GRID_LINESTYLE)
507
+ linewidth = kwargs.pop('lw', GRID_LINEWIDTH)
455
508
  update_kwargs(
456
509
  kwargs,
457
510
  edgecolor=GRID_COLOR if color is None else color,
458
- linestyle=GRID_LINESTYLE,
459
- linewidth=GRID_LINEWIDH,
511
+ linestyle=linestyle,
512
+ linewidth=linewidth,
460
513
  fill=GRID_FILL,
461
514
  )
462
- self._ax.add_patch(Polygon(numpy.vstack((real, imag)).T, **kwargs))
515
+ self._ax.add_patch(Polygon(numpy.vstack([real, imag]).T, **kwargs))
463
516
  if marker is not None:
464
517
  self._ax.plot(
465
518
  real,
@@ -470,18 +523,20 @@ class PhasorPlot:
470
523
  label=label_,
471
524
  )
472
525
  if label_ is not None:
473
- self._ax.legend()
526
+ self._labels = True
474
527
  return
475
528
 
476
529
  fraction = numpy.asarray(fraction)[indices]
530
+ linestyle = kwargs.pop('ls', GRID_LINESTYLE)
531
+ linewidth = kwargs.pop('lw', GRID_LINEWIDTH)
477
532
  update_kwargs(
478
533
  kwargs,
479
534
  color=GRID_COLOR if color is None else color,
480
- linestyle=GRID_LINESTYLE,
481
- linewidth=GRID_LINEWIDH,
535
+ linestyle=linestyle,
536
+ linewidth=linewidth,
482
537
  )
483
538
  center_re, center_im = numpy.average(
484
- numpy.vstack((real, imag)), axis=-1, weights=fraction
539
+ numpy.vstack([real, imag]), axis=-1, weights=fraction
485
540
  )
486
541
  for re, im in zip(real, imag):
487
542
  self._ax.add_line(
@@ -498,7 +553,7 @@ class PhasorPlot:
498
553
  label=label_,
499
554
  )
500
555
  if label_ is not None:
501
- self._ax.legend()
556
+ self._labels = True
502
557
 
503
558
  def line(
504
559
  self,
@@ -516,8 +571,7 @@ class PhasorPlot:
516
571
  imag : array_like, shape (n, )
517
572
  Imaginary components of line start and end coordinates.
518
573
  **kwargs
519
- Additional parameters passed to
520
- :py:class:`matplotlib.lines.Line2D`.
574
+ Optional arguments passed to :py:class:`matplotlib.lines.Line2D`.
521
575
 
522
576
  Returns
523
577
  -------
@@ -525,11 +579,10 @@ class PhasorPlot:
525
579
  List containing plotted line.
526
580
 
527
581
  """
582
+ linestyle = kwargs.pop('ls', GRID_LINESTYLE)
583
+ linewidth = kwargs.pop('lw', GRID_LINEWIDTH)
528
584
  update_kwargs(
529
- kwargs,
530
- color=GRID_COLOR,
531
- linestyle=GRID_LINESTYLE,
532
- linewidth=GRID_LINEWIDH,
585
+ kwargs, color=GRID_COLOR, linestyle=linestyle, linewidth=linewidth
533
586
  )
534
587
  return [self._ax.add_line(Line2D(real, imag, **kwargs))]
535
588
 
@@ -552,15 +605,16 @@ class PhasorPlot:
552
605
  radius : float
553
606
  Circle radius.
554
607
  **kwargs
555
- Additional parameters passed to
556
- :py:class:`matplotlib.patches.Circle`.
608
+ Optional arguments passed to :py:class:`matplotlib.patches.Circle`.
557
609
 
558
610
  """
611
+ linestyle = kwargs.pop('ls', GRID_LINESTYLE)
612
+ linewidth = kwargs.pop('lw', GRID_LINEWIDTH)
559
613
  update_kwargs(
560
614
  kwargs,
561
615
  color=GRID_COLOR,
562
- linestyle=GRID_LINESTYLE,
563
- linewidth=GRID_LINEWIDH,
616
+ linestyle=linestyle,
617
+ linewidth=linewidth,
564
618
  fill=GRID_FILL,
565
619
  )
566
620
  self._ax.add_patch(Circle((real, imag), radius, **kwargs))
@@ -576,7 +630,7 @@ class PhasorPlot:
576
630
  ) -> None:
577
631
  """Draw arrow between points.
578
632
 
579
- By default, draw a straight arrow with a `'-|>'` style, a mutation
633
+ By default, draw a straight arrow with a ``'-|>'`` style, a mutation
580
634
  scale of 20, and a miter join style.
581
635
 
582
636
  Parameters
@@ -589,7 +643,7 @@ class PhasorPlot:
589
643
  Angle in radians, controlling curvature of line between points.
590
644
  If None (default), draw a straight line.
591
645
  **kwargs
592
- Additional parameters passed to
646
+ Optional arguments passed to
593
647
  :py:class:`matplotlib.patches.FancyArrowPatch`.
594
648
 
595
649
  """
@@ -612,45 +666,57 @@ class PhasorPlot:
612
666
 
613
667
  def cursor(
614
668
  self,
615
- real: float,
616
- imag: float,
669
+ real: ArrayLike,
670
+ imag: ArrayLike,
671
+ real_limit: ArrayLike | None = None,
672
+ imag_limit: ArrayLike | None = None,
617
673
  /,
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,
674
+ *,
675
+ radius: ArrayLike | None = None,
676
+ radius_minor: ArrayLike | None = None,
677
+ angle: ArrayLike | Literal['phase', 'semicircle'] | str | None = None,
678
+ color: ArrayLike | None = None,
679
+ label: ArrayLike | None = None,
680
+ crosshair: bool = False,
681
+ polar: bool = False,
624
682
  **kwargs: Any,
625
683
  ) -> None:
626
- """Plot phase and modulation grid lines and arcs at phasor coordinates.
684
+ """Draw cursor(s) at phasor coordinates.
627
685
 
628
686
  Parameters
629
687
  ----------
630
- real : float
688
+ real : array_like
631
689
  Real component of phasor coordinate.
632
- imag : float
690
+ imag : array_like
633
691
  Imaginary component of phasor coordinate.
634
- real_limit : float, optional
692
+ real_limit : array_like, optional
635
693
  Real component of limiting phasor coordinate.
636
- imag_limit : float, optional
694
+ imag_limit : array_like, optional
637
695
  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
696
+ radius : array_like, optional
697
+ Radius of circular cursor.
698
+ radius_minor : array_like, optional
641
699
  Radius of elliptic cursor along semi-minor axis.
642
700
  By default, `radius_minor` is equal to `radius`, that is,
643
701
  the ellipse is circular.
644
- angle : float, optional
702
+ angle : array_like or {'phase', 'semicircle'}, optional
645
703
  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.
704
+ If None or 'phase', align the minor axis of the ellipse with
705
+ the closest tangent on the unit circle.
706
+ If 'semicircle', align the ellipse with the universal semicircle.
707
+ color : array_like, optional
708
+ Color of cursor.
709
+ label : array_like, optional
710
+ String label for cursor.
711
+ crosshair : bool, optional
712
+ If true, draw polar or Cartesian lines or arcs limited by radius.
713
+ Else, draw circle or ellipse (default).
714
+ Only applies if `radius` is provided.
715
+ polar : bool, optional
716
+ If true, draw phase line and modulation arc.
717
+ Else, draw Cartesian lines.
652
718
  **kwargs
653
- Additional parameters passed to
719
+ Optional arguments passed to
654
720
  :py:class:`matplotlib.lines.Line2D`,
655
721
  :py:class:`matplotlib.patches.Circle`,
656
722
  :py:class:`matplotlib.patches.Ellipse`, or
@@ -663,64 +729,83 @@ class PhasorPlot:
663
729
  """
664
730
  if real_limit is not None and imag_limit is not None:
665
731
  return self.polar_cursor(
666
- *phasor_to_polar_scalar(real, imag),
667
- *phasor_to_polar_scalar(real_limit, imag_limit),
732
+ *phasor_to_polar(real, imag),
733
+ *phasor_to_polar(real_limit, imag_limit),
668
734
  radius=radius,
669
735
  radius_minor=radius_minor,
670
736
  angle=angle,
671
- align_semicircle=align_semicircle,
737
+ color=color,
738
+ label=label,
739
+ crosshair=crosshair,
740
+ polar=polar,
672
741
  **kwargs,
673
742
  )
674
743
  return self.polar_cursor(
675
- *phasor_to_polar_scalar(real, imag),
744
+ *phasor_to_polar(real, imag),
676
745
  radius=radius,
677
746
  radius_minor=radius_minor,
678
747
  angle=angle,
679
- align_semicircle=align_semicircle,
680
- # _circle_only=True,
748
+ color=color,
749
+ label=label,
750
+ crosshair=crosshair,
751
+ polar=polar,
681
752
  **kwargs,
682
753
  )
683
754
 
684
755
  def polar_cursor(
685
756
  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,
757
+ phase: ArrayLike | None = None,
758
+ modulation: ArrayLike | None = None,
759
+ phase_limit: ArrayLike | None = None,
760
+ modulation_limit: ArrayLike | None = None,
761
+ *,
762
+ radius: ArrayLike | None = None,
763
+ radius_minor: ArrayLike | None = None,
764
+ angle: ArrayLike | Literal['phase', 'semicircle'] | str | None = None,
765
+ color: ArrayLike | None = None,
766
+ label: ArrayLike | None = None,
767
+ crosshair: bool = False,
768
+ polar: bool = True,
694
769
  **kwargs: Any,
695
770
  ) -> None:
696
- """Plot phase and modulation grid lines and arcs.
771
+ """Draw cursor(s) at polar coordinates.
697
772
 
698
773
  Parameters
699
774
  ----------
700
- phase : float, optional
775
+ phase : array_like, optional
701
776
  Angular component of polar coordinate in radians.
702
- modulation : float, optional
777
+ modulation : array_like, optional
703
778
  Radial component of polar coordinate.
704
- phase_limit : float, optional
779
+ phase_limit : array_like, optional
705
780
  Angular component of limiting polar coordinate (in radians).
706
- Modulation grid arcs are drawn between `phase` and `phase_limit`.
707
- modulation_limit : float, optional
781
+ Modulation arcs are drawn between `phase` and `phase_limit`
782
+ if `polar` is true.
783
+ modulation_limit : array_like, optional
708
784
  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
785
+ Phase lines are drawn from `modulation` to `modulation_limit`
786
+ if `polar` is true.
787
+ radius : array_like, optional
788
+ Radius of circular cursor.
789
+ radius_minor : array_like, optional
713
790
  Radius of elliptic cursor along semi-minor axis.
714
791
  By default, `radius_minor` is equal to `radius`, that is,
715
792
  the ellipse is circular.
716
- angle : float, optional
793
+ angle : array_like or {'phase', 'semicircle'}, optional
717
794
  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.
795
+ If None or 'phase', align the minor axis of the ellipse with
796
+ the closest tangent on the unit circle.
797
+ If 'semicircle', align the ellipse with the universal semicircle.
798
+ color : array_like, optional
799
+ Color of cursor.
800
+ label : array_like, optional
801
+ String label for cursor.
802
+ crosshair : bool, optional
803
+ If true, draw polar or Cartesian lines or arcs limited by radius.
804
+ Else, draw circle or ellipse (default).
805
+ Only applies if `radius` is provided.
806
+ polar : bool, optional
807
+ If true, draw phase line and modulation arc.
808
+ Else, draw Cartesian lines.
724
809
  **kwargs
725
810
  Additional parameters passed to
726
811
  :py:class:`matplotlib.lines.Line2D`,
@@ -733,42 +818,169 @@ class PhasorPlot:
733
818
  phasorpy.plot.PhasorPlot.cursor
734
819
 
735
820
  """
821
+ shape = None
822
+ if phase is not None:
823
+ phase = numpy.atleast_1d(phase)
824
+ if phase.ndim != 1:
825
+ raise ValueError(f'invalid {phase.ndim=} != 1')
826
+ shape = phase.shape
827
+ if modulation is not None:
828
+ if shape is not None:
829
+ modulation = numpy.broadcast_to(modulation, shape)
830
+ else:
831
+ modulation = numpy.atleast_1d(modulation)
832
+ if modulation.ndim != 1:
833
+ raise ValueError(f'invalid {modulation.ndim=} != 1')
834
+ shape = modulation.shape
835
+ if shape is None:
836
+ return
837
+
838
+ if phase_limit is not None:
839
+ phase_limit = numpy.broadcast_to(phase_limit, shape)
840
+ if modulation_limit is not None:
841
+ modulation_limit = numpy.broadcast_to(modulation_limit, shape)
842
+ if radius is not None:
843
+ radius = numpy.broadcast_to(radius, shape)
844
+ if radius_minor is not None:
845
+ radius_minor = numpy.broadcast_to(radius_minor, shape)
846
+ if angle is not None and not isinstance(angle, str):
847
+ angle = numpy.broadcast_to(angle, shape)
848
+ if label is not None:
849
+ label = numpy.broadcast_to(label, shape)
850
+ label = [str(c) for c in label]
851
+ if color is not None:
852
+ color = numpy.atleast_1d(color)
853
+ if color.dtype.kind == 'U':
854
+ color = numpy.broadcast_to(color, shape)
855
+ color = [str(c) for c in color]
856
+ else:
857
+ color = numpy.broadcast_to(color, (shape[0], color.shape[-1]))
858
+
859
+ for i in range(shape[0]):
860
+
861
+ if color is not None:
862
+ kwargs['color'] = color[i]
863
+ if label is not None:
864
+ kwargs['label'] = label[i]
865
+
866
+ self._cursor(
867
+ phase if phase is None else float(phase[i]),
868
+ modulation if modulation is None else float(modulation[i]),
869
+ phase_limit if phase_limit is None else float(phase_limit[i]),
870
+ (
871
+ modulation_limit
872
+ if modulation_limit is None
873
+ else float(modulation_limit[i])
874
+ ),
875
+ radius=radius if radius is None else float(radius[i]),
876
+ radius_minor=(
877
+ radius_minor
878
+ if radius_minor is None
879
+ else float(radius_minor[i])
880
+ ),
881
+ angle=(
882
+ angle
883
+ if (angle is None or isinstance(angle, str))
884
+ else float(angle[i])
885
+ ),
886
+ crosshair=crosshair,
887
+ polar=polar,
888
+ **kwargs,
889
+ )
890
+
891
+ def _cursor(
892
+ self,
893
+ phase: float | None = None,
894
+ modulation: float | None = None,
895
+ phase_limit: float | None = None,
896
+ modulation_limit: float | None = None,
897
+ *,
898
+ radius: float | None = None,
899
+ radius_minor: float | None = None,
900
+ angle: float | Literal['phase', 'semicircle'] | str | None = None,
901
+ crosshair: bool = False,
902
+ polar: bool = True,
903
+ **kwargs: Any,
904
+ ) -> None:
905
+ """Draw single cursor at polar coordinate."""
906
+ linestyle = kwargs.pop('ls', GRID_LINESTYLE_MAJOR)
907
+ linewidth = kwargs.pop('lw', GRID_LINEWIDTH)
736
908
  update_kwargs(
737
909
  kwargs,
738
910
  color=GRID_COLOR,
739
- linestyle=GRID_LINESTYLE,
740
- linewidth=GRID_LINEWIDH,
911
+ linestyle=linestyle,
912
+ linewidth=linewidth,
741
913
  fill=GRID_FILL,
914
+ zorder=GRID_ZORDER,
742
915
  )
743
- _circle_only = kwargs.pop('_circle_only', False)
916
+
744
917
  ax = self._ax
745
918
  if radius is not None and phase is not None and modulation is not None:
746
919
  x = modulation * math.cos(phase)
747
920
  y = modulation * math.sin(phase)
748
921
  if radius_minor is not None and radius_minor != radius:
749
922
  if angle is None:
750
- if align_semicircle:
923
+ angle = phase
924
+ elif isinstance(angle, str):
925
+ if angle == 'phase':
926
+ angle = phase
927
+ elif angle == 'semicircle':
751
928
  angle = math.atan2(y, x - 0.5)
752
929
  else:
753
- angle = phase
930
+ raise ValueError(f'invalid {angle=}')
754
931
  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,
932
+
933
+ if not crosshair:
934
+ # draw elliptical cursor
935
+ ax.add_patch(
936
+ Ellipse(
937
+ (x, y),
938
+ radius * 2,
939
+ radius_minor * 2,
940
+ angle=angle,
941
+ **kwargs,
942
+ )
762
943
  )
944
+ if 'label' in kwargs:
945
+ self._labels = True
946
+ return None
947
+
948
+ # TODO: implement crosshair intersecting with ellipse?
949
+ raise ValueError('crosshair not implemented with ellipse')
950
+
951
+ if not crosshair:
952
+ # draw circlar cursor
953
+ ax.add_patch(Circle((x, y), radius, **kwargs))
954
+ if 'label' in kwargs:
955
+ self._labels = True
956
+ return None
957
+
958
+ del kwargs['fill']
959
+ if not polar:
960
+ # draw Cartesian crosshair lines limited by radius
961
+ x0, y0, x1, y1 = _intersect_circle_line(
962
+ x, y, radius, x, y, x + 1, y
963
+ )
964
+ ax.add_line(Line2D([x0, x1], [y0, y1], **kwargs))
965
+ if 'label' in kwargs:
966
+ self._labels = True
967
+ del kwargs['label']
968
+ x0, y0, x1, y1 = _intersect_circle_line(
969
+ x, y, radius, x, y, x, y + 1
763
970
  )
764
- # TODO: implement gridlines intersecting with ellipse
971
+ ax.add_line(Line2D([x0, x1], [y0, y1], **kwargs))
765
972
  return None
766
- ax.add_patch(Circle((x, y), radius, **kwargs))
767
- if _circle_only:
973
+
974
+ if abs(x) < 1e-6 and abs(y) < 1e-6:
975
+ # phase and modulation not defined at origin
768
976
  return None
769
- del kwargs['fill']
977
+
978
+ # draw crosshair phase line and modulation arc limited by circle
770
979
  x0, y0, x1, y1 = _intersect_circle_line(x, y, radius, 0, 0, x, y)
771
- ax.add_line(Line2D((x0, x1), (y0, y1), **kwargs))
980
+ ax.add_line(Line2D([x0, x1], [y0, y1], **kwargs))
981
+ if 'label' in kwargs:
982
+ self._labels = True
983
+ del kwargs['label']
772
984
  x0, y0, x1, y1 = _intersect_circle_circle(
773
985
  0, 0, modulation, x, y, radius
774
986
  )
@@ -785,6 +997,30 @@ class PhasorPlot:
785
997
  )
786
998
  return None
787
999
 
1000
+ if not polar:
1001
+ if phase is None or modulation is None:
1002
+ return None
1003
+
1004
+ x0 = modulation * math.cos(phase)
1005
+ y0 = modulation * math.sin(phase)
1006
+ if phase_limit is None or modulation_limit is None:
1007
+ # draw Cartesian crosshair lines
1008
+ del kwargs['fill']
1009
+ ax.add_line(Line2D([x0, x0], [-2, 2], **kwargs))
1010
+ if 'label' in kwargs:
1011
+ self._labels = True
1012
+ del kwargs['label']
1013
+ ax.add_line(Line2D([-2, 2], [y0, y0], **kwargs))
1014
+ else:
1015
+ # draw rectangle
1016
+ x1 = modulation_limit * math.cos(phase_limit)
1017
+ y1 = modulation_limit * math.sin(phase_limit)
1018
+ ax.add_patch(Rectangle((x0, y0), x1 - x0, y1 - y0, **kwargs))
1019
+ if 'label' in kwargs:
1020
+ self._labels = True
1021
+ return None
1022
+
1023
+ # TODO: implement filled polar region/rectangle
788
1024
  del kwargs['fill']
789
1025
  for phi in (phase, phase_limit):
790
1026
  if phi is not None:
@@ -796,9 +1032,12 @@ class PhasorPlot:
796
1032
  else:
797
1033
  x0 = 0
798
1034
  y0 = 0
799
- x1 = math.cos(phi)
800
- y1 = math.sin(phi)
801
- ax.add_line(Line2D((x0, x1), (y0, y1), **kwargs))
1035
+ x1 = math.cos(phi) * 2
1036
+ y1 = math.sin(phi) * 2
1037
+ ax.add_line(Line2D([x0, x1], [y0, y1], **kwargs))
1038
+ if 'label' in kwargs:
1039
+ self._labels = True
1040
+ del kwargs['label']
802
1041
  for mod in (modulation, modulation_limit):
803
1042
  if mod is not None:
804
1043
  if phase is not None and phase_limit is not None:
@@ -806,7 +1045,8 @@ class PhasorPlot:
806
1045
  theta2 = math.degrees(max(phase, phase_limit))
807
1046
  else:
808
1047
  theta1 = 0.0
809
- theta2 = 360.0 if self._full else 90.0
1048
+ theta2 = 360.0 # if self._full else 90.0
1049
+ # TODO: filling arc objects is not supported
810
1050
  ax.add_patch(
811
1051
  Arc(
812
1052
  (0, 0),
@@ -814,51 +1054,176 @@ class PhasorPlot:
814
1054
  mod * 2,
815
1055
  theta1=theta1,
816
1056
  theta2=theta2,
817
- fill=False, # filling arc objects is not supported
1057
+ fill=False,
818
1058
  **kwargs,
819
1059
  )
820
1060
  )
1061
+ if 'label' in kwargs:
1062
+ self._labels = True
1063
+ del kwargs['label']
821
1064
  return None
822
1065
 
823
- def polar_grid(self, **kwargs: Any) -> None:
824
- """Draw polar coordinate system.
1066
+ def polar_grid(
1067
+ self,
1068
+ radii: int | Sequence[float] | None = None,
1069
+ angles: int | Sequence[float] | None = None,
1070
+ samples: int | None = None,
1071
+ labels: Sequence[str] | None = None,
1072
+ ticks: ArrayLike | None = None,
1073
+ tick_space: ArrayLike | None = None,
1074
+ tick_format: str | None = None,
1075
+ **kwargs: Any,
1076
+ ) -> None:
1077
+ r"""Draw polar coordinate system.
825
1078
 
826
1079
  Parameters
827
1080
  ----------
1081
+ radii : int or sequence of float, optional
1082
+ Position of radial gridlines in range (0, 1].
1083
+ If an integer, the number of equidistant radial gridlines.
1084
+ By default, three equidistant radial gridlines are drawn.
1085
+ The unit circle (radius 1), if included, is drawn in major style.
1086
+ angles : int or sequence of float, optional
1087
+ Position of angular gridlines in range [0, 2 pi].
1088
+ If an integer, the number of equidistant angular gridlines.
1089
+ By default, 12 equidistant angular gridlines are drawn.
1090
+ samples : int, optional
1091
+ Number of vertices of polygon inscribed in unit circle.
1092
+ By default, no inscribed polygon is drawn.
1093
+ labels : sequence of str, optional
1094
+ Tick labels on unit circle.
1095
+ Labels are placed at equidistant angles if `ticks` are not
1096
+ provided.
1097
+ ticks : array_like, optional
1098
+ Values at which to place tick labels on unit circle.
1099
+ If `labels` are not provided, `ticks` values formatted with
1100
+ `tick_format` are used as labels.
1101
+ If `tick_space` is not provided, tick values are angles in radians.
1102
+ tick_space : array_like, optional
1103
+ Values used to convert `ticks` to angles.
1104
+ For example, the wavelengths used to calculate spectral phasors
1105
+ or the minimum and maximum wavelengths of a sine-cosine filter.
1106
+ tick_format : str, optional
1107
+ Format string for tick values if `labels` is None.
1108
+ By default, the tick format is "{}".
828
1109
  **kwargs
829
- Parameters passed to
1110
+ Optional arguments passed to
830
1111
  :py:class:`matplotlib.patches.Circle` and
831
1112
  :py:class:`matplotlib.lines.Line2D`.
832
1113
 
1114
+ Raises
1115
+ ------
1116
+ ValueError
1117
+ If number of ticks doesn't match number of labels.
1118
+ If `tick_space` has less than two values.
1119
+
1120
+ Notes
1121
+ -----
1122
+ Use ``radii=1, angles=4`` to draw major gridlines only.
1123
+
1124
+ The values of ticks (:math:`v`) are converted to angles
1125
+ (:math:`\theta`) using `tick_space` (:math:`s`) according to:
1126
+
1127
+ .. math::
1128
+ \theta = \frac{v - s_0}{s_{-1} + s_1 - 2 s_0} \cdot 2 \pi
1129
+
833
1130
  """
834
1131
  ax = self._ax
835
- # major gridlines
836
- kwargs_copy = kwargs.copy()
1132
+ minor_kwargs = kwargs.copy()
1133
+ linestyle = minor_kwargs.pop('ls', GRID_LINESTYLE)
1134
+ linewidth = minor_kwargs.pop('lw', GRID_LINEWIDTH_MINOR)
837
1135
  update_kwargs(
838
- kwargs,
1136
+ minor_kwargs,
839
1137
  color=GRID_COLOR,
840
- linestyle=GRID_LINESTYLE_MAJOR,
841
- linewidth=GRID_LINEWIDH,
842
- # fill=GRID_FILL,
1138
+ linestyle=linestyle,
1139
+ linewidth=linewidth,
1140
+ zorder=GRID_ZORDER,
843
1141
  )
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
1142
+ linestyle = kwargs.pop('ls', GRID_LINESTYLE_MAJOR)
1143
+ linewidth = kwargs.pop('lw', GRID_LINEWIDTH)
849
1144
  update_kwargs(
850
1145
  kwargs,
851
1146
  color=GRID_COLOR,
852
- linestyle=GRID_LINESTYLE,
853
- linewidth=GRID_LINEWIDH_MINOR,
1147
+ linestyle=linestyle,
1148
+ linewidth=linewidth,
1149
+ zorder=GRID_ZORDER,
1150
+ # fill=GRID_FILL,
854
1151
  )
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))
1152
+
1153
+ if samples is not None and samples > 1:
1154
+ angle = numpy.linspace(0, 2 * math.pi, samples, endpoint=False)
1155
+ xy = numpy.vstack([numpy.cos(angle), numpy.sin(angle)]).T
1156
+ ax.add_patch(Polygon(xy, fill=False, **kwargs))
1157
+
1158
+ if radii is None:
1159
+ radii = [1 / 3, 2 / 3, 1.0]
1160
+ elif isinstance(radii, int):
1161
+ radii = numpy.linspace(0, 1, radii + 1, endpoint=True)[1:].tolist()
1162
+ for r in radii: # type: ignore[union-attr]
1163
+ if r < 1e-3:
1164
+ # skip zero radius
1165
+ continue
1166
+ if abs(r - 1.0) < 1e-3:
1167
+ # unit circle
1168
+ circle = Circle((0, 0), 1, fill=False, **kwargs)
1169
+ elif r > 1.0:
1170
+ continue
1171
+ else:
1172
+ # minor circle
1173
+ circle = Circle((0, 0), r, fill=False, **minor_kwargs)
1174
+ ax.add_patch(circle)
1175
+
1176
+ if angles is None:
1177
+ angles = 12
1178
+ if isinstance(angles, int):
1179
+ angles = numpy.linspace(
1180
+ 0, 2 * math.pi, angles, endpoint=False
1181
+ ).tolist()
1182
+ for a in angles: # type: ignore[union-attr]
1183
+ if a < 0 or a > 2 * math.pi:
1184
+ # skip angles out of range
1185
+ continue
1186
+ x = math.cos(a)
1187
+ y = math.sin(a)
1188
+ ax.add_line(Line2D([0.0, x], [0.0, y], **minor_kwargs))
1189
+
1190
+ if labels is None and ticks is None:
1191
+ # no labels
1192
+ return
1193
+ if ticks is None:
1194
+ # equidistant labels
1195
+ assert labels is not None
1196
+ ticks = numpy.linspace(0, 2 * math.pi, len(labels), endpoint=False)
1197
+ tick_space = None
1198
+ elif labels is None:
1199
+ # use tick values as labels
1200
+ assert ticks is not None
1201
+ ticks = numpy.array(ticks, ndmin=1, copy=True)
1202
+ if tick_format is None:
1203
+ tick_format = '{}'
1204
+ labels = [tick_format.format(t) for t in ticks]
1205
+ ticks = ticks.astype(numpy.float64)
1206
+ else:
1207
+ # ticks and labels
1208
+ ticks = numpy.array(ticks, dtype=numpy.float64, ndmin=1, copy=True)
1209
+ if ticks.size != len(labels):
1210
+ raise ValueError(f'{ticks.size=} != {len(labels)=}')
1211
+
1212
+ if tick_space is not None:
1213
+ tick_space = numpy.asarray(tick_space, dtype=numpy.float64)
1214
+ if tick_space.ndim != 1 or tick_space.size < 2:
1215
+ raise ValueError(
1216
+ f'invalid {tick_space.ndim=} or {tick_space.size=} < 2'
1217
+ )
1218
+ assert isinstance(ticks, numpy.ndarray) # for mypy
1219
+ ticks -= tick_space[0]
1220
+ ticks /= tick_space[-1] + tick_space[1] - 2 * tick_space[0]
1221
+ ticks *= 2 * math.pi
1222
+
1223
+ real = numpy.cos(ticks)
1224
+ imag = numpy.sin(ticks)
1225
+ self._unitcircle_ticks = CircleTicks(labels=labels)
1226
+ ax.plot(real, imag, path_effects=[self._unitcircle_ticks], **kwargs)
862
1227
 
863
1228
  def semicircle(
864
1229
  self,
@@ -894,26 +1259,32 @@ class PhasorPlot:
894
1259
  use_lines : bool, optional, default: False
895
1260
  Draw universal semicircle using lines instead of arc.
896
1261
  **kwargs
897
- Additional parameters passed to
1262
+ Optional arguments passed to
898
1263
  :py:class:`matplotlib.lines.Line2D` or
899
1264
  :py:class:`matplotlib.patches.Arc` and
900
1265
  :py:meth:`matplotlib.axes.Axes.plot`.
901
1266
 
902
1267
  Returns
903
1268
  -------
904
- list[matplotlib.lines.Line2D]
1269
+ list of matplotlib.lines.Line2D
905
1270
  Lines representing plotted semicircle and ticks.
906
1271
 
907
1272
  """
908
1273
  if frequency is not None:
909
1274
  self._frequency = float(frequency)
910
1275
 
1276
+ linestyle = kwargs.pop('ls', GRID_LINESTYLE_MAJOR)
1277
+ linewidth = kwargs.pop('lw', GRID_LINEWIDTH)
911
1278
  update_kwargs(
912
1279
  kwargs,
1280
+ linestyle=linestyle,
1281
+ linewidth=linewidth,
913
1282
  color=GRID_COLOR,
914
- linestyle=GRID_LINESTYLE_MAJOR,
915
- linewidth=GRID_LINEWIDH,
1283
+ zorder=GRID_ZORDER,
916
1284
  )
1285
+ if 'label' in kwargs:
1286
+ self._labels = True
1287
+
917
1288
  if phasor_reference is not None:
918
1289
  polar_reference = phasor_to_polar_scalar(*phasor_reference)
919
1290
  if polar_reference is None:
@@ -949,10 +1320,13 @@ class PhasorPlot:
949
1320
  )
950
1321
  )
951
1322
 
1323
+ kwargs.pop('label', None) # don't pass label to ticks
1324
+ kwargs.pop('capstyle', None)
1325
+
952
1326
  if frequency is not None and polar_reference == (0.0, 1.0):
953
1327
  # draw ticks and labels
954
1328
  lifetime, labels = _semicircle_ticks(frequency, lifetime, labels)
955
- self._semicircle_ticks = SemicircleTicks(labels=labels)
1329
+ self._semicircle_ticks = CircleTicks((0.5, 0.0), labels=labels)
956
1330
  lines.extend(
957
1331
  ax.plot(
958
1332
  *phasor_transform(
@@ -963,7 +1337,6 @@ class PhasorPlot:
963
1337
  **kwargs,
964
1338
  )
965
1339
  )
966
- self._reset_limits()
967
1340
  return lines
968
1341
 
969
1342
  def _on_format_coord(self, x: float, y: float) -> str:
@@ -979,39 +1352,49 @@ class PhasorPlot:
979
1352
  return ' '.join(reversed(ret))
980
1353
 
981
1354
 
982
- class SemicircleTicks(AbstractPathEffect):
983
- """Draw ticks on universal semicircle.
1355
+ class CircleTicks(AbstractPathEffect):
1356
+ """Draw ticks on unit circle or universal semicircle.
984
1357
 
985
1358
  Parameters
986
1359
  ----------
1360
+ origin : (float, float), optional
1361
+ Origin of circle.
987
1362
  size : float, optional
988
1363
  Length of tick in dots.
989
1364
  The default is ``rcParams['xtick.major.size']``.
990
1365
  labels : sequence of str, optional
991
1366
  Tick labels for each vertex in path.
992
1367
  **kwargs
993
- Extra keywords passed to matplotlib's
1368
+ Optional arguments passed to
994
1369
  :py:meth:`matplotlib.patheffects.AbstractPathEffect._update_gc`.
995
1370
 
996
1371
  """
997
1372
 
1373
+ _origin: tuple[float, float] # origin of circle
998
1374
  _size: float # tick length
999
1375
  _labels: tuple[str, ...] # tick labels
1000
1376
  _gc: dict[str, Any] # keywords passed to _update_gc
1001
1377
 
1002
1378
  def __init__(
1003
1379
  self,
1380
+ origin: tuple[float, float] | None = None,
1381
+ /,
1004
1382
  size: float | None = None,
1005
1383
  labels: Sequence[str] | None = None,
1006
1384
  **kwargs: Any,
1007
1385
  ) -> None:
1008
1386
  super().__init__((0.0, 0.0))
1009
1387
 
1388
+ if origin is None:
1389
+ self._origin = 0.0, 0.0
1390
+ else:
1391
+ self._origin = float(origin[0]), float(origin[1])
1392
+
1010
1393
  if size is None:
1011
1394
  self._size = pyplot.rcParams['xtick.major.size']
1012
1395
  else:
1013
1396
  self._size = size
1014
- if labels is None or not labels:
1397
+ if labels is None or len(labels) == 0:
1015
1398
  self._labels = ()
1016
1399
  else:
1017
1400
  self._labels = tuple(labels)
@@ -1024,7 +1407,7 @@ class SemicircleTicks(AbstractPathEffect):
1024
1407
 
1025
1408
  @labels.setter
1026
1409
  def labels(self, value: Sequence[str] | None, /) -> None:
1027
- if value is None or not value:
1410
+ if value is None:
1028
1411
  self._labels = ()
1029
1412
  else:
1030
1413
  self._labels = tuple(value)
@@ -1050,7 +1433,7 @@ class SemicircleTicks(AbstractPathEffect):
1050
1433
  # approximate half size of 'x'
1051
1434
  fontsize = renderer.points_to_pixels(font.get_size_in_points()) / 4
1052
1435
  size = renderer.points_to_pixels(self._size)
1053
- origin = affine.transform([[0.5, 0.0]])
1436
+ origin = affine.transform((self._origin,))
1054
1437
 
1055
1438
  transpath = affine.transform_path(tpath)
1056
1439
  polys = transpath.to_polygons(closed_only=False)
@@ -1088,6 +1471,8 @@ class SemicircleTicks(AbstractPathEffect):
1088
1471
  # TODO: get rendered text size from matplotlib.text.Text?
1089
1472
  # this did not work:
1090
1473
  # Text(d[i,0], h - d[i,1], label, ha='center', va='center')
1474
+ if not s:
1475
+ continue
1091
1476
  x = x + fontsize * len(s.split()[0]) * (dx - 1.0)
1092
1477
  y = h - y + fontsize
1093
1478
  renderer.draw_text(gc0, x, y, s, font, 0.0)