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.
- phasorpy/__init__.py +1 -1
- phasorpy/_phasorpy.cp313-win_amd64.pyd +0 -0
- phasorpy/_phasorpy.pyx +320 -10
- phasorpy/_utils.py +114 -33
- phasorpy/cli.py +19 -1
- phasorpy/cluster.py +12 -18
- phasorpy/color.py +11 -7
- phasorpy/{components.py → component.py} +263 -36
- phasorpy/{cursors.py → cursor.py} +31 -33
- phasorpy/datasets.py +118 -8
- phasorpy/experimental.py +4 -168
- phasorpy/filter.py +966 -0
- phasorpy/io/__init__.py +3 -1
- phasorpy/io/_flimlabs.py +26 -16
- phasorpy/io/_leica.py +38 -34
- phasorpy/io/_ometiff.py +10 -9
- phasorpy/io/_other.py +116 -8
- phasorpy/io/_simfcs.py +52 -24
- phasorpy/lifetime.py +2058 -0
- phasorpy/phasor.py +106 -2502
- phasorpy/plot/_functions.py +13 -7
- phasorpy/plot/_lifetime_plots.py +34 -24
- phasorpy/plot/_phasorplot.py +561 -176
- phasorpy/plot/_phasorplot_fret.py +12 -10
- phasorpy/utils.py +22 -10
- {phasorpy-0.6.dist-info → phasorpy-0.8.dist-info}/METADATA +8 -7
- phasorpy-0.8.dist-info/RECORD +36 -0
- phasorpy-0.6.dist-info/RECORD +0 -34
- {phasorpy-0.6.dist-info → phasorpy-0.8.dist-info}/WHEEL +0 -0
- {phasorpy-0.6.dist-info → phasorpy-0.8.dist-info}/entry_points.txt +0 -0
- {phasorpy-0.6.dist-info → phasorpy-0.8.dist-info}/licenses/LICENSE.txt +0 -0
- {phasorpy-0.6.dist-info → phasorpy-0.8.dist-info}/top_level.txt +0 -0
phasorpy/plot/_phasorplot.py
CHANGED
@@ -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
|
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 ..
|
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
|
-
|
46
|
-
|
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
|
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
|
-
|
66
|
-
|
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
|
-
|
87
|
-
"""
|
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 =
|
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
|
-
|
113
|
-
|
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
|
-
|
120
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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.
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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=
|
459
|
-
linewidth=
|
511
|
+
linestyle=linestyle,
|
512
|
+
linewidth=linewidth,
|
460
513
|
fill=GRID_FILL,
|
461
514
|
)
|
462
|
-
self._ax.add_patch(Polygon(numpy.vstack(
|
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.
|
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=
|
481
|
-
linewidth=
|
535
|
+
linestyle=linestyle,
|
536
|
+
linewidth=linewidth,
|
482
537
|
)
|
483
538
|
center_re, center_im = numpy.average(
|
484
|
-
numpy.vstack(
|
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.
|
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
|
-
|
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
|
-
|
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=
|
563
|
-
linewidth=
|
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
|
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
|
-
|
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:
|
616
|
-
imag:
|
669
|
+
real: ArrayLike,
|
670
|
+
imag: ArrayLike,
|
671
|
+
real_limit: ArrayLike | None = None,
|
672
|
+
imag_limit: ArrayLike | None = None,
|
617
673
|
/,
|
618
|
-
|
619
|
-
|
620
|
-
|
621
|
-
|
622
|
-
|
623
|
-
|
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
|
-
"""
|
684
|
+
"""Draw cursor(s) at phasor coordinates.
|
627
685
|
|
628
686
|
Parameters
|
629
687
|
----------
|
630
|
-
real :
|
688
|
+
real : array_like
|
631
689
|
Real component of phasor coordinate.
|
632
|
-
imag :
|
690
|
+
imag : array_like
|
633
691
|
Imaginary component of phasor coordinate.
|
634
|
-
real_limit :
|
692
|
+
real_limit : array_like, optional
|
635
693
|
Real component of limiting phasor coordinate.
|
636
|
-
imag_limit :
|
694
|
+
imag_limit : array_like, optional
|
637
695
|
Imaginary component of limiting phasor coordinate.
|
638
|
-
radius :
|
639
|
-
Radius of
|
640
|
-
radius_minor :
|
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 :
|
702
|
+
angle : array_like or {'phase', 'semicircle'}, optional
|
645
703
|
Rotation angle of semi-major axis of elliptic cursor in radians.
|
646
|
-
If None
|
647
|
-
|
648
|
-
|
649
|
-
|
650
|
-
|
651
|
-
|
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
|
-
|
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
|
-
*
|
667
|
-
*
|
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
|
-
|
737
|
+
color=color,
|
738
|
+
label=label,
|
739
|
+
crosshair=crosshair,
|
740
|
+
polar=polar,
|
672
741
|
**kwargs,
|
673
742
|
)
|
674
743
|
return self.polar_cursor(
|
675
|
-
*
|
744
|
+
*phasor_to_polar(real, imag),
|
676
745
|
radius=radius,
|
677
746
|
radius_minor=radius_minor,
|
678
747
|
angle=angle,
|
679
|
-
|
680
|
-
|
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:
|
687
|
-
modulation:
|
688
|
-
phase_limit:
|
689
|
-
modulation_limit:
|
690
|
-
|
691
|
-
|
692
|
-
|
693
|
-
|
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
|
-
"""
|
771
|
+
"""Draw cursor(s) at polar coordinates.
|
697
772
|
|
698
773
|
Parameters
|
699
774
|
----------
|
700
|
-
phase :
|
775
|
+
phase : array_like, optional
|
701
776
|
Angular component of polar coordinate in radians.
|
702
|
-
modulation :
|
777
|
+
modulation : array_like, optional
|
703
778
|
Radial component of polar coordinate.
|
704
|
-
phase_limit :
|
779
|
+
phase_limit : array_like, optional
|
705
780
|
Angular component of limiting polar coordinate (in radians).
|
706
|
-
Modulation
|
707
|
-
|
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
|
710
|
-
|
711
|
-
|
712
|
-
|
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 :
|
793
|
+
angle : array_like or {'phase', 'semicircle'}, optional
|
717
794
|
Rotation angle of semi-major axis of elliptic cursor in radians.
|
718
|
-
If None
|
719
|
-
|
720
|
-
|
721
|
-
|
722
|
-
|
723
|
-
|
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=
|
740
|
-
linewidth=
|
911
|
+
linestyle=linestyle,
|
912
|
+
linewidth=linewidth,
|
741
913
|
fill=GRID_FILL,
|
914
|
+
zorder=GRID_ZORDER,
|
742
915
|
)
|
743
|
-
|
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
|
-
|
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
|
930
|
+
raise ValueError(f'invalid {angle=}')
|
754
931
|
angle = math.degrees(angle)
|
755
|
-
|
756
|
-
|
757
|
-
|
758
|
-
|
759
|
-
|
760
|
-
|
761
|
-
|
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
|
-
|
971
|
+
ax.add_line(Line2D([x0, x1], [y0, y1], **kwargs))
|
765
972
|
return None
|
766
|
-
|
767
|
-
if
|
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
|
-
|
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(
|
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(
|
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,
|
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(
|
824
|
-
|
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
|
-
|
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
|
-
|
836
|
-
|
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
|
-
|
1136
|
+
minor_kwargs,
|
839
1137
|
color=GRID_COLOR,
|
840
|
-
linestyle=
|
841
|
-
linewidth=
|
842
|
-
|
1138
|
+
linestyle=linestyle,
|
1139
|
+
linewidth=linewidth,
|
1140
|
+
zorder=GRID_ZORDER,
|
843
1141
|
)
|
844
|
-
|
845
|
-
|
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=
|
853
|
-
linewidth=
|
1147
|
+
linestyle=linestyle,
|
1148
|
+
linewidth=linewidth,
|
1149
|
+
zorder=GRID_ZORDER,
|
1150
|
+
# fill=GRID_FILL,
|
854
1151
|
)
|
855
|
-
|
856
|
-
|
857
|
-
|
858
|
-
|
859
|
-
|
860
|
-
|
861
|
-
|
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
|
-
|
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
|
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
|
-
|
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 =
|
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
|
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
|
-
|
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
|
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
|
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(
|
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)
|