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