phasorpy 0.1__cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- phasorpy/__init__.py +10 -0
- phasorpy/__main__.py +7 -0
- phasorpy/_phasorpy.cpython-312-aarch64-linux-gnu.so +0 -0
- phasorpy/_phasorpy.pyx +1811 -0
- phasorpy/_typing.py +77 -0
- phasorpy/_utils.py +441 -0
- phasorpy/cli.py +87 -0
- phasorpy/color.py +581 -0
- phasorpy/components.py +313 -0
- phasorpy/conftest.py +36 -0
- phasorpy/cursors.py +502 -0
- phasorpy/datasets.py +433 -0
- phasorpy/io.py +1671 -0
- phasorpy/phasor.py +3135 -0
- phasorpy/plot.py +2074 -0
- phasorpy/py.typed +0 -0
- phasorpy/utils.py +68 -0
- phasorpy/version.py +71 -0
- phasorpy-0.1.dist-info/LICENSE.txt +21 -0
- phasorpy-0.1.dist-info/METADATA +78 -0
- phasorpy-0.1.dist-info/RECORD +25 -0
- phasorpy-0.1.dist-info/WHEEL +6 -0
- phasorpy-0.1.dist-info/entry_points.txt +2 -0
- phasorpy-0.1.dist-info/top_level.txt +1 -0
- phasorpy.libs/libgomp-d22c30c5.so.1.0.0 +0 -0
phasorpy/phasor.py
ADDED
@@ -0,0 +1,3135 @@
|
|
1
|
+
"""Calculate, convert, calibrate, and reduce phasor coordinates.
|
2
|
+
|
3
|
+
The ``phasorpy.phasor`` module provides functions to:
|
4
|
+
|
5
|
+
- calculate phasor coordinates from time-resolved and spectral signals:
|
6
|
+
|
7
|
+
- :py:func:`phasor_from_signal`
|
8
|
+
|
9
|
+
- synthesize signals from phasor coordinates or lifetimes:
|
10
|
+
|
11
|
+
- :py:func:`phasor_to_signal`
|
12
|
+
- :py:func:`lifetime_to_signal`
|
13
|
+
|
14
|
+
- convert between phasor coordinates and single- or multi-component
|
15
|
+
fluorescence lifetimes:
|
16
|
+
|
17
|
+
- :py:func:`phasor_from_lifetime`
|
18
|
+
- :py:func:`phasor_from_apparent_lifetime`
|
19
|
+
- :py:func:`phasor_to_apparent_lifetime`
|
20
|
+
|
21
|
+
- convert to and from polar coordinates (phase and modulation):
|
22
|
+
|
23
|
+
- :py:func:`phasor_from_polar`
|
24
|
+
- :py:func:`phasor_to_polar`
|
25
|
+
- :py:func:`polar_from_apparent_lifetime`
|
26
|
+
- :py:func:`polar_to_apparent_lifetime`
|
27
|
+
|
28
|
+
- transform phasor coordinates:
|
29
|
+
|
30
|
+
- :py:func:`phasor_transform`
|
31
|
+
- :py:func:`phasor_multiply`
|
32
|
+
- :py:func:`phasor_divide`
|
33
|
+
|
34
|
+
- calibrate phasor coordinates with reference of known fluorescence
|
35
|
+
lifetime:
|
36
|
+
|
37
|
+
- :py:func:`phasor_calibrate`
|
38
|
+
- :py:func:`polar_from_reference`
|
39
|
+
- :py:func:`polar_from_reference_phasor`
|
40
|
+
|
41
|
+
- reduce dimensionality of arrays of phasor coordinates:
|
42
|
+
|
43
|
+
- :py:func:`phasor_center`
|
44
|
+
- :py:func:`phasor_to_principal_plane`
|
45
|
+
|
46
|
+
- calculate phasor coordinates for FRET donor and acceptor channels:
|
47
|
+
|
48
|
+
- :py:func:`phasor_from_fret_donor`
|
49
|
+
- :py:func:`phasor_from_fret_acceptor`
|
50
|
+
|
51
|
+
- convert between single component lifetimes and optimal frequency:
|
52
|
+
|
53
|
+
- :py:func:`lifetime_to_frequency`
|
54
|
+
- :py:func:`lifetime_from_frequency`
|
55
|
+
|
56
|
+
- convert between fractional intensities and pre-exponential amplitudes:
|
57
|
+
|
58
|
+
- :py:func:`lifetime_fraction_from_amplitude`
|
59
|
+
- :py:func:`lifetime_fraction_to_amplitude`
|
60
|
+
|
61
|
+
- calculate phasor coordinates on semicircle at other harmonics:
|
62
|
+
|
63
|
+
- :py:func:`phasor_at_harmonic`
|
64
|
+
|
65
|
+
- filter phasor coordinates:
|
66
|
+
|
67
|
+
- :py:func:`phasor_filter`
|
68
|
+
- :py:func:`phasor_threshold`
|
69
|
+
|
70
|
+
"""
|
71
|
+
|
72
|
+
from __future__ import annotations
|
73
|
+
|
74
|
+
__all__ = [
|
75
|
+
'lifetime_fraction_from_amplitude',
|
76
|
+
'lifetime_fraction_to_amplitude',
|
77
|
+
'lifetime_from_frequency',
|
78
|
+
'lifetime_to_frequency',
|
79
|
+
'lifetime_to_signal',
|
80
|
+
'phasor_at_harmonic',
|
81
|
+
'phasor_calibrate',
|
82
|
+
'phasor_center',
|
83
|
+
'phasor_divide',
|
84
|
+
'phasor_filter',
|
85
|
+
'phasor_from_apparent_lifetime',
|
86
|
+
'phasor_from_fret_acceptor',
|
87
|
+
'phasor_from_fret_donor',
|
88
|
+
'phasor_from_lifetime',
|
89
|
+
'phasor_from_polar',
|
90
|
+
'phasor_from_signal',
|
91
|
+
'phasor_multiply',
|
92
|
+
'phasor_semicircle',
|
93
|
+
'phasor_threshold',
|
94
|
+
'phasor_to_apparent_lifetime',
|
95
|
+
'phasor_to_complex',
|
96
|
+
'phasor_to_polar',
|
97
|
+
'phasor_to_principal_plane',
|
98
|
+
'phasor_to_signal',
|
99
|
+
'phasor_transform',
|
100
|
+
'polar_from_apparent_lifetime',
|
101
|
+
'polar_from_reference',
|
102
|
+
'polar_from_reference_phasor',
|
103
|
+
'polar_to_apparent_lifetime',
|
104
|
+
]
|
105
|
+
|
106
|
+
import math
|
107
|
+
import numbers
|
108
|
+
from collections.abc import Sequence
|
109
|
+
from typing import TYPE_CHECKING
|
110
|
+
|
111
|
+
if TYPE_CHECKING:
|
112
|
+
from ._typing import (
|
113
|
+
Any,
|
114
|
+
NDArray,
|
115
|
+
ArrayLike,
|
116
|
+
DTypeLike,
|
117
|
+
Callable,
|
118
|
+
Literal,
|
119
|
+
)
|
120
|
+
|
121
|
+
import numpy
|
122
|
+
|
123
|
+
from ._phasorpy import (
|
124
|
+
_gaussian_signal,
|
125
|
+
_phasor_at_harmonic,
|
126
|
+
_phasor_divide,
|
127
|
+
_phasor_from_apparent_lifetime,
|
128
|
+
_phasor_from_fret_acceptor,
|
129
|
+
_phasor_from_fret_donor,
|
130
|
+
_phasor_from_lifetime,
|
131
|
+
_phasor_from_polar,
|
132
|
+
_phasor_from_signal,
|
133
|
+
_phasor_from_single_lifetime,
|
134
|
+
_phasor_multiply,
|
135
|
+
_phasor_threshold_closed,
|
136
|
+
_phasor_threshold_mean_closed,
|
137
|
+
_phasor_threshold_mean_open,
|
138
|
+
_phasor_threshold_nan,
|
139
|
+
_phasor_threshold_open,
|
140
|
+
_phasor_to_apparent_lifetime,
|
141
|
+
_phasor_to_polar,
|
142
|
+
_phasor_transform,
|
143
|
+
_phasor_transform_const,
|
144
|
+
_polar_from_apparent_lifetime,
|
145
|
+
_polar_from_reference,
|
146
|
+
_polar_from_reference_phasor,
|
147
|
+
_polar_from_single_lifetime,
|
148
|
+
_polar_to_apparent_lifetime,
|
149
|
+
)
|
150
|
+
from ._utils import parse_harmonic
|
151
|
+
from .utils import number_threads
|
152
|
+
|
153
|
+
|
154
|
+
def phasor_from_signal(
|
155
|
+
signal: ArrayLike,
|
156
|
+
/,
|
157
|
+
*,
|
158
|
+
axis: int = -1,
|
159
|
+
harmonic: int | Sequence[int] | Literal['all'] | str | None = None,
|
160
|
+
sample_phase: ArrayLike | None = None,
|
161
|
+
use_fft: bool | None = None,
|
162
|
+
rfft: Callable[..., NDArray[Any]] | None = None,
|
163
|
+
dtype: DTypeLike = None,
|
164
|
+
num_threads: int | None = None,
|
165
|
+
) -> tuple[NDArray[Any], NDArray[Any], NDArray[Any]]:
|
166
|
+
r"""Return phasor coordinates from signal.
|
167
|
+
|
168
|
+
Parameters
|
169
|
+
----------
|
170
|
+
signal : array_like
|
171
|
+
Frequency-domain, time-domain, or hyperspectral data.
|
172
|
+
A minimum of three samples are required along `axis`.
|
173
|
+
The samples must be uniformly spaced.
|
174
|
+
axis : int, optional
|
175
|
+
Axis over which to compute phasor coordinates.
|
176
|
+
The default is the last axis (-1).
|
177
|
+
harmonic : int, sequence of int, or 'all', optional
|
178
|
+
Harmonics to return.
|
179
|
+
If `'all'`, return all harmonics for `signal` samples along `axis`.
|
180
|
+
Else, harmonics must be at least one and no larger than half the
|
181
|
+
number of `signal` samples along `axis`.
|
182
|
+
The default is the first harmonic (fundamental frequency).
|
183
|
+
sample_phase : array_like, optional
|
184
|
+
Phase values (in radians) of `signal` samples along `axis`.
|
185
|
+
If None (default), samples are assumed to be uniformly spaced along
|
186
|
+
one period.
|
187
|
+
The array size must equal the number of samples along `axis`.
|
188
|
+
Cannot be used with `harmonic!=1` or `use_fft=True`.
|
189
|
+
use_fft : bool, optional
|
190
|
+
If true, use a real forward Fast Fourier Transform (FFT).
|
191
|
+
If false, use a Cython implementation that is optimized (faster and
|
192
|
+
resource saving) for calculating few harmonics.
|
193
|
+
By default, FFT is only used when all or at least 8 harmonics are
|
194
|
+
calculated, or `rfft` is specified.
|
195
|
+
rfft : callable, optional
|
196
|
+
Drop-in replacement function for ``numpy.fft.rfft``.
|
197
|
+
For example, ``scipy.fft.rfft`` or ``mkl_fft._numpy_fft.rfft``.
|
198
|
+
Used to calculate the real forward FFT.
|
199
|
+
dtype : dtype_like, optional
|
200
|
+
Data type of output arrays. Either float32 or float64.
|
201
|
+
The default is float64 unless the `signal` is float32.
|
202
|
+
num_threads : int, optional
|
203
|
+
Number of OpenMP threads to use for parallelization when not using FFT.
|
204
|
+
By default, multi-threading is disabled.
|
205
|
+
If zero, up to half of logical CPUs are used.
|
206
|
+
OpenMP may not be available on all platforms.
|
207
|
+
|
208
|
+
Returns
|
209
|
+
-------
|
210
|
+
mean : ndarray
|
211
|
+
Average of `signal` along `axis` (zero harmonic).
|
212
|
+
real : ndarray
|
213
|
+
Real component of phasor coordinates at `harmonic` along `axis`.
|
214
|
+
imag : ndarray
|
215
|
+
Imaginary component of phasor coordinates at `harmonic` along `axis`.
|
216
|
+
|
217
|
+
Raises
|
218
|
+
------
|
219
|
+
ValueError
|
220
|
+
The `signal` has less than three samples along `axis`.
|
221
|
+
The `sample_phase` size does not equal the number of samples along
|
222
|
+
`axis`.
|
223
|
+
IndexError
|
224
|
+
`harmonic` is smaller than 1 or greater than half the samples along
|
225
|
+
`axis`.
|
226
|
+
TypeError
|
227
|
+
The `signal`, `dtype`, or `harmonic` types are not supported.
|
228
|
+
|
229
|
+
See Also
|
230
|
+
--------
|
231
|
+
phasorpy.phasor.phasor_to_signal
|
232
|
+
:ref:`sphx_glr_tutorials_benchmarks_phasorpy_phasor_from_signal.py`
|
233
|
+
|
234
|
+
Notes
|
235
|
+
-----
|
236
|
+
The phasor coordinates `real` (:math:`G`), `imag` (:math:`S`), and
|
237
|
+
`mean` (:math:`F_{DC}`) are calculated from :math:`K\ge3` samples of the
|
238
|
+
signal :math:`F` af `harmonic` :math:`h` according to:
|
239
|
+
|
240
|
+
.. math::
|
241
|
+
|
242
|
+
F_{DC} &= \frac{1}{K} \sum_{k=0}^{K-1} F_{k}
|
243
|
+
|
244
|
+
G &= \frac{1}{K} \sum_{k=0}^{K-1} F_{k}
|
245
|
+
\cos{\left (2 \pi h \frac{k}{K} \right )} \cdot \frac{1}{F_{DC}}
|
246
|
+
|
247
|
+
S &= \frac{1}{K} \sum_{k=0}^{K-1} F_{k}
|
248
|
+
\sin{\left (2 \pi h \frac{k}{K} \right )} \cdot \frac{1}{F_{DC}}
|
249
|
+
|
250
|
+
If :math:`F_{DC} = 0`, the phasor coordinates are undefined
|
251
|
+
(:math:`NaN` or :math:`\infty`).
|
252
|
+
Use `NaN`-aware software to further process the phasor coordinates.
|
253
|
+
|
254
|
+
The phasor coordinates may be zero, for example, in case of only constant
|
255
|
+
background in time-resolved signals, or as the result of linear
|
256
|
+
combination of non-zero spectral phasors coordinates.
|
257
|
+
|
258
|
+
Examples
|
259
|
+
--------
|
260
|
+
Calculate phasor coordinates of a phase-shifted sinusoidal waveform:
|
261
|
+
|
262
|
+
>>> sample_phase = numpy.linspace(0, 2 * math.pi, 5, endpoint=False)
|
263
|
+
>>> signal = 1.1 * (numpy.cos(sample_phase - 0.785398) * 2 * 0.707107 + 1)
|
264
|
+
>>> phasor_from_signal(signal) # doctest: +NUMBER
|
265
|
+
(array(1.1), array(0.5), array(0.5))
|
266
|
+
|
267
|
+
The sinusoidal signal does not have a second harmonic component:
|
268
|
+
|
269
|
+
>>> phasor_from_signal(signal, harmonic=2) # doctest: +NUMBER
|
270
|
+
(array(1.1), array(0.0), array(0.0))
|
271
|
+
|
272
|
+
"""
|
273
|
+
# TODO: C-order not required by rfft?
|
274
|
+
# TODO: preserve array subtypes?
|
275
|
+
signal = numpy.asarray(signal, order='C')
|
276
|
+
if signal.dtype.kind not in 'uif':
|
277
|
+
raise TypeError(f'signal must be real valued, not {signal.dtype=}')
|
278
|
+
samples = numpy.size(signal, axis) # this also verifies axis and ndim >= 1
|
279
|
+
if samples < 3:
|
280
|
+
raise ValueError(f'not enough {samples=} along {axis=}')
|
281
|
+
|
282
|
+
if dtype is None:
|
283
|
+
dtype = numpy.float32 if signal.dtype.char == 'f' else numpy.float64
|
284
|
+
dtype = numpy.dtype(dtype)
|
285
|
+
if dtype.kind != 'f':
|
286
|
+
raise TypeError(f'{dtype=} not supported')
|
287
|
+
|
288
|
+
harmonic, keepdims = parse_harmonic(harmonic, samples)
|
289
|
+
num_harmonics = len(harmonic)
|
290
|
+
|
291
|
+
if sample_phase is not None:
|
292
|
+
if use_fft:
|
293
|
+
raise ValueError('sample_phase cannot be used with FFT')
|
294
|
+
if num_harmonics > 1 or harmonic[0] != 1:
|
295
|
+
raise ValueError('sample_phase cannot be used with harmonic != 1')
|
296
|
+
sample_phase = numpy.atleast_1d(
|
297
|
+
numpy.asarray(sample_phase, dtype=numpy.float64)
|
298
|
+
)
|
299
|
+
if sample_phase.ndim != 1 or sample_phase.size != samples:
|
300
|
+
raise ValueError(f'{sample_phase.shape=} != ({samples},)')
|
301
|
+
|
302
|
+
if use_fft is None:
|
303
|
+
use_fft = sample_phase is None and (
|
304
|
+
rfft is not None
|
305
|
+
or num_harmonics > 7
|
306
|
+
or num_harmonics == samples // 2
|
307
|
+
)
|
308
|
+
|
309
|
+
if use_fft:
|
310
|
+
if rfft is None:
|
311
|
+
rfft = numpy.fft.rfft
|
312
|
+
|
313
|
+
fft: NDArray[Any] = rfft(signal, axis=axis, norm='forward')
|
314
|
+
|
315
|
+
mean = fft.take(0, axis=axis).real
|
316
|
+
if not mean.ndim == 0:
|
317
|
+
mean = numpy.ascontiguousarray(mean, dtype)
|
318
|
+
fft = fft.take(harmonic, axis=axis)
|
319
|
+
real = numpy.ascontiguousarray(fft.real, dtype)
|
320
|
+
imag = numpy.ascontiguousarray(fft.imag, dtype)
|
321
|
+
del fft
|
322
|
+
|
323
|
+
if not keepdims and real.shape[axis] == 1:
|
324
|
+
dc = mean
|
325
|
+
real = real.squeeze(axis)
|
326
|
+
imag = imag.squeeze(axis)
|
327
|
+
else:
|
328
|
+
# make broadcastable
|
329
|
+
dc = numpy.expand_dims(mean, 0)
|
330
|
+
real = numpy.moveaxis(real, axis, 0)
|
331
|
+
imag = numpy.moveaxis(imag, axis, 0)
|
332
|
+
|
333
|
+
# complex division by mean signal
|
334
|
+
with numpy.errstate(divide='ignore', invalid='ignore'):
|
335
|
+
real /= dc
|
336
|
+
imag /= dc
|
337
|
+
numpy.negative(imag, out=imag)
|
338
|
+
|
339
|
+
if not keepdims and real.ndim == 0:
|
340
|
+
return mean.squeeze(), real.squeeze(), imag.squeeze()
|
341
|
+
|
342
|
+
return mean, real, imag
|
343
|
+
|
344
|
+
num_threads = number_threads(num_threads)
|
345
|
+
|
346
|
+
sincos = numpy.empty((num_harmonics, samples, 2))
|
347
|
+
for i, h in enumerate(harmonic):
|
348
|
+
if sample_phase is None:
|
349
|
+
phase = numpy.linspace(
|
350
|
+
0,
|
351
|
+
h * math.pi * 2.0,
|
352
|
+
samples,
|
353
|
+
endpoint=False,
|
354
|
+
dtype=numpy.float64,
|
355
|
+
)
|
356
|
+
else:
|
357
|
+
phase = sample_phase
|
358
|
+
sincos[i, :, 0] = numpy.cos(phase)
|
359
|
+
sincos[i, :, 1] = numpy.sin(phase)
|
360
|
+
|
361
|
+
# reshape to 3D with axis in middle
|
362
|
+
axis = axis % signal.ndim
|
363
|
+
shape0 = signal.shape[:axis]
|
364
|
+
shape1 = signal.shape[axis + 1 :]
|
365
|
+
size0 = math.prod(shape0)
|
366
|
+
size1 = math.prod(shape1)
|
367
|
+
phasor = numpy.empty((num_harmonics * 2 + 1, size0, size1), dtype)
|
368
|
+
signal = signal.reshape((size0, samples, size1))
|
369
|
+
|
370
|
+
_phasor_from_signal(phasor, signal, sincos, num_threads)
|
371
|
+
|
372
|
+
# restore original shape
|
373
|
+
shape = shape0 + shape1
|
374
|
+
mean = phasor[0].reshape(shape)
|
375
|
+
if keepdims:
|
376
|
+
shape = (num_harmonics,) + shape
|
377
|
+
real = phasor[1 : num_harmonics + 1].reshape(shape)
|
378
|
+
imag = phasor[1 + num_harmonics :].reshape(shape)
|
379
|
+
if shape:
|
380
|
+
return mean, real, imag
|
381
|
+
return mean.squeeze(), real.squeeze(), imag.squeeze()
|
382
|
+
|
383
|
+
|
384
|
+
def phasor_to_signal(
|
385
|
+
mean: ArrayLike,
|
386
|
+
real: ArrayLike,
|
387
|
+
imag: ArrayLike,
|
388
|
+
/,
|
389
|
+
*,
|
390
|
+
samples: int = 64,
|
391
|
+
harmonic: int | Sequence[int] | Literal['all'] | str | None = None,
|
392
|
+
axis: int = -1,
|
393
|
+
irfft: Callable[..., NDArray[Any]] | None = None,
|
394
|
+
) -> NDArray[numpy.float64]:
|
395
|
+
"""Return signal from phasor coordinates using inverse Fourier transform.
|
396
|
+
|
397
|
+
Parameters
|
398
|
+
----------
|
399
|
+
mean : array_like
|
400
|
+
Average signal intensity (DC).
|
401
|
+
If not scalar, shape must match the last two dimensions of `real`.
|
402
|
+
real : array_like
|
403
|
+
Real component of phasor coordinates.
|
404
|
+
Multiple harmonics, if any, must be in the first axis.
|
405
|
+
imag : array_like
|
406
|
+
Imaginary component of phasor coordinates.
|
407
|
+
Must be same shape as `real`.
|
408
|
+
samples : int, default: 64
|
409
|
+
Number of signal samples to return. Must be at least three.
|
410
|
+
harmonic : int, sequence of int, or 'all', optional
|
411
|
+
Harmonics included in first axis of `real` and `imag`.
|
412
|
+
If None, lower harmonics are inferred from the shapes of phasor
|
413
|
+
coordinates (most commonly, lower harmonics are present if the number
|
414
|
+
of dimensions of `mean` is one less than `real`).
|
415
|
+
If `'all'`, the harmonics in the first axis of phasor coordinates are
|
416
|
+
the lower harmonics.
|
417
|
+
Else, harmonics must be at least one and no larger than half of
|
418
|
+
`samples`.
|
419
|
+
The phasor coordinates of missing harmonics are zeroed
|
420
|
+
if `samples` is greater than twice the number of harmonics.
|
421
|
+
axis : int, optional
|
422
|
+
Axis at which to return signal samples.
|
423
|
+
The default is the last axis (-1).
|
424
|
+
irfft : callable, optional
|
425
|
+
Drop-in replacement function for ``numpy.fft.irfft``.
|
426
|
+
For example, ``scipy.fft.irfft`` or ``mkl_fft._numpy_fft.irfft``.
|
427
|
+
Used to calculate the real inverse FFT.
|
428
|
+
|
429
|
+
Returns
|
430
|
+
-------
|
431
|
+
signal : ndarray
|
432
|
+
Reconstructed signal with samples of one period along the last axis.
|
433
|
+
|
434
|
+
See Also
|
435
|
+
--------
|
436
|
+
phasorpy.phasor.phasor_from_signal
|
437
|
+
|
438
|
+
Notes
|
439
|
+
-----
|
440
|
+
The reconstructed signal may be undefined if the input phasor coordinates,
|
441
|
+
or signal mean contain `NaN` values.
|
442
|
+
|
443
|
+
Examples
|
444
|
+
--------
|
445
|
+
Reconstruct exact signal from phasor coordinates at all harmonics:
|
446
|
+
|
447
|
+
>>> sample_phase = numpy.linspace(0, 2 * math.pi, 5, endpoint=False)
|
448
|
+
>>> signal = 1.1 * (numpy.cos(sample_phase - 0.785398) * 2 * 0.707107 + 1)
|
449
|
+
>>> signal
|
450
|
+
array([2.2, 2.486, 0.8566, -0.4365, 0.3938])
|
451
|
+
>>> phasor_to_signal(
|
452
|
+
... *phasor_from_signal(signal, harmonic='all'),
|
453
|
+
... harmonic='all',
|
454
|
+
... samples=len(signal)
|
455
|
+
... ) # doctest: +NUMBER
|
456
|
+
array([2.2, 2.486, 0.8566, -0.4365, 0.3938])
|
457
|
+
|
458
|
+
Reconstruct a single-frequency waveform from phasor coordinates at
|
459
|
+
first harmonic:
|
460
|
+
|
461
|
+
>>> phasor_to_signal(1.1, 0.5, 0.5, samples=5) # doctest: +NUMBER
|
462
|
+
array([2.2, 2.486, 0.8566, -0.4365, 0.3938])
|
463
|
+
|
464
|
+
"""
|
465
|
+
mean = numpy.array(mean, ndmin=0, copy=True)
|
466
|
+
real = numpy.array(real, ndmin=0, copy=True)
|
467
|
+
imag = numpy.array(imag, ndmin=1, copy=True)
|
468
|
+
|
469
|
+
if isinstance(harmonic, (int, numbers.Integral)) and harmonic == 0:
|
470
|
+
# harmonics are expected in the first axes of real and imag
|
471
|
+
samples_ = 2 * imag.shape[0]
|
472
|
+
else:
|
473
|
+
samples_ = samples
|
474
|
+
|
475
|
+
harmonic_ = harmonic
|
476
|
+
harmonic, has_harmonic_axis = parse_harmonic(harmonic, samples_)
|
477
|
+
|
478
|
+
if real.ndim == 1 and len(harmonic) > 1 and real.shape[0] == len(harmonic):
|
479
|
+
# single axis contains harmonic
|
480
|
+
has_harmonic_axis = True
|
481
|
+
real = real[..., None]
|
482
|
+
imag = imag[..., None]
|
483
|
+
keepdims = mean.ndim > 0
|
484
|
+
else:
|
485
|
+
keepdims = mean.ndim > 0 or real.ndim > 0
|
486
|
+
|
487
|
+
mean, real = numpy.atleast_1d(mean, real)
|
488
|
+
|
489
|
+
if real.dtype.kind != 'f' or imag.dtype.kind != 'f':
|
490
|
+
raise ValueError(f'{real.dtype=} or {imag.dtype=} not floating point')
|
491
|
+
if real.shape != imag.shape:
|
492
|
+
raise ValueError(f'{real.shape=} != {imag.shape=}')
|
493
|
+
|
494
|
+
if (
|
495
|
+
harmonic_ is None
|
496
|
+
and mean.size > 1
|
497
|
+
and mean.ndim + 1 == real.ndim
|
498
|
+
and mean.shape == real.shape[1:]
|
499
|
+
):
|
500
|
+
# infer harmonic from shapes of mean and real
|
501
|
+
harmonic = list(range(1, real.shape[0] + 1))
|
502
|
+
has_harmonic_axis = True
|
503
|
+
|
504
|
+
if not has_harmonic_axis:
|
505
|
+
real = real[None, ...]
|
506
|
+
imag = imag[None, ...]
|
507
|
+
|
508
|
+
if len(harmonic) != real.shape[0]:
|
509
|
+
raise ValueError(f'{len(harmonic)=} != {real.shape[0]=}')
|
510
|
+
|
511
|
+
# complex multiplication by mean signal
|
512
|
+
real *= mean
|
513
|
+
imag *= mean
|
514
|
+
numpy.negative(imag, out=imag)
|
515
|
+
|
516
|
+
fft: NDArray[Any] = numpy.zeros(
|
517
|
+
(samples // 2 + 1, *real.shape[1:]), dtype=numpy.complex128
|
518
|
+
)
|
519
|
+
fft.real[[0]] = mean
|
520
|
+
fft.real[harmonic] = real[: len(harmonic)]
|
521
|
+
fft.imag[harmonic] = imag[: len(harmonic)]
|
522
|
+
|
523
|
+
if irfft is None:
|
524
|
+
irfft = numpy.fft.irfft
|
525
|
+
|
526
|
+
signal: NDArray[Any] = irfft(fft, samples, axis=0, norm='forward')
|
527
|
+
|
528
|
+
if not keepdims:
|
529
|
+
signal = signal[:, 0]
|
530
|
+
elif axis != 0:
|
531
|
+
signal = numpy.moveaxis(signal, 0, axis)
|
532
|
+
return signal
|
533
|
+
|
534
|
+
|
535
|
+
def lifetime_to_signal(
|
536
|
+
frequency: float,
|
537
|
+
lifetime: ArrayLike,
|
538
|
+
fraction: ArrayLike | None = None,
|
539
|
+
*,
|
540
|
+
mean: ArrayLike | None = None,
|
541
|
+
background: ArrayLike | None = None,
|
542
|
+
samples: int = 64,
|
543
|
+
harmonic: int | Sequence[int] | Literal['all'] | str | None = None,
|
544
|
+
zero_phase: float | None = None,
|
545
|
+
zero_stdev: float | None = None,
|
546
|
+
preexponential: bool = False,
|
547
|
+
unit_conversion: float = 1e-3,
|
548
|
+
) -> tuple[NDArray[Any], NDArray[Any], NDArray[Any]]:
|
549
|
+
r"""Return synthetic signal from lifetime components.
|
550
|
+
|
551
|
+
Return synthetic signal, instrument response function (IRF), and
|
552
|
+
time axis, sampled over one period of the fundamental frequency.
|
553
|
+
The signal is convoluted with the IRF, which is approximated by a
|
554
|
+
normal distribution.
|
555
|
+
|
556
|
+
Parameters
|
557
|
+
----------
|
558
|
+
frequency : float
|
559
|
+
Fundamental laser pulse or modulation frequency in MHz.
|
560
|
+
lifetime : array_like
|
561
|
+
Lifetime components in ns.
|
562
|
+
fraction : array_like, optional
|
563
|
+
Fractional intensities or pre-exponential amplitudes of the lifetime
|
564
|
+
components. Fractions are normalized to sum to 1.
|
565
|
+
Must be specified if `lifetime` is not a scalar.
|
566
|
+
mean : array_like, optional, default: 1.0
|
567
|
+
Average signal intensity (DC). Must be scalar for now.
|
568
|
+
background : array_like, optional, default: 0.0
|
569
|
+
Background signal intensity. Must be smaller than `mean`.
|
570
|
+
samples : int, default: 64
|
571
|
+
Number of signal samples to return. Must be at least 16.
|
572
|
+
harmonic : int, sequence of int, or 'all', optional, default: 'all'
|
573
|
+
Harmonics used to synthesize signal.
|
574
|
+
If `'all'`, all harmonics are used.
|
575
|
+
Else, harmonics must be at least one and no larger than half of
|
576
|
+
`samples`.
|
577
|
+
Use `'all'` to synthesize an exponential time-domain decay signal,
|
578
|
+
or `1` to synthesize a homodyne signal.
|
579
|
+
zero_phase : float, optional
|
580
|
+
Position of instrument response function in radians.
|
581
|
+
Must be in range 0.0 to :math:`\pi`. The default is the 8th sample.
|
582
|
+
zero_stdev : float, optional
|
583
|
+
Standard deviation of instrument response function in radians.
|
584
|
+
Must be at least 1.5 samples and no more than one tenth of samples
|
585
|
+
to allow for sufficient sampling of the function.
|
586
|
+
The default is 1.5 samples. Increase `samples` to narrow the IRF.
|
587
|
+
preexponential : bool, optional, default: False
|
588
|
+
If true, `fraction` values are pre-exponential amplitudes,
|
589
|
+
else fractional intensities.
|
590
|
+
unit_conversion : float, optional, default: 1e-3
|
591
|
+
Product of `frequency` and `lifetime` units' prefix factors.
|
592
|
+
The default is 1e-3 for MHz and ns, or Hz and ms.
|
593
|
+
Use 1.0 for Hz and s.
|
594
|
+
|
595
|
+
Returns
|
596
|
+
-------
|
597
|
+
signal : ndarray
|
598
|
+
Signal generated from lifetimes at frequency, convoluted with
|
599
|
+
instrument response function.
|
600
|
+
zero : ndarray
|
601
|
+
Instrument response function.
|
602
|
+
time : ndarray
|
603
|
+
Time for each sample in signal in units of `lifetime`.
|
604
|
+
|
605
|
+
See Also
|
606
|
+
--------
|
607
|
+
phasorpy.phasor.phasor_from_lifetime
|
608
|
+
phasorpy.phasor.phasor_to_signal
|
609
|
+
:ref:`sphx_glr_tutorials_api_phasorpy_lifetime_to_signal.py`
|
610
|
+
|
611
|
+
Notes
|
612
|
+
-----
|
613
|
+
This implementation is based on an inverse digital Fourier transform (DFT).
|
614
|
+
Because DFT cannot be used on signals with discontinuities
|
615
|
+
(for example, an exponential decay starting at zero) without producing
|
616
|
+
strong artifacts (ripples), the signal is convoluted with a continuous
|
617
|
+
instrument response function (IRF). The minimum width of the IRF is
|
618
|
+
limited due to sampling requirements.
|
619
|
+
|
620
|
+
Examples
|
621
|
+
--------
|
622
|
+
Synthesize a multi-exponential time-domain decay signal for two
|
623
|
+
lifetime components of 4.2 and 0.9 ns at 40 MHz:
|
624
|
+
|
625
|
+
>>> signal, zero, times = lifetime_to_signal(
|
626
|
+
... 40, [4.2, 0.9], fraction=[0.8, 0.2], samples=16
|
627
|
+
... )
|
628
|
+
>>> signal # doctest: +NUMBER
|
629
|
+
array([0.2846, 0.1961, 0.1354, ..., 0.8874, 0.6029, 0.4135])
|
630
|
+
|
631
|
+
Synthesize a homodyne frequency-domain waveform signal for
|
632
|
+
a single lifetime:
|
633
|
+
|
634
|
+
>>> signal, zero, times = lifetime_to_signal(
|
635
|
+
... 40.0, 4.2, samples=16, harmonic=1
|
636
|
+
... )
|
637
|
+
>>> signal # doctest: +NUMBER
|
638
|
+
array([0.2047, -0.05602, -0.156, ..., 1.471, 1.031, 0.5865])
|
639
|
+
|
640
|
+
"""
|
641
|
+
if harmonic is None:
|
642
|
+
harmonic = 'all'
|
643
|
+
all_hamonics = harmonic == 'all'
|
644
|
+
harmonic, _ = parse_harmonic(harmonic, samples)
|
645
|
+
|
646
|
+
if samples < 16:
|
647
|
+
raise ValueError(f'{samples=} < 16')
|
648
|
+
|
649
|
+
if background is None:
|
650
|
+
background = 0.0
|
651
|
+
background = numpy.asarray(background)
|
652
|
+
|
653
|
+
if mean is None:
|
654
|
+
mean = 1.0
|
655
|
+
mean = numpy.asarray(mean)
|
656
|
+
mean -= background
|
657
|
+
if numpy.any(mean <= 0.0):
|
658
|
+
raise ValueError('mean - background must not be less than zero')
|
659
|
+
|
660
|
+
scale = samples / (2.0 * math.pi)
|
661
|
+
if zero_phase is None:
|
662
|
+
zero_phase = 8.0 / scale
|
663
|
+
phase = zero_phase * scale # in sample units
|
664
|
+
if zero_stdev is None:
|
665
|
+
zero_stdev = 1.5 / scale
|
666
|
+
stdev = zero_stdev * scale # in sample units
|
667
|
+
|
668
|
+
if zero_phase < 0 or zero_phase > 2.0 * math.pi:
|
669
|
+
raise ValueError(f'{zero_phase=} out of range [0 .. 2 pi]')
|
670
|
+
if stdev < 1.5:
|
671
|
+
raise ValueError(
|
672
|
+
f'{zero_stdev=} < {1.5 / scale} cannot be sampled sufficiently'
|
673
|
+
)
|
674
|
+
if stdev >= samples / 10:
|
675
|
+
raise ValueError(f'{zero_stdev=} > pi / 5 not supported')
|
676
|
+
|
677
|
+
frequencies = numpy.atleast_1d(frequency)
|
678
|
+
if frequencies.size > 1 or frequencies[0] <= 0.0:
|
679
|
+
raise ValueError('frequency must be scalar and positive')
|
680
|
+
frequencies = numpy.linspace(
|
681
|
+
frequency, samples // 2 * frequency, samples // 2
|
682
|
+
)
|
683
|
+
frequencies = frequencies[[h - 1 for h in harmonic]]
|
684
|
+
|
685
|
+
real, imag = phasor_from_lifetime(
|
686
|
+
frequencies,
|
687
|
+
lifetime,
|
688
|
+
fraction,
|
689
|
+
preexponential=preexponential,
|
690
|
+
unit_conversion=unit_conversion,
|
691
|
+
)
|
692
|
+
real, imag = numpy.atleast_1d(real, imag)
|
693
|
+
|
694
|
+
zero = numpy.zeros(samples, dtype=numpy.float64)
|
695
|
+
_gaussian_signal(zero, phase, stdev)
|
696
|
+
zero_mean, zero_real, zero_imag = phasor_from_signal(
|
697
|
+
zero, harmonic=harmonic
|
698
|
+
)
|
699
|
+
if real.ndim > 1:
|
700
|
+
# make broadcastable with real and imag
|
701
|
+
zero_real = zero_real[:, None]
|
702
|
+
zero_imag = zero_imag[:, None]
|
703
|
+
if not all_hamonics:
|
704
|
+
zero = phasor_to_signal(
|
705
|
+
zero_mean, zero_real, zero_imag, samples=samples, harmonic=harmonic
|
706
|
+
)
|
707
|
+
|
708
|
+
phasor_multiply(real, imag, zero_real, zero_imag, out=(real, imag))
|
709
|
+
|
710
|
+
if len(harmonic) == 1:
|
711
|
+
harmonic = harmonic[0]
|
712
|
+
signal = phasor_to_signal(
|
713
|
+
mean, real, imag, samples=samples, harmonic=harmonic
|
714
|
+
)
|
715
|
+
signal += numpy.asarray(background)
|
716
|
+
|
717
|
+
time = numpy.linspace(0, 1.0 / (unit_conversion * frequency), samples)
|
718
|
+
|
719
|
+
return signal.squeeze(), zero.squeeze(), time
|
720
|
+
|
721
|
+
|
722
|
+
def phasor_semicircle(
|
723
|
+
samples: int = 101, /
|
724
|
+
) -> tuple[NDArray[numpy.float64], NDArray[numpy.float64]]:
|
725
|
+
r"""Return equally spaced phasor coordinates on universal semicircle.
|
726
|
+
|
727
|
+
Parameters
|
728
|
+
----------
|
729
|
+
samples : int, optional, default: 101
|
730
|
+
Number of coordinates to return.
|
731
|
+
|
732
|
+
Returns
|
733
|
+
-------
|
734
|
+
real : ndarray
|
735
|
+
Real component of semicircle phasor coordinates.
|
736
|
+
imag : ndarray
|
737
|
+
Imaginary component of semicircle phasor coordinates.
|
738
|
+
|
739
|
+
Raises
|
740
|
+
------
|
741
|
+
ValueError
|
742
|
+
The number of `samples` is smaller than 1.
|
743
|
+
|
744
|
+
Notes
|
745
|
+
-----
|
746
|
+
If more than one sample, the first and last phasor coordinates returned
|
747
|
+
are ``(0, 0)`` and ``(1, 0)``.
|
748
|
+
The center coordinate, if any, is ``(0.5, 0.5)``.
|
749
|
+
|
750
|
+
The universal semicircle is composed of the phasor coordinates of
|
751
|
+
single lifetime components, where the relation of polar coordinates
|
752
|
+
(phase :math:`\phi` and modulation :math:`M`) is:
|
753
|
+
|
754
|
+
.. math::
|
755
|
+
|
756
|
+
M = \cos{\phi}
|
757
|
+
|
758
|
+
Examples
|
759
|
+
--------
|
760
|
+
Calculate three phasor coordinates on universal semicircle:
|
761
|
+
|
762
|
+
>>> phasor_semicircle(3) # doctest: +NUMBER
|
763
|
+
(array([0, 0.5, 1]), array([0.0, 0.5, 0]))
|
764
|
+
|
765
|
+
"""
|
766
|
+
if samples < 1:
|
767
|
+
raise ValueError(f'{samples=} < 1')
|
768
|
+
arange = numpy.linspace(math.pi, 0.0, samples)
|
769
|
+
real = numpy.cos(arange)
|
770
|
+
real += 1.0
|
771
|
+
real *= 0.5
|
772
|
+
imag = numpy.sin(arange)
|
773
|
+
imag *= 0.5
|
774
|
+
return real, imag
|
775
|
+
|
776
|
+
|
777
|
+
def phasor_to_complex(
|
778
|
+
real: ArrayLike,
|
779
|
+
imag: ArrayLike,
|
780
|
+
/,
|
781
|
+
*,
|
782
|
+
dtype: DTypeLike = None,
|
783
|
+
) -> NDArray[numpy.complex64 | numpy.complex128]:
|
784
|
+
"""Return phasor coordinates as complex numbers.
|
785
|
+
|
786
|
+
Parameters
|
787
|
+
----------
|
788
|
+
real : array_like
|
789
|
+
Real component of phasor coordinates.
|
790
|
+
imag : array_like
|
791
|
+
Imaginary component of phasor coordinates.
|
792
|
+
dtype : dtype_like, optional
|
793
|
+
Data type of output array. Either complex64 or complex128.
|
794
|
+
By default, complex64 if `real` and `imag` are float32,
|
795
|
+
else complex128.
|
796
|
+
|
797
|
+
Returns
|
798
|
+
-------
|
799
|
+
complex : ndarray
|
800
|
+
Phasor coordinates as complex numbers.
|
801
|
+
|
802
|
+
Examples
|
803
|
+
--------
|
804
|
+
Convert phasor coordinates to complex number arrays:
|
805
|
+
|
806
|
+
>>> phasor_to_complex([0.4, 0.5], [0.2, 0.3])
|
807
|
+
array([0.4+0.2j, 0.5+0.3j])
|
808
|
+
|
809
|
+
"""
|
810
|
+
real = numpy.asarray(real)
|
811
|
+
imag = numpy.asarray(imag)
|
812
|
+
if dtype is None:
|
813
|
+
if real.dtype == numpy.float32 and imag.dtype == numpy.float32:
|
814
|
+
dtype = numpy.complex64
|
815
|
+
else:
|
816
|
+
dtype = numpy.complex128
|
817
|
+
else:
|
818
|
+
dtype = numpy.dtype(dtype)
|
819
|
+
if dtype.kind != 'c':
|
820
|
+
raise ValueError(f'{dtype=} not a complex type')
|
821
|
+
|
822
|
+
c = numpy.empty(numpy.broadcast(real, imag).shape, dtype)
|
823
|
+
c.real = real
|
824
|
+
c.imag = imag
|
825
|
+
return c
|
826
|
+
|
827
|
+
|
828
|
+
def phasor_multiply(
|
829
|
+
real: ArrayLike,
|
830
|
+
imag: ArrayLike,
|
831
|
+
factor_real: ArrayLike,
|
832
|
+
factor_imag: ArrayLike,
|
833
|
+
/,
|
834
|
+
**kwargs: Any,
|
835
|
+
) -> tuple[NDArray[Any], NDArray[Any]]:
|
836
|
+
r"""Return complex multiplication of two phasors.
|
837
|
+
|
838
|
+
Complex multiplication can be used, for example, to convolve two signals
|
839
|
+
such as exponential decay and instrument response functions.
|
840
|
+
|
841
|
+
Parameters
|
842
|
+
----------
|
843
|
+
real : array_like
|
844
|
+
Real component of phasor coordinates to multiply.
|
845
|
+
imag : array_like
|
846
|
+
Imaginary component of phasor coordinates to multiply.
|
847
|
+
factor_real : array_like
|
848
|
+
Real component of phasor coordinates to multiply by.
|
849
|
+
factor_imag : array_like
|
850
|
+
Imaginary component of phasor coordinates to multiply by.
|
851
|
+
**kwargs
|
852
|
+
Optional `arguments passed to numpy universal functions
|
853
|
+
<https://numpy.org/doc/stable/reference/ufuncs.html#ufuncs-kwargs>`_.
|
854
|
+
|
855
|
+
Returns
|
856
|
+
-------
|
857
|
+
real : ndarray
|
858
|
+
Real component of complex multiplication.
|
859
|
+
imag : ndarray
|
860
|
+
Imaginary component of complex multiplication.
|
861
|
+
|
862
|
+
Notes
|
863
|
+
-----
|
864
|
+
The phasor coordinates `real` (:math:`G`) and `imag` (:math:`S`)
|
865
|
+
are multiplied by phasor coordinates `factor_real` (:math:`g`)
|
866
|
+
and `factor_imag` (:math:`s`) according to:
|
867
|
+
|
868
|
+
.. math::
|
869
|
+
|
870
|
+
G' &= G \cdot g - S \cdot s
|
871
|
+
|
872
|
+
S' &= G \cdot s + S \cdot g
|
873
|
+
|
874
|
+
Examples
|
875
|
+
--------
|
876
|
+
Multiply two sets of phasor coordinates:
|
877
|
+
|
878
|
+
>>> phasor_multiply([0.1, 0.2], [0.3, 0.4], [0.5, 0.6], [0.7, 0.8])
|
879
|
+
(array([-0.16, -0.2]), array([0.22, 0.4]))
|
880
|
+
|
881
|
+
"""
|
882
|
+
# c = phasor_to_complex(real, imag) * phasor_to_complex(
|
883
|
+
# factor_real, factor_imag
|
884
|
+
# )
|
885
|
+
# return c.real, c.imag
|
886
|
+
return _phasor_multiply( # type: ignore[no-any-return]
|
887
|
+
real, imag, factor_real, factor_imag, **kwargs
|
888
|
+
)
|
889
|
+
|
890
|
+
|
891
|
+
def phasor_divide(
|
892
|
+
real: ArrayLike,
|
893
|
+
imag: ArrayLike,
|
894
|
+
divisor_real: ArrayLike,
|
895
|
+
divisor_imag: ArrayLike,
|
896
|
+
/,
|
897
|
+
**kwargs: Any,
|
898
|
+
) -> tuple[NDArray[Any], NDArray[Any]]:
|
899
|
+
r"""Return complex division of two phasors.
|
900
|
+
|
901
|
+
Complex division can be used, for example, to deconvolve two signals
|
902
|
+
such as exponential decay and instrument response functions.
|
903
|
+
|
904
|
+
Parameters
|
905
|
+
----------
|
906
|
+
real : array_like
|
907
|
+
Real component of phasor coordinates to divide.
|
908
|
+
imag : array_like
|
909
|
+
Imaginary component of phasor coordinates to divide.
|
910
|
+
divisor_real : array_like
|
911
|
+
Real component of phasor coordinates to divide by.
|
912
|
+
divisor_imag : array_like
|
913
|
+
Imaginary component of phasor coordinates to divide by.
|
914
|
+
**kwargs
|
915
|
+
Optional `arguments passed to numpy universal functions
|
916
|
+
<https://numpy.org/doc/stable/reference/ufuncs.html#ufuncs-kwargs>`_.
|
917
|
+
|
918
|
+
Returns
|
919
|
+
-------
|
920
|
+
real : ndarray
|
921
|
+
Real component of complex division.
|
922
|
+
imag : ndarray
|
923
|
+
Imaginary component of complex division.
|
924
|
+
|
925
|
+
Notes
|
926
|
+
-----
|
927
|
+
The phasor coordinates `real` (:math:`G`) and `imag` (:math:`S`)
|
928
|
+
are divided by phasor coordinates `divisor_real` (:math:`g`)
|
929
|
+
and `divisor_imag` (:math:`s`) according to:
|
930
|
+
|
931
|
+
.. math::
|
932
|
+
|
933
|
+
d &= g \cdot g + s \cdot s
|
934
|
+
|
935
|
+
G' &= (G \cdot g + S \cdot s) / d
|
936
|
+
|
937
|
+
S' &= (G \cdot s - S \cdot g) / d
|
938
|
+
|
939
|
+
Examples
|
940
|
+
--------
|
941
|
+
Divide two sets of phasor coordinates:
|
942
|
+
|
943
|
+
>>> phasor_divide([-0.16, -0.2], [0.22, 0.4], [0.5, 0.6], [0.7, 0.8])
|
944
|
+
(array([0.1, 0.2]), array([0.3, 0.4]))
|
945
|
+
|
946
|
+
"""
|
947
|
+
# c = phasor_to_complex(real, imag) / phasor_to_complex(
|
948
|
+
# divisor_real, divisor_imag
|
949
|
+
# )
|
950
|
+
# return c.real, c.imag
|
951
|
+
return _phasor_divide( # type: ignore[no-any-return]
|
952
|
+
real, imag, divisor_real, divisor_imag, **kwargs
|
953
|
+
)
|
954
|
+
|
955
|
+
|
956
|
+
def phasor_calibrate(
|
957
|
+
real: ArrayLike,
|
958
|
+
imag: ArrayLike,
|
959
|
+
reference_real: ArrayLike,
|
960
|
+
reference_imag: ArrayLike,
|
961
|
+
/,
|
962
|
+
frequency: ArrayLike,
|
963
|
+
lifetime: ArrayLike,
|
964
|
+
*,
|
965
|
+
fraction: ArrayLike | None = None,
|
966
|
+
preexponential: bool = False,
|
967
|
+
unit_conversion: float = 1e-3,
|
968
|
+
reverse: bool = False,
|
969
|
+
method: Literal['mean', 'median'] = 'mean',
|
970
|
+
skip_axis: int | Sequence[int] | None = None,
|
971
|
+
) -> tuple[NDArray[Any], NDArray[Any]]:
|
972
|
+
"""
|
973
|
+
Return calibrated/referenced phasor coordinates.
|
974
|
+
|
975
|
+
Calibration of phasor coordinates from time-resolved measurements is
|
976
|
+
necessary to account for the instrument response function (IRF) and delays
|
977
|
+
in the electronics.
|
978
|
+
|
979
|
+
Parameters
|
980
|
+
----------
|
981
|
+
real : array_like
|
982
|
+
Real component of phasor coordinates to be calibrated.
|
983
|
+
imag : array_like
|
984
|
+
Imaginary component of phasor coordinates to be calibrated.
|
985
|
+
reference_real : array_like
|
986
|
+
Real component of phasor coordinates from reference of known lifetime.
|
987
|
+
Must be measured with the same instrument setting as the phasor
|
988
|
+
coordinates to be calibrated.
|
989
|
+
reference_imag : array_like
|
990
|
+
Imaginary component of phasor coordinates from reference of known
|
991
|
+
lifetime.
|
992
|
+
Must be measured with the same instrument setting as the phasor
|
993
|
+
coordinates to be calibrated.
|
994
|
+
frequency : array_like
|
995
|
+
Laser pulse or modulation frequency in MHz.
|
996
|
+
A scalar or one-dimensional sequence.
|
997
|
+
lifetime : array_like
|
998
|
+
Lifetime components in ns. Must be scalar or one dimensional.
|
999
|
+
fraction : array_like, optional
|
1000
|
+
Fractional intensities or pre-exponential amplitudes of the lifetime
|
1001
|
+
components. Fractions are normalized to sum to 1.
|
1002
|
+
Must be same size as `lifetime`.
|
1003
|
+
preexponential : bool, optional
|
1004
|
+
If true, `fraction` values are pre-exponential amplitudes,
|
1005
|
+
else fractional intensities (default).
|
1006
|
+
unit_conversion : float, optional
|
1007
|
+
Product of `frequency` and `lifetime` units' prefix factors.
|
1008
|
+
The default is 1e-3 for MHz and ns, or Hz and ms.
|
1009
|
+
Use 1.0 for Hz and s.
|
1010
|
+
reverse : bool, optional
|
1011
|
+
Reverse calibration.
|
1012
|
+
method : str, optional
|
1013
|
+
Method used for calculating center of `reference_real` and
|
1014
|
+
`reference_imag`:
|
1015
|
+
|
1016
|
+
- ``'mean'``: Arithmetic mean of phasor coordinates.
|
1017
|
+
- ``'median'``: Spatial median of phasor coordinates.
|
1018
|
+
skip_axis : int or sequence of int, optional
|
1019
|
+
Axes to be excluded during center calculation. If None, all
|
1020
|
+
axes are considered.
|
1021
|
+
|
1022
|
+
Returns
|
1023
|
+
-------
|
1024
|
+
real : ndarray
|
1025
|
+
Calibrated real component of phasor coordinates.
|
1026
|
+
imag : ndarray
|
1027
|
+
Calibrated imaginary component of phasor coordinates.
|
1028
|
+
|
1029
|
+
Raises
|
1030
|
+
------
|
1031
|
+
ValueError
|
1032
|
+
The array shapes of `real` and `imag`, or `reference_real` and
|
1033
|
+
`reference_imag` do not match.
|
1034
|
+
|
1035
|
+
See Also
|
1036
|
+
--------
|
1037
|
+
phasorpy.phasor.phasor_transform
|
1038
|
+
phasorpy.phasor.polar_from_reference_phasor
|
1039
|
+
phasorpy.phasor.phasor_center
|
1040
|
+
phasorpy.phasor.phasor_from_lifetime
|
1041
|
+
|
1042
|
+
Notes
|
1043
|
+
-----
|
1044
|
+
This function is a convenience wrapper for the following operations:
|
1045
|
+
|
1046
|
+
.. code-block:: python
|
1047
|
+
|
1048
|
+
phasor_transform(
|
1049
|
+
real,
|
1050
|
+
imag,
|
1051
|
+
*polar_from_reference_phasor(
|
1052
|
+
*phasor_center(
|
1053
|
+
reference_real,
|
1054
|
+
reference_imag,
|
1055
|
+
skip_axis,
|
1056
|
+
method,
|
1057
|
+
),
|
1058
|
+
*phasor_from_lifetime(
|
1059
|
+
frequency,
|
1060
|
+
lifetime,
|
1061
|
+
fraction,
|
1062
|
+
preexponential,
|
1063
|
+
unit_conversion,
|
1064
|
+
),
|
1065
|
+
),
|
1066
|
+
)
|
1067
|
+
|
1068
|
+
Calibration can be reversed such that
|
1069
|
+
|
1070
|
+
.. code-block:: python
|
1071
|
+
|
1072
|
+
real, imag == phasor_calibrate(
|
1073
|
+
*phasor_calibrate(real, imag, *args, **kwargs),
|
1074
|
+
*args,
|
1075
|
+
reverse=True,
|
1076
|
+
**kwargs
|
1077
|
+
)
|
1078
|
+
|
1079
|
+
Examples
|
1080
|
+
--------
|
1081
|
+
>>> phasor_calibrate(
|
1082
|
+
... [0.1, 0.2, 0.3],
|
1083
|
+
... [0.4, 0.5, 0.6],
|
1084
|
+
... [0.2, 0.3, 0.4],
|
1085
|
+
... [0.5, 0.6, 0.7],
|
1086
|
+
... frequency=80,
|
1087
|
+
... lifetime=4,
|
1088
|
+
... ) # doctest: +NUMBER
|
1089
|
+
(array([0.0658, 0.132, 0.198]), array([0.2657, 0.332, 0.399]))
|
1090
|
+
|
1091
|
+
Undo the previous calibration:
|
1092
|
+
|
1093
|
+
>>> phasor_calibrate(
|
1094
|
+
... [0.0658, 0.132, 0.198],
|
1095
|
+
... [0.2657, 0.332, 0.399],
|
1096
|
+
... [0.2, 0.3, 0.4],
|
1097
|
+
... [0.5, 0.6, 0.7],
|
1098
|
+
... frequency=80,
|
1099
|
+
... lifetime=4,
|
1100
|
+
... reverse=True,
|
1101
|
+
... ) # doctest: +NUMBER
|
1102
|
+
(array([0.1, 0.2, 0.3]), array([0.4, 0.5, 0.6]))
|
1103
|
+
|
1104
|
+
"""
|
1105
|
+
re = numpy.asarray(real)
|
1106
|
+
im = numpy.asarray(imag)
|
1107
|
+
if re.shape != im.shape:
|
1108
|
+
raise ValueError(f'real.shape={re.shape} != imag.shape={im.shape}')
|
1109
|
+
ref_re = numpy.asarray(reference_real)
|
1110
|
+
ref_im = numpy.asarray(reference_imag)
|
1111
|
+
if ref_re.shape != ref_im.shape:
|
1112
|
+
raise ValueError(
|
1113
|
+
f'reference_real.shape={ref_re.shape} '
|
1114
|
+
f'!= reference_imag.shape{ref_im.shape}'
|
1115
|
+
)
|
1116
|
+
measured_re, measured_im = phasor_center(
|
1117
|
+
reference_real, reference_imag, skip_axis=skip_axis, method=method
|
1118
|
+
)
|
1119
|
+
known_re, known_im = phasor_from_lifetime(
|
1120
|
+
frequency,
|
1121
|
+
lifetime,
|
1122
|
+
fraction,
|
1123
|
+
preexponential=preexponential,
|
1124
|
+
unit_conversion=unit_conversion,
|
1125
|
+
)
|
1126
|
+
phi_zero, mod_zero = polar_from_reference_phasor(
|
1127
|
+
measured_re, measured_im, known_re, known_im
|
1128
|
+
)
|
1129
|
+
if numpy.ndim(phi_zero) > 0:
|
1130
|
+
if reverse:
|
1131
|
+
numpy.negative(phi_zero, out=phi_zero)
|
1132
|
+
numpy.reciprocal(mod_zero, out=mod_zero)
|
1133
|
+
_, axis = _parse_skip_axis(skip_axis, re.ndim)
|
1134
|
+
if axis is not None:
|
1135
|
+
phi_zero = numpy.expand_dims(
|
1136
|
+
phi_zero,
|
1137
|
+
axis=axis,
|
1138
|
+
)
|
1139
|
+
mod_zero = numpy.expand_dims(
|
1140
|
+
mod_zero,
|
1141
|
+
axis=axis,
|
1142
|
+
)
|
1143
|
+
elif reverse:
|
1144
|
+
phi_zero = -phi_zero
|
1145
|
+
mod_zero = 1.0 / mod_zero
|
1146
|
+
return phasor_transform(re, im, phi_zero, mod_zero)
|
1147
|
+
|
1148
|
+
|
1149
|
+
def phasor_transform(
|
1150
|
+
real: ArrayLike,
|
1151
|
+
imag: ArrayLike,
|
1152
|
+
phase: ArrayLike = 0.0,
|
1153
|
+
modulation: ArrayLike = 1.0,
|
1154
|
+
/,
|
1155
|
+
**kwargs: Any,
|
1156
|
+
) -> tuple[NDArray[Any], NDArray[Any]]:
|
1157
|
+
r"""Return rotated and scaled phasor coordinates.
|
1158
|
+
|
1159
|
+
This function rotates and uniformly scales phasor coordinates around the
|
1160
|
+
origin.
|
1161
|
+
It can be used, for example, to calibrate phasor coordinates.
|
1162
|
+
|
1163
|
+
Parameters
|
1164
|
+
----------
|
1165
|
+
real : array_like
|
1166
|
+
Real component of phasor coordinates to transform.
|
1167
|
+
imag : array_like
|
1168
|
+
Imaginary component of phasor coordinates to transform.
|
1169
|
+
phase : array_like, optional, default: 0.0
|
1170
|
+
Rotation angle in radians.
|
1171
|
+
modulation : array_like, optional, default: 1.0
|
1172
|
+
Uniform scale factor.
|
1173
|
+
**kwargs
|
1174
|
+
Optional `arguments passed to numpy universal functions
|
1175
|
+
<https://numpy.org/doc/stable/reference/ufuncs.html#ufuncs-kwargs>`_.
|
1176
|
+
|
1177
|
+
Returns
|
1178
|
+
-------
|
1179
|
+
real : ndarray
|
1180
|
+
Real component of rotated and scaled phasor coordinates.
|
1181
|
+
imag : ndarray
|
1182
|
+
Imaginary component of rotated and scaled phasor coordinates.
|
1183
|
+
|
1184
|
+
Notes
|
1185
|
+
-----
|
1186
|
+
The phasor coordinates `real` (:math:`G`) and `imag` (:math:`S`)
|
1187
|
+
are rotated by `phase` (:math:`\phi`)
|
1188
|
+
and scaled by `modulation_zero` (:math:`M`)
|
1189
|
+
around the origin according to:
|
1190
|
+
|
1191
|
+
.. math::
|
1192
|
+
|
1193
|
+
g &= M \cdot \cos{\phi}
|
1194
|
+
|
1195
|
+
s &= M \cdot \sin{\phi}
|
1196
|
+
|
1197
|
+
G' &= G \cdot g - S \cdot s
|
1198
|
+
|
1199
|
+
S' &= G \cdot s + S \cdot g
|
1200
|
+
|
1201
|
+
Examples
|
1202
|
+
--------
|
1203
|
+
Use scalar reference coordinates to rotate and scale phasor coordinates:
|
1204
|
+
|
1205
|
+
>>> phasor_transform(
|
1206
|
+
... [0.1, 0.2, 0.3], [0.4, 0.5, 0.6], 0.1, 0.5
|
1207
|
+
... ) # doctest: +NUMBER
|
1208
|
+
(array([0.0298, 0.0745, 0.119]), array([0.204, 0.259, 0.3135]))
|
1209
|
+
|
1210
|
+
Use separate reference coordinates for each phasor coordinate:
|
1211
|
+
|
1212
|
+
>>> phasor_transform(
|
1213
|
+
... [0.1, 0.2, 0.3], [0.4, 0.5, 0.6], [0.2, 0.2, 0.3], [0.5, 0.2, 0.3]
|
1214
|
+
... ) # doctest: +NUMBER
|
1215
|
+
(array([0.00927, 0.0193, 0.0328]), array([0.206, 0.106, 0.1986]))
|
1216
|
+
|
1217
|
+
"""
|
1218
|
+
if numpy.ndim(phase) == 0 and numpy.ndim(modulation) == 0:
|
1219
|
+
return _phasor_transform_const( # type: ignore[no-any-return]
|
1220
|
+
real,
|
1221
|
+
imag,
|
1222
|
+
modulation * numpy.cos(phase),
|
1223
|
+
modulation * numpy.sin(phase),
|
1224
|
+
)
|
1225
|
+
return _phasor_transform( # type: ignore[no-any-return]
|
1226
|
+
real, imag, phase, modulation, **kwargs
|
1227
|
+
)
|
1228
|
+
|
1229
|
+
|
1230
|
+
def polar_from_reference_phasor(
|
1231
|
+
measured_real: ArrayLike,
|
1232
|
+
measured_imag: ArrayLike,
|
1233
|
+
known_real: ArrayLike,
|
1234
|
+
known_imag: ArrayLike,
|
1235
|
+
/,
|
1236
|
+
**kwargs: Any,
|
1237
|
+
) -> tuple[NDArray[Any], NDArray[Any]]:
|
1238
|
+
r"""Return polar coordinates for calibration from reference phasor.
|
1239
|
+
|
1240
|
+
Return rotation angle and scale factor for calibrating phasor coordinates
|
1241
|
+
from measured and known phasor coordinates of a reference, for example,
|
1242
|
+
a sample of known lifetime.
|
1243
|
+
|
1244
|
+
Parameters
|
1245
|
+
----------
|
1246
|
+
measured_real : array_like
|
1247
|
+
Real component of measured phasor coordinates.
|
1248
|
+
measured_imag : array_like
|
1249
|
+
Imaginary component of measured phasor coordinates.
|
1250
|
+
known_real : array_like
|
1251
|
+
Real component of reference phasor coordinates.
|
1252
|
+
known_imag : array_like
|
1253
|
+
Imaginary component of reference phasor coordinates.
|
1254
|
+
**kwargs
|
1255
|
+
Optional `arguments passed to numpy universal functions
|
1256
|
+
<https://numpy.org/doc/stable/reference/ufuncs.html#ufuncs-kwargs>`_.
|
1257
|
+
|
1258
|
+
Returns
|
1259
|
+
-------
|
1260
|
+
phase_zero : ndarray
|
1261
|
+
Angular component of polar coordinates for calibration in radians.
|
1262
|
+
modulation_zero : ndarray
|
1263
|
+
Radial component of polar coordinates for calibration.
|
1264
|
+
|
1265
|
+
See Also
|
1266
|
+
--------
|
1267
|
+
phasorpy.phasor.polar_from_reference
|
1268
|
+
|
1269
|
+
Notes
|
1270
|
+
-----
|
1271
|
+
This function performs the following operations:
|
1272
|
+
|
1273
|
+
.. code-block:: python
|
1274
|
+
|
1275
|
+
polar_from_reference(
|
1276
|
+
*phasor_to_polar(measured_real, measured_imag),
|
1277
|
+
*phasor_to_polar(known_real, known_imag),
|
1278
|
+
)
|
1279
|
+
|
1280
|
+
Examples
|
1281
|
+
--------
|
1282
|
+
>>> polar_from_reference_phasor(0.5, 0.0, 1.0, 0.0)
|
1283
|
+
(0.0, 2.0)
|
1284
|
+
|
1285
|
+
"""
|
1286
|
+
return _polar_from_reference_phasor( # type: ignore[no-any-return]
|
1287
|
+
measured_real, measured_imag, known_real, known_imag, **kwargs
|
1288
|
+
)
|
1289
|
+
|
1290
|
+
|
1291
|
+
def polar_from_reference(
|
1292
|
+
measured_phase: ArrayLike,
|
1293
|
+
measured_modulation: ArrayLike,
|
1294
|
+
known_phase: ArrayLike,
|
1295
|
+
known_modulation: ArrayLike,
|
1296
|
+
/,
|
1297
|
+
**kwargs: Any,
|
1298
|
+
) -> tuple[NDArray[Any], NDArray[Any]]:
|
1299
|
+
r"""Return polar coordinates for calibration from reference coordinates.
|
1300
|
+
|
1301
|
+
Return rotation angle and scale factor for calibrating phasor coordinates
|
1302
|
+
from measured and known polar coordinates of a reference, for example,
|
1303
|
+
a sample of known lifetime.
|
1304
|
+
|
1305
|
+
Parameters
|
1306
|
+
----------
|
1307
|
+
measured_phase : array_like
|
1308
|
+
Angular component of measured polar coordinates in radians.
|
1309
|
+
measured_modulation : array_like
|
1310
|
+
Radial component of measured polar coordinates.
|
1311
|
+
known_phase : array_like
|
1312
|
+
Angular component of reference polar coordinates in radians.
|
1313
|
+
known_modulation : array_like
|
1314
|
+
Radial component of reference polar coordinates.
|
1315
|
+
**kwargs
|
1316
|
+
Optional `arguments passed to numpy universal functions
|
1317
|
+
<https://numpy.org/doc/stable/reference/ufuncs.html#ufuncs-kwargs>`_.
|
1318
|
+
|
1319
|
+
Returns
|
1320
|
+
-------
|
1321
|
+
phase_zero : ndarray
|
1322
|
+
Angular component of polar coordinates for calibration in radians.
|
1323
|
+
modulation_zero : ndarray
|
1324
|
+
Radial component of polar coordinates for calibration.
|
1325
|
+
|
1326
|
+
See Also
|
1327
|
+
--------
|
1328
|
+
phasorpy.phasor.polar_from_reference_phasor
|
1329
|
+
|
1330
|
+
Examples
|
1331
|
+
--------
|
1332
|
+
>>> polar_from_reference(0.2, 0.4, 0.4, 1.3)
|
1333
|
+
(0.2, 3.25)
|
1334
|
+
|
1335
|
+
"""
|
1336
|
+
return _polar_from_reference( # type: ignore[no-any-return]
|
1337
|
+
measured_phase,
|
1338
|
+
measured_modulation,
|
1339
|
+
known_phase,
|
1340
|
+
known_modulation,
|
1341
|
+
**kwargs,
|
1342
|
+
)
|
1343
|
+
|
1344
|
+
|
1345
|
+
def phasor_to_polar(
|
1346
|
+
real: ArrayLike,
|
1347
|
+
imag: ArrayLike,
|
1348
|
+
/,
|
1349
|
+
**kwargs: Any,
|
1350
|
+
) -> tuple[NDArray[Any], NDArray[Any]]:
|
1351
|
+
r"""Return polar coordinates from phasor coordinates.
|
1352
|
+
|
1353
|
+
Parameters
|
1354
|
+
----------
|
1355
|
+
real : array_like
|
1356
|
+
Real component of phasor coordinates.
|
1357
|
+
imag : array_like
|
1358
|
+
Imaginary component of phasor coordinates.
|
1359
|
+
**kwargs
|
1360
|
+
Optional `arguments passed to numpy universal functions
|
1361
|
+
<https://numpy.org/doc/stable/reference/ufuncs.html#ufuncs-kwargs>`_.
|
1362
|
+
|
1363
|
+
Notes
|
1364
|
+
-----
|
1365
|
+
The phasor coordinates `real` (:math:`G`) and `imag` (:math:`S`)
|
1366
|
+
are converted to polar coordinates `phase` (:math:`\phi`) and
|
1367
|
+
`modulation` (:math:`M`) according to:
|
1368
|
+
|
1369
|
+
.. math::
|
1370
|
+
|
1371
|
+
\phi &= \arctan(S / G)
|
1372
|
+
|
1373
|
+
M &= \sqrt{G^2 + S^2}
|
1374
|
+
|
1375
|
+
Returns
|
1376
|
+
-------
|
1377
|
+
phase : ndarray
|
1378
|
+
Angular component of polar coordinates in radians.
|
1379
|
+
modulation : ndarray
|
1380
|
+
Radial component of polar coordinates.
|
1381
|
+
|
1382
|
+
See Also
|
1383
|
+
--------
|
1384
|
+
phasorpy.phasor.phasor_from_polar
|
1385
|
+
|
1386
|
+
Examples
|
1387
|
+
--------
|
1388
|
+
Calculate polar coordinates from three phasor coordinates:
|
1389
|
+
|
1390
|
+
>>> phasor_to_polar([1.0, 0.5, 0.0], [0.0, 0.5, 1.0]) # doctest: +NUMBER
|
1391
|
+
(array([0, 0.7854, 1.571]), array([1, 0.7071, 1]))
|
1392
|
+
|
1393
|
+
"""
|
1394
|
+
return _phasor_to_polar( # type: ignore[no-any-return]
|
1395
|
+
real, imag, **kwargs
|
1396
|
+
)
|
1397
|
+
|
1398
|
+
|
1399
|
+
def phasor_from_polar(
|
1400
|
+
phase: ArrayLike,
|
1401
|
+
modulation: ArrayLike,
|
1402
|
+
/,
|
1403
|
+
**kwargs: Any,
|
1404
|
+
) -> tuple[NDArray[Any], NDArray[Any]]:
|
1405
|
+
r"""Return phasor coordinates from polar coordinates.
|
1406
|
+
|
1407
|
+
Parameters
|
1408
|
+
----------
|
1409
|
+
phase : array_like
|
1410
|
+
Angular component of polar coordinates in radians.
|
1411
|
+
modulation : array_like
|
1412
|
+
Radial component of polar coordinates.
|
1413
|
+
**kwargs
|
1414
|
+
Optional `arguments passed to numpy universal functions
|
1415
|
+
<https://numpy.org/doc/stable/reference/ufuncs.html#ufuncs-kwargs>`_.
|
1416
|
+
|
1417
|
+
Returns
|
1418
|
+
-------
|
1419
|
+
real : ndarray
|
1420
|
+
Real component of phasor coordinates.
|
1421
|
+
imag : ndarray
|
1422
|
+
Imaginary component of phasor coordinates.
|
1423
|
+
|
1424
|
+
See Also
|
1425
|
+
--------
|
1426
|
+
phasorpy.phasor.phasor_to_polar
|
1427
|
+
|
1428
|
+
Notes
|
1429
|
+
-----
|
1430
|
+
The polar coordinates `phase` (:math:`\phi`) and `modulation` (:math:`M`)
|
1431
|
+
are converted to phasor coordinates `real` (:math:`G`) and
|
1432
|
+
`imag` (:math:`S`) according to:
|
1433
|
+
|
1434
|
+
.. math::
|
1435
|
+
|
1436
|
+
G &= M \cdot \cos{\phi}
|
1437
|
+
|
1438
|
+
S &= M \cdot \sin{\phi}
|
1439
|
+
|
1440
|
+
Examples
|
1441
|
+
--------
|
1442
|
+
Calculate phasor coordinates from three polar coordinates:
|
1443
|
+
|
1444
|
+
>>> phasor_from_polar(
|
1445
|
+
... [0.0, math.pi / 4, math.pi / 2], [1.0, math.sqrt(0.5), 1.0]
|
1446
|
+
... ) # doctest: +NUMBER
|
1447
|
+
(array([1, 0.5, 0.0]), array([0, 0.5, 1]))
|
1448
|
+
|
1449
|
+
"""
|
1450
|
+
return _phasor_from_polar( # type: ignore[no-any-return]
|
1451
|
+
phase, modulation, **kwargs
|
1452
|
+
)
|
1453
|
+
|
1454
|
+
|
1455
|
+
def phasor_to_apparent_lifetime(
|
1456
|
+
real: ArrayLike,
|
1457
|
+
imag: ArrayLike,
|
1458
|
+
/,
|
1459
|
+
frequency: ArrayLike,
|
1460
|
+
*,
|
1461
|
+
unit_conversion: float = 1e-3,
|
1462
|
+
**kwargs: Any,
|
1463
|
+
) -> tuple[NDArray[Any], NDArray[Any]]:
|
1464
|
+
r"""Return apparent single lifetimes from phasor coordinates.
|
1465
|
+
|
1466
|
+
Parameters
|
1467
|
+
----------
|
1468
|
+
real : array_like
|
1469
|
+
Real component of phasor coordinates.
|
1470
|
+
imag : array_like
|
1471
|
+
Imaginary component of phasor coordinates.
|
1472
|
+
frequency : array_like
|
1473
|
+
Laser pulse or modulation frequency in MHz.
|
1474
|
+
unit_conversion : float, optional
|
1475
|
+
Product of `frequency` and returned `lifetime` units' prefix factors.
|
1476
|
+
The default is 1e-3 for MHz and ns, or Hz and ms.
|
1477
|
+
Use 1.0 for Hz and s.
|
1478
|
+
**kwargs
|
1479
|
+
Optional `arguments passed to numpy universal functions
|
1480
|
+
<https://numpy.org/doc/stable/reference/ufuncs.html#ufuncs-kwargs>`_.
|
1481
|
+
|
1482
|
+
Returns
|
1483
|
+
-------
|
1484
|
+
phase_lifetime : ndarray
|
1485
|
+
Apparent single lifetime from angular component of phasor coordinates.
|
1486
|
+
modulation_lifetime : ndarray
|
1487
|
+
Apparent single lifetime from radial component of phasor coordinates.
|
1488
|
+
|
1489
|
+
See Also
|
1490
|
+
--------
|
1491
|
+
phasorpy.phasor.phasor_from_apparent_lifetime
|
1492
|
+
|
1493
|
+
Notes
|
1494
|
+
-----
|
1495
|
+
The phasor coordinates `real` (:math:`G`) and `imag` (:math:`S`)
|
1496
|
+
are converted to apparent single lifetimes
|
1497
|
+
`phase_lifetime` (:math:`\tau_{\phi}`) and
|
1498
|
+
`modulation_lifetime` (:math:`\tau_{M}`) at frequency :math:`f`
|
1499
|
+
according to:
|
1500
|
+
|
1501
|
+
.. math::
|
1502
|
+
|
1503
|
+
\omega &= 2 \pi f
|
1504
|
+
|
1505
|
+
\tau_{\phi} &= \omega^{-1} \cdot S / G
|
1506
|
+
|
1507
|
+
\tau_{M} &= \omega^{-1} \cdot \sqrt{1 / (S^2 + G^2) - 1}
|
1508
|
+
|
1509
|
+
Examples
|
1510
|
+
--------
|
1511
|
+
The apparent single lifetimes from phase and modulation are equal
|
1512
|
+
only if the phasor coordinates lie on the universal semicircle:
|
1513
|
+
|
1514
|
+
>>> phasor_to_apparent_lifetime(
|
1515
|
+
... 0.5, [0.5, 0.45], frequency=80
|
1516
|
+
... ) # doctest: +NUMBER
|
1517
|
+
(array([1.989, 1.79]), array([1.989, 2.188]))
|
1518
|
+
|
1519
|
+
Apparent single lifetimes of phasor coordinates outside the universal
|
1520
|
+
semicircle are undefined:
|
1521
|
+
|
1522
|
+
>>> phasor_to_apparent_lifetime(-0.1, 1.1, 80) # doctest: +NUMBER
|
1523
|
+
(-21.8, 0.0)
|
1524
|
+
|
1525
|
+
Apparent single lifetimes at the universal semicircle endpoints are
|
1526
|
+
infinite and zero:
|
1527
|
+
|
1528
|
+
>>> phasor_to_apparent_lifetime([0, 1], [0, 0], 80) # doctest: +NUMBER
|
1529
|
+
(array([inf, 0]), array([inf, 0]))
|
1530
|
+
|
1531
|
+
"""
|
1532
|
+
omega = numpy.array(frequency, dtype=numpy.float64) # makes copy
|
1533
|
+
omega *= math.pi * 2.0 * unit_conversion
|
1534
|
+
return _phasor_to_apparent_lifetime( # type: ignore[no-any-return]
|
1535
|
+
real, imag, omega, **kwargs
|
1536
|
+
)
|
1537
|
+
|
1538
|
+
|
1539
|
+
def phasor_from_apparent_lifetime(
|
1540
|
+
phase_lifetime: ArrayLike,
|
1541
|
+
modulation_lifetime: ArrayLike | None,
|
1542
|
+
/,
|
1543
|
+
frequency: ArrayLike,
|
1544
|
+
*,
|
1545
|
+
unit_conversion: float = 1e-3,
|
1546
|
+
**kwargs: Any,
|
1547
|
+
) -> tuple[NDArray[Any], NDArray[Any]]:
|
1548
|
+
r"""Return phasor coordinates from apparent single lifetimes.
|
1549
|
+
|
1550
|
+
Parameters
|
1551
|
+
----------
|
1552
|
+
phase_lifetime : ndarray
|
1553
|
+
Apparent single lifetime from phase.
|
1554
|
+
modulation_lifetime : ndarray, optional
|
1555
|
+
Apparent single lifetime from modulation.
|
1556
|
+
If None, `modulation_lifetime` is same as `phase_lifetime`.
|
1557
|
+
frequency : array_like
|
1558
|
+
Laser pulse or modulation frequency in MHz.
|
1559
|
+
unit_conversion : float, optional
|
1560
|
+
Product of `frequency` and `lifetime` units' prefix factors.
|
1561
|
+
The default is 1e-3 for MHz and ns, or Hz and ms.
|
1562
|
+
Use 1.0 for Hz and s.
|
1563
|
+
**kwargs
|
1564
|
+
Optional `arguments passed to numpy universal functions
|
1565
|
+
<https://numpy.org/doc/stable/reference/ufuncs.html#ufuncs-kwargs>`_.
|
1566
|
+
|
1567
|
+
Returns
|
1568
|
+
-------
|
1569
|
+
real : ndarray
|
1570
|
+
Real component of phasor coordinates.
|
1571
|
+
imag : ndarray
|
1572
|
+
Imaginary component of phasor coordinates.
|
1573
|
+
|
1574
|
+
See Also
|
1575
|
+
--------
|
1576
|
+
phasorpy.phasor.phasor_to_apparent_lifetime
|
1577
|
+
|
1578
|
+
Notes
|
1579
|
+
-----
|
1580
|
+
The apparent single lifetimes `phase_lifetime` (:math:`\tau_{\phi}`)
|
1581
|
+
and `modulation_lifetime` (:math:`\tau_{M}`) are converted to phasor
|
1582
|
+
coordinates `real` (:math:`G`) and `imag` (:math:`S`) at
|
1583
|
+
frequency :math:`f` according to:
|
1584
|
+
|
1585
|
+
.. math::
|
1586
|
+
|
1587
|
+
\omega &= 2 \pi f
|
1588
|
+
|
1589
|
+
\phi & = \arctan(\omega \tau_{\phi})
|
1590
|
+
|
1591
|
+
M &= 1 / \sqrt{1 + (\omega \tau_{M})^2}
|
1592
|
+
|
1593
|
+
G &= M \cdot \cos{\phi}
|
1594
|
+
|
1595
|
+
S &= M \cdot \sin{\phi}
|
1596
|
+
|
1597
|
+
Examples
|
1598
|
+
--------
|
1599
|
+
If the apparent single lifetimes from phase and modulation are equal,
|
1600
|
+
the phasor coordinates lie on the universal semicircle, else inside:
|
1601
|
+
|
1602
|
+
>>> phasor_from_apparent_lifetime(
|
1603
|
+
... 1.9894, [1.9894, 2.4113], frequency=80.0
|
1604
|
+
... ) # doctest: +NUMBER
|
1605
|
+
(array([0.5, 0.45]), array([0.5, 0.45]))
|
1606
|
+
|
1607
|
+
Zero and infinite apparent single lifetimes define the endpoints of the
|
1608
|
+
universal semicircle:
|
1609
|
+
|
1610
|
+
>>> phasor_from_apparent_lifetime(
|
1611
|
+
... [0.0, 1e9], [0.0, 1e9], frequency=80
|
1612
|
+
... ) # doctest: +NUMBER
|
1613
|
+
(array([1, 0.0]), array([0, 0.0]))
|
1614
|
+
|
1615
|
+
"""
|
1616
|
+
omega = numpy.array(frequency, dtype=numpy.float64) # makes copy
|
1617
|
+
omega *= math.pi * 2.0 * unit_conversion
|
1618
|
+
if modulation_lifetime is None:
|
1619
|
+
return _phasor_from_single_lifetime( # type: ignore[no-any-return]
|
1620
|
+
phase_lifetime, omega, **kwargs
|
1621
|
+
)
|
1622
|
+
return _phasor_from_apparent_lifetime( # type: ignore[no-any-return]
|
1623
|
+
phase_lifetime, modulation_lifetime, omega, **kwargs
|
1624
|
+
)
|
1625
|
+
|
1626
|
+
|
1627
|
+
def lifetime_to_frequency(
|
1628
|
+
lifetime: ArrayLike,
|
1629
|
+
*,
|
1630
|
+
unit_conversion: float = 1e-3,
|
1631
|
+
) -> NDArray[numpy.float64]:
|
1632
|
+
r"""Return optimal frequency for resolving single component lifetime.
|
1633
|
+
|
1634
|
+
Parameters
|
1635
|
+
----------
|
1636
|
+
lifetime : array_like
|
1637
|
+
Single component lifetime.
|
1638
|
+
unit_conversion : float, optional, default: 1e-3
|
1639
|
+
Product of `frequency` and `lifetime` units' prefix factors.
|
1640
|
+
The default is 1e-3 for MHz and ns, or Hz and ms.
|
1641
|
+
Use 1.0 for Hz and s.
|
1642
|
+
|
1643
|
+
Returns
|
1644
|
+
-------
|
1645
|
+
frequency : ndarray
|
1646
|
+
Optimal laser pulse or modulation frequency for resolving `lifetime`.
|
1647
|
+
|
1648
|
+
Notes
|
1649
|
+
-----
|
1650
|
+
The optimal frequency :math:`f` to resolve a single component lifetime
|
1651
|
+
:math:`\tau` is
|
1652
|
+
(:ref:`Redford & Clegg 2005 <redford-clegg-2005>`. Eq. B.6):
|
1653
|
+
|
1654
|
+
.. math::
|
1655
|
+
|
1656
|
+
\omega &= 2 \pi f
|
1657
|
+
|
1658
|
+
\omega^2 &= \frac{1 + \sqrt{3}}{2 \tau^2}
|
1659
|
+
|
1660
|
+
Examples
|
1661
|
+
--------
|
1662
|
+
Measurements of a lifetime near 4 ns should be made at 47 MHz,
|
1663
|
+
near 1 ns at 186 MHz:
|
1664
|
+
|
1665
|
+
>>> lifetime_to_frequency([4.0, 1.0]) # doctest: +NUMBER
|
1666
|
+
array([46.5, 186])
|
1667
|
+
|
1668
|
+
"""
|
1669
|
+
t = numpy.reciprocal(lifetime, dtype=numpy.float64)
|
1670
|
+
t *= 0.18601566519848653 / unit_conversion
|
1671
|
+
return t
|
1672
|
+
|
1673
|
+
|
1674
|
+
def lifetime_from_frequency(
|
1675
|
+
frequency: ArrayLike,
|
1676
|
+
*,
|
1677
|
+
unit_conversion: float = 1e-3,
|
1678
|
+
) -> NDArray[numpy.float64]:
|
1679
|
+
r"""Return single component lifetime best resolved at frequency.
|
1680
|
+
|
1681
|
+
Parameters
|
1682
|
+
----------
|
1683
|
+
frequency : array_like
|
1684
|
+
Laser pulse or modulation frequency.
|
1685
|
+
unit_conversion : float, optional, default: 1e-3
|
1686
|
+
Product of `frequency` and `lifetime` units' prefix factors.
|
1687
|
+
The default is 1e-3 for MHz and ns, or Hz and ms.
|
1688
|
+
Use 1.0 for Hz and s.
|
1689
|
+
|
1690
|
+
Returns
|
1691
|
+
-------
|
1692
|
+
lifetime : ndarray
|
1693
|
+
Single component lifetime best resolved at `frequency`.
|
1694
|
+
|
1695
|
+
Notes
|
1696
|
+
-----
|
1697
|
+
The lifetime :math:`\tau` that is best resolved at frequency :math:`f` is
|
1698
|
+
(:ref:`Redford & Clegg 2005 <redford-clegg-2005>`. Eq. B.6):
|
1699
|
+
|
1700
|
+
.. math::
|
1701
|
+
|
1702
|
+
\omega &= 2 \pi f
|
1703
|
+
|
1704
|
+
\tau^2 &= \frac{1 + \sqrt{3}}{2 \omega^2}
|
1705
|
+
|
1706
|
+
Examples
|
1707
|
+
--------
|
1708
|
+
Measurements at frequencies of 47 and 186 MHz are best for measuring
|
1709
|
+
lifetimes near 4 and 1 ns respectively:
|
1710
|
+
|
1711
|
+
>>> lifetime_from_frequency([46.5, 186]) # doctest: +NUMBER
|
1712
|
+
array([4, 1])
|
1713
|
+
|
1714
|
+
"""
|
1715
|
+
t = numpy.reciprocal(frequency, dtype=numpy.float64)
|
1716
|
+
t *= 0.18601566519848653 / unit_conversion
|
1717
|
+
return t
|
1718
|
+
|
1719
|
+
|
1720
|
+
def lifetime_fraction_to_amplitude(
|
1721
|
+
lifetime: ArrayLike, fraction: ArrayLike, *, axis: int = -1
|
1722
|
+
) -> NDArray[numpy.float64]:
|
1723
|
+
r"""Return pre-exponential amplitude from fractional intensity.
|
1724
|
+
|
1725
|
+
Parameters
|
1726
|
+
----------
|
1727
|
+
lifetime : array_like
|
1728
|
+
Lifetime components.
|
1729
|
+
fraction : array_like
|
1730
|
+
Fractional intensities of lifetime components.
|
1731
|
+
Fractions are normalized to sum to 1.
|
1732
|
+
axis : int, optional
|
1733
|
+
Axis over which to compute pre-exponential amplitudes.
|
1734
|
+
The default is the last axis (-1).
|
1735
|
+
|
1736
|
+
Returns
|
1737
|
+
-------
|
1738
|
+
amplitude : ndarray
|
1739
|
+
Pre-exponential amplitudes.
|
1740
|
+
The product of `amplitude` and `lifetime` sums to 1 along `axis`.
|
1741
|
+
|
1742
|
+
See Also
|
1743
|
+
--------
|
1744
|
+
phasorpy.phasor.lifetime_fraction_from_amplitude
|
1745
|
+
|
1746
|
+
Notes
|
1747
|
+
-----
|
1748
|
+
The pre-exponential amplitude :math:`a` of component :math:`j` with
|
1749
|
+
lifetime :math:`\tau` and fractional intensity :math:`\alpha` is:
|
1750
|
+
|
1751
|
+
.. math::
|
1752
|
+
|
1753
|
+
a_{j} = \frac{\alpha_{j}}{\tau_{j} \cdot \sum_{j} \alpha_{j}}
|
1754
|
+
|
1755
|
+
Examples
|
1756
|
+
--------
|
1757
|
+
>>> lifetime_fraction_to_amplitude(
|
1758
|
+
... [4.0, 1.0], [1.6, 0.4]
|
1759
|
+
... ) # doctest: +NUMBER
|
1760
|
+
array([0.2, 0.2])
|
1761
|
+
|
1762
|
+
"""
|
1763
|
+
t = numpy.array(fraction, dtype=numpy.float64) # makes copy
|
1764
|
+
t /= numpy.sum(t, axis=axis, keepdims=True)
|
1765
|
+
numpy.true_divide(t, lifetime, out=t)
|
1766
|
+
return t
|
1767
|
+
|
1768
|
+
|
1769
|
+
def lifetime_fraction_from_amplitude(
|
1770
|
+
lifetime: ArrayLike, amplitude: ArrayLike, *, axis: int = -1
|
1771
|
+
) -> NDArray[numpy.float64]:
|
1772
|
+
r"""Return fractional intensity from pre-exponential amplitude.
|
1773
|
+
|
1774
|
+
Parameters
|
1775
|
+
----------
|
1776
|
+
lifetime : array_like
|
1777
|
+
Lifetime of components.
|
1778
|
+
amplitude : array_like
|
1779
|
+
Pre-exponential amplitudes of lifetime components.
|
1780
|
+
axis : int, optional
|
1781
|
+
Axis over which to compute fractional intensities.
|
1782
|
+
The default is the last axis (-1).
|
1783
|
+
|
1784
|
+
Returns
|
1785
|
+
-------
|
1786
|
+
fraction : ndarray
|
1787
|
+
Fractional intensities, normalized to sum to 1 along `axis`.
|
1788
|
+
|
1789
|
+
See Also
|
1790
|
+
--------
|
1791
|
+
phasorpy.phasor.lifetime_fraction_to_amplitude
|
1792
|
+
|
1793
|
+
Notes
|
1794
|
+
-----
|
1795
|
+
The fractional intensity :math:`\alpha` of component :math:`j` with
|
1796
|
+
lifetime :math:`\tau` and pre-exponential amplitude :math:`a` is:
|
1797
|
+
|
1798
|
+
.. math::
|
1799
|
+
|
1800
|
+
\alpha_{j} = \frac{a_{j} \tau_{j}}{\sum_{j} a_{j} \tau_{j}}
|
1801
|
+
|
1802
|
+
Examples
|
1803
|
+
--------
|
1804
|
+
>>> lifetime_fraction_from_amplitude(
|
1805
|
+
... [4.0, 1.0], [1.0, 1.0]
|
1806
|
+
... ) # doctest: +NUMBER
|
1807
|
+
array([0.8, 0.2])
|
1808
|
+
|
1809
|
+
"""
|
1810
|
+
t = numpy.multiply(amplitude, lifetime, dtype=numpy.float64)
|
1811
|
+
t /= numpy.sum(t, axis=axis, keepdims=True)
|
1812
|
+
return t
|
1813
|
+
|
1814
|
+
|
1815
|
+
def phasor_at_harmonic(
|
1816
|
+
real: ArrayLike,
|
1817
|
+
harmonic: ArrayLike,
|
1818
|
+
other_harmonic: ArrayLike,
|
1819
|
+
/,
|
1820
|
+
**kwargs: Any,
|
1821
|
+
) -> tuple[NDArray[numpy.float64], NDArray[numpy.float64]]:
|
1822
|
+
r"""Return phasor coordinates on universal semicircle at other harmonics.
|
1823
|
+
|
1824
|
+
Return phasor coordinates at any harmonic, given the real component of
|
1825
|
+
phasor coordinates of a single exponential lifetime at a certain harmonic.
|
1826
|
+
The input and output phasor coordinates lie on the universal semicircle.
|
1827
|
+
|
1828
|
+
Parameters
|
1829
|
+
----------
|
1830
|
+
real : array_like
|
1831
|
+
Real component of phasor coordinates of single exponential lifetime
|
1832
|
+
at `harmonic`.
|
1833
|
+
harmonic : array_like
|
1834
|
+
Harmonic of `real` coordinate. Must be integer >= 1.
|
1835
|
+
other_harmonic : array_like
|
1836
|
+
Harmonic for which to return phasor coordinates. Must be integer >= 1.
|
1837
|
+
**kwargs
|
1838
|
+
Optional `arguments passed to numpy universal functions
|
1839
|
+
<https://numpy.org/doc/stable/reference/ufuncs.html#ufuncs-kwargs>`_.
|
1840
|
+
|
1841
|
+
Returns
|
1842
|
+
-------
|
1843
|
+
real_other : ndarray
|
1844
|
+
Real component of phasor coordinates at `other_harmonic`.
|
1845
|
+
imag_other : ndarray
|
1846
|
+
Imaginary component of phasor coordinates at `other_harmonic`.
|
1847
|
+
|
1848
|
+
Notes
|
1849
|
+
-----
|
1850
|
+
The phasor coordinates
|
1851
|
+
:math:`g_{n}` (`real_other`) and :math:`s_{n}` (`imag_other`)
|
1852
|
+
of a single exponential lifetime at harmonic :math:`n` (`other_harmonic`)
|
1853
|
+
is calculated from the real part of the phasor coordinates
|
1854
|
+
:math:`g_{m}` (`real`) at harmonic :math:`m` (`harmonic`) according to
|
1855
|
+
(:ref:`Torrado, Malacrida, & Ranjit. 2022 <torrado-2022>`. Eq. 25):
|
1856
|
+
|
1857
|
+
.. math::
|
1858
|
+
|
1859
|
+
g_{n} &= \frac{m^2 \cdot g_{m}}{n^2 + (m^2-n^2) \cdot g_{m}}
|
1860
|
+
|
1861
|
+
s_{n} &= \sqrt{G_{n} - g_{n}^2}
|
1862
|
+
|
1863
|
+
This function is equivalent to the following operations:
|
1864
|
+
|
1865
|
+
.. code-block:: python
|
1866
|
+
|
1867
|
+
phasor_from_lifetime(
|
1868
|
+
frequency=other_harmonic,
|
1869
|
+
lifetime=phasor_to_apparent_lifetime(
|
1870
|
+
real, sqrt(real - real * real), frequency=harmonic
|
1871
|
+
)[0],
|
1872
|
+
)
|
1873
|
+
|
1874
|
+
Examples
|
1875
|
+
--------
|
1876
|
+
The phasor coordinates at higher harmonics are approaching the origin:
|
1877
|
+
|
1878
|
+
>>> phasor_at_harmonic(0.5, 1, [1, 2, 4, 8]) # doctest: +NUMBER
|
1879
|
+
(array([0.5, 0.2, 0.05882, 0.01538]), array([0.5, 0.4, 0.2353, 0.1231]))
|
1880
|
+
|
1881
|
+
"""
|
1882
|
+
harmonic = numpy.asarray(harmonic, dtype=numpy.int32)
|
1883
|
+
if numpy.any(harmonic < 1):
|
1884
|
+
raise ValueError('invalid harmonic')
|
1885
|
+
|
1886
|
+
other_harmonic = numpy.asarray(other_harmonic, dtype=numpy.int32)
|
1887
|
+
if numpy.any(other_harmonic < 1):
|
1888
|
+
raise ValueError('invalid other_harmonic')
|
1889
|
+
|
1890
|
+
return _phasor_at_harmonic( # type: ignore[no-any-return]
|
1891
|
+
real, harmonic, other_harmonic, **kwargs
|
1892
|
+
)
|
1893
|
+
|
1894
|
+
|
1895
|
+
def phasor_from_lifetime(
|
1896
|
+
frequency: ArrayLike,
|
1897
|
+
lifetime: ArrayLike,
|
1898
|
+
fraction: ArrayLike | None = None,
|
1899
|
+
*,
|
1900
|
+
preexponential: bool = False,
|
1901
|
+
unit_conversion: float = 1e-3,
|
1902
|
+
keepdims: bool = False,
|
1903
|
+
) -> tuple[NDArray[numpy.float64], NDArray[numpy.float64]]:
|
1904
|
+
r"""Return phasor coordinates from lifetime components.
|
1905
|
+
|
1906
|
+
Calculate phasor coordinates as a function of frequency, single or
|
1907
|
+
multiple lifetime components, and the pre-exponential amplitudes
|
1908
|
+
or fractional intensities of the components.
|
1909
|
+
|
1910
|
+
Parameters
|
1911
|
+
----------
|
1912
|
+
frequency : array_like
|
1913
|
+
Laser pulse or modulation frequency in MHz.
|
1914
|
+
A scalar or one-dimensional sequence.
|
1915
|
+
lifetime : array_like
|
1916
|
+
Lifetime components in ns. See notes below for allowed dimensions.
|
1917
|
+
fraction : array_like, optional
|
1918
|
+
Fractional intensities or pre-exponential amplitudes of the lifetime
|
1919
|
+
components. Fractions are normalized to sum to 1.
|
1920
|
+
See notes below for allowed dimensions.
|
1921
|
+
preexponential : bool, optional, default: False
|
1922
|
+
If true, `fraction` values are pre-exponential amplitudes,
|
1923
|
+
else fractional intensities.
|
1924
|
+
unit_conversion : float, optional, default: 1e-3
|
1925
|
+
Product of `frequency` and `lifetime` units' prefix factors.
|
1926
|
+
The default is 1e-3 for MHz and ns, or Hz and ms.
|
1927
|
+
Use 1.0 for Hz and s.
|
1928
|
+
keepdims : bool, optional, default: False
|
1929
|
+
If true, length-one dimensions are left in phasor coordinates.
|
1930
|
+
|
1931
|
+
Returns
|
1932
|
+
-------
|
1933
|
+
real : ndarray
|
1934
|
+
Real component of phasor coordinates.
|
1935
|
+
imag : ndarray
|
1936
|
+
Imaginary component of phasor coordinates.
|
1937
|
+
|
1938
|
+
See notes below for dimensions of the returned arrays.
|
1939
|
+
|
1940
|
+
Raises
|
1941
|
+
------
|
1942
|
+
ValueError
|
1943
|
+
Input arrays exceed their allowed dimensionality or do not match.
|
1944
|
+
|
1945
|
+
Notes
|
1946
|
+
-----
|
1947
|
+
The phasor coordinates :math:`G` (`real`) and :math:`S` (`imag`) for
|
1948
|
+
many lifetime components :math:`j` with lifetimes :math:`\tau` and
|
1949
|
+
pre-exponential amplitudes :math:`\alpha` at frequency :math:`f` are:
|
1950
|
+
|
1951
|
+
.. math::
|
1952
|
+
|
1953
|
+
\omega &= 2 \pi f
|
1954
|
+
|
1955
|
+
g_{j} &= \alpha_{j} / (1 + (\omega \tau_{j})^2)
|
1956
|
+
|
1957
|
+
G &= \sum_{j} g_{j}
|
1958
|
+
|
1959
|
+
S &= \sum_{j} \omega \tau_{j} g_{j}
|
1960
|
+
|
1961
|
+
The relation between pre-exponential amplitudes :math:`a` and
|
1962
|
+
fractional intensities :math:`\alpha` is:
|
1963
|
+
|
1964
|
+
.. math::
|
1965
|
+
F_{DC} &= \sum_{j} a_{j} \tau_{j}
|
1966
|
+
|
1967
|
+
\alpha_{j} &= a_{j} \tau_{j} / F_{DC}
|
1968
|
+
|
1969
|
+
The following combinations of `lifetime` and `fraction` parameters are
|
1970
|
+
supported:
|
1971
|
+
|
1972
|
+
- `lifetime` is scalar or one-dimensional, holding single component
|
1973
|
+
lifetimes. `fraction` is None.
|
1974
|
+
Return arrays of shape `(frequency.size, lifetime.size)`.
|
1975
|
+
|
1976
|
+
- `lifetime` is two-dimensional, `fraction` is one-dimensional.
|
1977
|
+
The last dimensions match in size, holding lifetime components and
|
1978
|
+
their fractions.
|
1979
|
+
Return arrays of shape `(frequency.size, lifetime.shape[1])`.
|
1980
|
+
|
1981
|
+
- `lifetime` is one-dimensional, `fraction` is two-dimensional.
|
1982
|
+
The last dimensions must match in size, holding lifetime components and
|
1983
|
+
their fractions.
|
1984
|
+
Return arrays of shape `(frequency.size, fraction.shape[1])`.
|
1985
|
+
|
1986
|
+
- `lifetime` and `fraction` are up to two-dimensional of same shape.
|
1987
|
+
The last dimensions hold lifetime components and their fractions.
|
1988
|
+
Return arrays of shape `(frequency.size, lifetime.shape[0])`.
|
1989
|
+
|
1990
|
+
Length-one dimensions are removed from returned arrays
|
1991
|
+
if `keepdims` is false (default).
|
1992
|
+
|
1993
|
+
Examples
|
1994
|
+
--------
|
1995
|
+
Phasor coordinates of a single lifetime component (in ns) at a
|
1996
|
+
frequency of 80 MHz:
|
1997
|
+
|
1998
|
+
>>> phasor_from_lifetime(80.0, 1.9894368) # doctest: +NUMBER
|
1999
|
+
(0.5, 0.5)
|
2000
|
+
|
2001
|
+
Phasor coordinates of two lifetime components with equal fractional
|
2002
|
+
intensities:
|
2003
|
+
|
2004
|
+
>>> phasor_from_lifetime(
|
2005
|
+
... 80.0, [3.9788735, 0.9947183], [0.5, 0.5]
|
2006
|
+
... ) # doctest: +NUMBER
|
2007
|
+
(0.5, 0.4)
|
2008
|
+
|
2009
|
+
Phasor coordinates of two lifetime components with equal pre-exponential
|
2010
|
+
amplitudes:
|
2011
|
+
|
2012
|
+
>>> phasor_from_lifetime(
|
2013
|
+
... 80.0, [3.9788735, 0.9947183], [0.5, 0.5], preexponential=True
|
2014
|
+
... ) # doctest: +NUMBER
|
2015
|
+
(0.32, 0.4)
|
2016
|
+
|
2017
|
+
Phasor coordinates of many single-component lifetimes (fractions omitted):
|
2018
|
+
|
2019
|
+
>>> phasor_from_lifetime(
|
2020
|
+
... 80.0, [3.9788735, 1.9894368, 0.9947183]
|
2021
|
+
... ) # doctest: +NUMBER
|
2022
|
+
(array([0.2, 0.5, 0.8]), array([0.4, 0.5, 0.4]))
|
2023
|
+
|
2024
|
+
Phasor coordinates of two lifetime components with varying fractions:
|
2025
|
+
|
2026
|
+
>>> phasor_from_lifetime(
|
2027
|
+
... 80.0, [3.9788735, 0.9947183], [[1, 0], [0.5, 0.5], [0, 1]]
|
2028
|
+
... ) # doctest: +NUMBER
|
2029
|
+
(array([0.2, 0.5, 0.8]), array([0.4, 0.4, 0.4]))
|
2030
|
+
|
2031
|
+
Phasor coordinates of multiple two-component lifetimes with constant
|
2032
|
+
fractions, keeping dimensions:
|
2033
|
+
|
2034
|
+
>>> phasor_from_lifetime(
|
2035
|
+
... 80.0, [[3.9788735, 0.9947183], [1.9894368, 1.9894368]], [0.5, 0.5]
|
2036
|
+
... ) # doctest: +NUMBER
|
2037
|
+
(array([0.5, 0.5]), array([0.4, 0.5]))
|
2038
|
+
|
2039
|
+
Phasor coordinates of multiple two-component lifetimes with specific
|
2040
|
+
fractions at multiple frequencies. Frequencies are in Hz, lifetimes in ns:
|
2041
|
+
|
2042
|
+
>>> phasor_from_lifetime(
|
2043
|
+
... [40e6, 80e6],
|
2044
|
+
... [[1e-9, 0.9947183e-9], [3.9788735e-9, 0.9947183e-9]],
|
2045
|
+
... [[0, 1], [0.5, 0.5]],
|
2046
|
+
... unit_conversion=1.0,
|
2047
|
+
... ) # doctest: +NUMBER
|
2048
|
+
(array([[0.941, 0.721], [0.8, 0.5]]), array([[0.235, 0.368], [0.4, 0.4]]))
|
2049
|
+
|
2050
|
+
"""
|
2051
|
+
if unit_conversion < 1e-16:
|
2052
|
+
raise ValueError(f'{unit_conversion=} < 1e-16')
|
2053
|
+
frequency = numpy.atleast_1d(numpy.asarray(frequency, dtype=numpy.float64))
|
2054
|
+
if frequency.ndim != 1:
|
2055
|
+
raise ValueError('frequency is not one-dimensional array')
|
2056
|
+
lifetime = numpy.atleast_1d(numpy.asarray(lifetime, dtype=numpy.float64))
|
2057
|
+
if lifetime.ndim > 2:
|
2058
|
+
raise ValueError('lifetime must be one- or two-dimensional array')
|
2059
|
+
|
2060
|
+
if fraction is None:
|
2061
|
+
# single-component lifetimes
|
2062
|
+
if lifetime.ndim > 1:
|
2063
|
+
raise ValueError(
|
2064
|
+
'lifetime must be one-dimensional array if fraction is None'
|
2065
|
+
)
|
2066
|
+
lifetime = lifetime.reshape(-1, 1) # move components to last axis
|
2067
|
+
fraction = numpy.ones_like(lifetime) # not really used
|
2068
|
+
else:
|
2069
|
+
fraction = numpy.atleast_1d(
|
2070
|
+
numpy.asarray(fraction, dtype=numpy.float64)
|
2071
|
+
)
|
2072
|
+
if fraction.ndim > 2:
|
2073
|
+
raise ValueError('fraction must be one- or two-dimensional array')
|
2074
|
+
|
2075
|
+
if lifetime.ndim == 1 and fraction.ndim == 1:
|
2076
|
+
# one multi-component lifetime
|
2077
|
+
if lifetime.shape != fraction.shape:
|
2078
|
+
raise ValueError(
|
2079
|
+
f'{lifetime.shape=} does not match {fraction.shape=}'
|
2080
|
+
)
|
2081
|
+
lifetime = lifetime.reshape(1, -1)
|
2082
|
+
fraction = fraction.reshape(1, -1)
|
2083
|
+
nvar = 1
|
2084
|
+
elif lifetime.ndim == 2 and fraction.ndim == 2:
|
2085
|
+
# multiple, multi-component lifetimes
|
2086
|
+
if lifetime.shape[1] != fraction.shape[1]:
|
2087
|
+
raise ValueError(f'{lifetime.shape[1]=} != {fraction.shape[1]=}')
|
2088
|
+
nvar = lifetime.shape[0]
|
2089
|
+
elif lifetime.ndim == 2 and fraction.ndim == 1:
|
2090
|
+
# variable components, same fractions
|
2091
|
+
fraction = fraction.reshape(1, -1)
|
2092
|
+
nvar = lifetime.shape[0]
|
2093
|
+
elif lifetime.ndim == 1 and fraction.ndim == 2:
|
2094
|
+
# same components, varying fractions
|
2095
|
+
lifetime = lifetime.reshape(1, -1)
|
2096
|
+
nvar = fraction.shape[0]
|
2097
|
+
else:
|
2098
|
+
# unreachable code
|
2099
|
+
raise RuntimeError(f'{lifetime.shape=}, {fraction.shape=}')
|
2100
|
+
|
2101
|
+
phasor = numpy.empty((2, frequency.size, nvar), dtype=numpy.float64)
|
2102
|
+
|
2103
|
+
_phasor_from_lifetime(
|
2104
|
+
phasor, frequency, lifetime, fraction, unit_conversion, preexponential
|
2105
|
+
)
|
2106
|
+
|
2107
|
+
if not keepdims:
|
2108
|
+
phasor = phasor.squeeze()
|
2109
|
+
return phasor[0], phasor[1]
|
2110
|
+
|
2111
|
+
|
2112
|
+
def polar_to_apparent_lifetime(
|
2113
|
+
phase: ArrayLike,
|
2114
|
+
modulation: ArrayLike,
|
2115
|
+
/,
|
2116
|
+
frequency: ArrayLike,
|
2117
|
+
*,
|
2118
|
+
unit_conversion: float = 1e-3,
|
2119
|
+
**kwargs: Any,
|
2120
|
+
) -> tuple[NDArray[Any], NDArray[Any]]:
|
2121
|
+
r"""Return apparent single lifetimes from polar coordinates.
|
2122
|
+
|
2123
|
+
Parameters
|
2124
|
+
----------
|
2125
|
+
phase : array_like
|
2126
|
+
Angular component of polar coordinates.
|
2127
|
+
imag : array_like
|
2128
|
+
Radial component of polar coordinates.
|
2129
|
+
frequency : array_like
|
2130
|
+
Laser pulse or modulation frequency in MHz.
|
2131
|
+
unit_conversion : float, optional
|
2132
|
+
Product of `frequency` and returned `lifetime` units' prefix factors.
|
2133
|
+
The default is 1e-3 for MHz and ns, or Hz and ms.
|
2134
|
+
Use 1.0 for Hz and s.
|
2135
|
+
**kwargs
|
2136
|
+
Optional `arguments passed to numpy universal functions
|
2137
|
+
<https://numpy.org/doc/stable/reference/ufuncs.html#ufuncs-kwargs>`_.
|
2138
|
+
|
2139
|
+
Returns
|
2140
|
+
-------
|
2141
|
+
phase_lifetime : ndarray
|
2142
|
+
Apparent single lifetime from `phase`.
|
2143
|
+
modulation_lifetime : ndarray
|
2144
|
+
Apparent single lifetime from `modulation`.
|
2145
|
+
|
2146
|
+
See Also
|
2147
|
+
--------
|
2148
|
+
phasorpy.phasor.polar_from_apparent_lifetime
|
2149
|
+
|
2150
|
+
Notes
|
2151
|
+
-----
|
2152
|
+
The polar coordinates `phase` (:math:`\phi`) and `modulation` (:math:`M`)
|
2153
|
+
are converted to apparent single lifetimes
|
2154
|
+
`phase_lifetime` (:math:`\tau_{\phi}`) and
|
2155
|
+
`modulation_lifetime` (:math:`\tau_{M}`) at frequency :math:`f`
|
2156
|
+
according to:
|
2157
|
+
|
2158
|
+
.. math::
|
2159
|
+
|
2160
|
+
\omega &= 2 \pi f
|
2161
|
+
|
2162
|
+
\tau_{\phi} &= \omega^{-1} \cdot \tan{\phi}
|
2163
|
+
|
2164
|
+
\tau_{M} &= \omega^{-1} \cdot \sqrt{1 / M^2 - 1}
|
2165
|
+
|
2166
|
+
Examples
|
2167
|
+
--------
|
2168
|
+
The apparent single lifetimes from phase and modulation are equal
|
2169
|
+
only if the polar coordinates lie on the universal semicircle:
|
2170
|
+
|
2171
|
+
>>> polar_to_apparent_lifetime(
|
2172
|
+
... math.pi / 4, numpy.hypot([0.5, 0.45], [0.5, 0.45]), frequency=80
|
2173
|
+
... ) # doctest: +NUMBER
|
2174
|
+
(array([1.989, 1.989]), array([1.989, 2.411]))
|
2175
|
+
|
2176
|
+
"""
|
2177
|
+
omega = numpy.array(frequency, dtype=numpy.float64) # makes copy
|
2178
|
+
omega *= math.pi * 2.0 * unit_conversion
|
2179
|
+
return _polar_to_apparent_lifetime( # type: ignore[no-any-return]
|
2180
|
+
phase, modulation, omega, **kwargs
|
2181
|
+
)
|
2182
|
+
|
2183
|
+
|
2184
|
+
def polar_from_apparent_lifetime(
|
2185
|
+
phase_lifetime: ArrayLike,
|
2186
|
+
modulation_lifetime: ArrayLike | None,
|
2187
|
+
/,
|
2188
|
+
frequency: ArrayLike,
|
2189
|
+
*,
|
2190
|
+
unit_conversion: float = 1e-3,
|
2191
|
+
**kwargs: Any,
|
2192
|
+
) -> tuple[NDArray[Any], NDArray[Any]]:
|
2193
|
+
r"""Return polar coordinates from apparent single lifetimes.
|
2194
|
+
|
2195
|
+
Parameters
|
2196
|
+
----------
|
2197
|
+
phase_lifetime : ndarray
|
2198
|
+
Apparent single lifetime from phase.
|
2199
|
+
modulation_lifetime : ndarray, optional
|
2200
|
+
Apparent single lifetime from modulation.
|
2201
|
+
If None, `modulation_lifetime` is same as `phase_lifetime`.
|
2202
|
+
frequency : array_like
|
2203
|
+
Laser pulse or modulation frequency in MHz.
|
2204
|
+
unit_conversion : float, optional
|
2205
|
+
Product of `frequency` and `lifetime` units' prefix factors.
|
2206
|
+
The default is 1e-3 for MHz and ns, or Hz and ms.
|
2207
|
+
Use 1.0 for Hz and s.
|
2208
|
+
**kwargs
|
2209
|
+
Optional `arguments passed to numpy universal functions
|
2210
|
+
<https://numpy.org/doc/stable/reference/ufuncs.html#ufuncs-kwargs>`_.
|
2211
|
+
|
2212
|
+
Returns
|
2213
|
+
-------
|
2214
|
+
phase : ndarray
|
2215
|
+
Angular component of polar coordinates.
|
2216
|
+
modulation : ndarray
|
2217
|
+
Radial component of polar coordinates.
|
2218
|
+
|
2219
|
+
See Also
|
2220
|
+
--------
|
2221
|
+
phasorpy.phasor.polar_to_apparent_lifetime
|
2222
|
+
|
2223
|
+
Notes
|
2224
|
+
-----
|
2225
|
+
The apparent single lifetimes `phase_lifetime` (:math:`\tau_{\phi}`)
|
2226
|
+
and `modulation_lifetime` (:math:`\tau_{M}`) are converted to polar
|
2227
|
+
coordinates `phase` (:math:`\phi`) and `modulation` (:math:`M`) at
|
2228
|
+
frequency :math:`f` according to:
|
2229
|
+
|
2230
|
+
.. math::
|
2231
|
+
|
2232
|
+
\omega &= 2 \pi f
|
2233
|
+
|
2234
|
+
\phi & = \arctan(\omega \tau_{\phi})
|
2235
|
+
|
2236
|
+
M &= 1 / \sqrt{1 + (\omega \tau_{M})^2}
|
2237
|
+
|
2238
|
+
Examples
|
2239
|
+
--------
|
2240
|
+
If the apparent single lifetimes from phase and modulation are equal,
|
2241
|
+
the polar coordinates lie on the universal semicircle, else inside:
|
2242
|
+
|
2243
|
+
>>> polar_from_apparent_lifetime(
|
2244
|
+
... 1.9894, [1.9894, 2.4113], frequency=80.0
|
2245
|
+
... ) # doctest: +NUMBER
|
2246
|
+
(array([0.7854, 0.7854]), array([0.7071, 0.6364]))
|
2247
|
+
|
2248
|
+
"""
|
2249
|
+
omega = numpy.array(frequency, dtype=numpy.float64) # makes copy
|
2250
|
+
omega *= math.pi * 2.0 * unit_conversion
|
2251
|
+
if modulation_lifetime is None:
|
2252
|
+
return _polar_from_single_lifetime( # type: ignore[no-any-return]
|
2253
|
+
phase_lifetime, omega, **kwargs
|
2254
|
+
)
|
2255
|
+
return _polar_from_apparent_lifetime( # type: ignore[no-any-return]
|
2256
|
+
phase_lifetime, modulation_lifetime, omega, **kwargs
|
2257
|
+
)
|
2258
|
+
|
2259
|
+
|
2260
|
+
def phasor_from_fret_donor(
|
2261
|
+
frequency: ArrayLike,
|
2262
|
+
donor_lifetime: ArrayLike,
|
2263
|
+
*,
|
2264
|
+
fret_efficiency: ArrayLike = 0.0,
|
2265
|
+
donor_freting: ArrayLike = 1.0,
|
2266
|
+
donor_background: ArrayLike = 0.0,
|
2267
|
+
background_real: ArrayLike = 0.0,
|
2268
|
+
background_imag: ArrayLike = 0.0,
|
2269
|
+
unit_conversion: float = 1e-3,
|
2270
|
+
**kwargs: Any,
|
2271
|
+
) -> tuple[NDArray[Any], NDArray[Any]]:
|
2272
|
+
"""Return phasor coordinates of FRET donor channel.
|
2273
|
+
|
2274
|
+
Calculate phasor coordinates of a FRET (Förster Resonance Energy Transfer)
|
2275
|
+
donor channel as a function of frequency, donor lifetime, FRET efficiency,
|
2276
|
+
fraction of donors undergoing FRET, and background fluorescence.
|
2277
|
+
|
2278
|
+
The phasor coordinates of the donor channel contain fractions of:
|
2279
|
+
|
2280
|
+
- donor not undergoing energy transfer
|
2281
|
+
- donor quenched by energy transfer
|
2282
|
+
- background fluorescence
|
2283
|
+
|
2284
|
+
Parameters
|
2285
|
+
----------
|
2286
|
+
frequency : array_like
|
2287
|
+
Laser pulse or modulation frequency in MHz.
|
2288
|
+
donor_lifetime : array_like
|
2289
|
+
Lifetime of donor without FRET in ns.
|
2290
|
+
fret_efficiency : array_like, optional, default 0
|
2291
|
+
FRET efficiency in range [0..1].
|
2292
|
+
donor_freting : array_like, optional, default 1
|
2293
|
+
Fraction of donors participating in FRET. Range [0..1].
|
2294
|
+
donor_background : array_like, optional, default 0
|
2295
|
+
Weight of background fluorescence in donor channel
|
2296
|
+
relative to fluorescence of donor without FRET.
|
2297
|
+
A weight of 1 means the fluorescence of background and donor
|
2298
|
+
without FRET are equal.
|
2299
|
+
background_real : array_like, optional, default 0
|
2300
|
+
Real component of background fluorescence phasor coordinate
|
2301
|
+
at `frequency`.
|
2302
|
+
background_imag : array_like, optional, default 0
|
2303
|
+
Imaginary component of background fluorescence phasor coordinate
|
2304
|
+
at `frequency`.
|
2305
|
+
unit_conversion : float, optional
|
2306
|
+
Product of `frequency` and `lifetime` units' prefix factors.
|
2307
|
+
The default is 1e-3 for MHz and ns, or Hz and ms.
|
2308
|
+
Use 1.0 for Hz and s.
|
2309
|
+
**kwargs
|
2310
|
+
Optional `arguments passed to numpy universal functions
|
2311
|
+
<https://numpy.org/doc/stable/reference/ufuncs.html#ufuncs-kwargs>`_.
|
2312
|
+
|
2313
|
+
Returns
|
2314
|
+
-------
|
2315
|
+
real : ndarray
|
2316
|
+
Real component of donor channel phasor coordinates.
|
2317
|
+
imag : ndarray
|
2318
|
+
Imaginary component of donor channel phasor coordinates.
|
2319
|
+
|
2320
|
+
See Also
|
2321
|
+
--------
|
2322
|
+
phasorpy.phasor.phasor_from_fret_acceptor
|
2323
|
+
:ref:`sphx_glr_tutorials_api_phasorpy_fret.py`
|
2324
|
+
|
2325
|
+
Examples
|
2326
|
+
--------
|
2327
|
+
Compute the phasor coordinates of a FRET donor channel at three
|
2328
|
+
FRET efficiencies:
|
2329
|
+
|
2330
|
+
>>> phasor_from_fret_donor(
|
2331
|
+
... frequency=80,
|
2332
|
+
... donor_lifetime=4.2,
|
2333
|
+
... fret_efficiency=[0.0, 0.3, 1.0],
|
2334
|
+
... donor_freting=0.9,
|
2335
|
+
... donor_background=0.1,
|
2336
|
+
... background_real=0.11,
|
2337
|
+
... background_imag=0.12,
|
2338
|
+
... ) # doctest: +NUMBER
|
2339
|
+
(array([0.1766, 0.2737, 0.1466]), array([0.3626, 0.4134, 0.2534]))
|
2340
|
+
|
2341
|
+
"""
|
2342
|
+
omega = numpy.array(frequency, dtype=numpy.float64) # makes copy
|
2343
|
+
omega *= math.pi * 2.0 * unit_conversion
|
2344
|
+
return _phasor_from_fret_donor( # type: ignore[no-any-return]
|
2345
|
+
omega,
|
2346
|
+
donor_lifetime,
|
2347
|
+
fret_efficiency,
|
2348
|
+
donor_freting,
|
2349
|
+
donor_background,
|
2350
|
+
background_real,
|
2351
|
+
background_imag,
|
2352
|
+
**kwargs,
|
2353
|
+
)
|
2354
|
+
|
2355
|
+
|
2356
|
+
def phasor_from_fret_acceptor(
|
2357
|
+
frequency: ArrayLike,
|
2358
|
+
donor_lifetime: ArrayLike,
|
2359
|
+
acceptor_lifetime: ArrayLike,
|
2360
|
+
*,
|
2361
|
+
fret_efficiency: ArrayLike = 0.0,
|
2362
|
+
donor_freting: ArrayLike = 1.0,
|
2363
|
+
donor_bleedthrough: ArrayLike = 0.0,
|
2364
|
+
acceptor_bleedthrough: ArrayLike = 0.0,
|
2365
|
+
acceptor_background: ArrayLike = 0.0,
|
2366
|
+
background_real: ArrayLike = 0.0,
|
2367
|
+
background_imag: ArrayLike = 0.0,
|
2368
|
+
unit_conversion: float = 1e-3,
|
2369
|
+
**kwargs: Any,
|
2370
|
+
) -> tuple[NDArray[Any], NDArray[Any]]:
|
2371
|
+
"""Return phasor coordinates of FRET acceptor channel.
|
2372
|
+
|
2373
|
+
Calculate phasor coordinates of a FRET (Förster Resonance Energy Transfer)
|
2374
|
+
acceptor channel as a function of frequency, donor and acceptor lifetimes,
|
2375
|
+
FRET efficiency, fraction of donors undergoing FRET, fraction of directly
|
2376
|
+
excited acceptors, fraction of donor fluorescence in acceptor channel,
|
2377
|
+
and background fluorescence.
|
2378
|
+
|
2379
|
+
The phasor coordinates of the acceptor channel contain fractions of:
|
2380
|
+
|
2381
|
+
- acceptor sensitized by energy transfer
|
2382
|
+
- directly excited acceptor
|
2383
|
+
- donor bleedthrough
|
2384
|
+
- background fluorescence
|
2385
|
+
|
2386
|
+
Parameters
|
2387
|
+
----------
|
2388
|
+
frequency : array_like
|
2389
|
+
Laser pulse or modulation frequency in MHz.
|
2390
|
+
donor_lifetime : array_like
|
2391
|
+
Lifetime of donor without FRET in ns.
|
2392
|
+
acceptor_lifetime : array_like
|
2393
|
+
Lifetime of acceptor in ns.
|
2394
|
+
fret_efficiency : array_like, optional, default 0
|
2395
|
+
FRET efficiency in range [0..1].
|
2396
|
+
donor_freting : array_like, optional, default 1
|
2397
|
+
Fraction of donors participating in FRET. Range [0..1].
|
2398
|
+
donor_bleedthrough : array_like, optional, default 0
|
2399
|
+
Weight of donor fluorescence in acceptor channel
|
2400
|
+
relative to fluorescence of fully sensitized acceptor.
|
2401
|
+
A weight of 1 means the fluorescence from donor and fully sensitized
|
2402
|
+
acceptor are equal.
|
2403
|
+
The background in the donor channel does not bleed through.
|
2404
|
+
acceptor_bleedthrough : array_like, optional, default 0
|
2405
|
+
Weight of fluorescence from directly excited acceptor
|
2406
|
+
relative to fluorescence of fully sensitized acceptor.
|
2407
|
+
A weight of 1 means the fluorescence from directly excited acceptor
|
2408
|
+
and fully sensitized acceptor are equal.
|
2409
|
+
acceptor_background : array_like, optional, default 0
|
2410
|
+
Weight of background fluorescence in acceptor channel
|
2411
|
+
relative to fluorescence of fully sensitized acceptor.
|
2412
|
+
A weight of 1 means the fluorescence of background and fully
|
2413
|
+
sensitized acceptor are equal.
|
2414
|
+
background_real : array_like, optional, default 0
|
2415
|
+
Real component of background fluorescence phasor coordinate
|
2416
|
+
at `frequency`.
|
2417
|
+
background_imag : array_like, optional, default 0
|
2418
|
+
Imaginary component of background fluorescence phasor coordinate
|
2419
|
+
at `frequency`.
|
2420
|
+
unit_conversion : float, optional
|
2421
|
+
Product of `frequency` and `lifetime` units' prefix factors.
|
2422
|
+
The default is 1e-3 for MHz and ns, or Hz and ms.
|
2423
|
+
Use 1.0 for Hz and s.
|
2424
|
+
**kwargs
|
2425
|
+
Optional `arguments passed to numpy universal functions
|
2426
|
+
<https://numpy.org/doc/stable/reference/ufuncs.html#ufuncs-kwargs>`_.
|
2427
|
+
|
2428
|
+
Returns
|
2429
|
+
-------
|
2430
|
+
real : ndarray
|
2431
|
+
Real component of acceptor channel phasor coordinates.
|
2432
|
+
imag : ndarray
|
2433
|
+
Imaginary component of acceptor channel phasor coordinates.
|
2434
|
+
|
2435
|
+
See Also
|
2436
|
+
--------
|
2437
|
+
phasorpy.phasor.phasor_from_fret_donor
|
2438
|
+
:ref:`sphx_glr_tutorials_api_phasorpy_fret.py`
|
2439
|
+
|
2440
|
+
Examples
|
2441
|
+
--------
|
2442
|
+
Compute the phasor coordinates of a FRET acceptor channel at three
|
2443
|
+
FRET efficiencies:
|
2444
|
+
|
2445
|
+
>>> phasor_from_fret_acceptor(
|
2446
|
+
... frequency=80,
|
2447
|
+
... donor_lifetime=4.2,
|
2448
|
+
... acceptor_lifetime=3.0,
|
2449
|
+
... fret_efficiency=[0.0, 0.3, 1.0],
|
2450
|
+
... donor_freting=0.9,
|
2451
|
+
... donor_bleedthrough=0.1,
|
2452
|
+
... acceptor_bleedthrough=0.1,
|
2453
|
+
... acceptor_background=0.1,
|
2454
|
+
... background_real=0.11,
|
2455
|
+
... background_imag=0.12,
|
2456
|
+
... ) # doctest: +NUMBER
|
2457
|
+
(array([0.1996, 0.05772, 0.2867]), array([0.3225, 0.3103, 0.4292]))
|
2458
|
+
|
2459
|
+
"""
|
2460
|
+
omega = numpy.array(frequency, dtype=numpy.float64) # makes copy
|
2461
|
+
omega *= math.pi * 2.0 * unit_conversion
|
2462
|
+
return _phasor_from_fret_acceptor( # type: ignore[no-any-return]
|
2463
|
+
omega,
|
2464
|
+
donor_lifetime,
|
2465
|
+
acceptor_lifetime,
|
2466
|
+
fret_efficiency,
|
2467
|
+
donor_freting,
|
2468
|
+
donor_bleedthrough,
|
2469
|
+
acceptor_bleedthrough,
|
2470
|
+
acceptor_background,
|
2471
|
+
background_real,
|
2472
|
+
background_imag,
|
2473
|
+
**kwargs,
|
2474
|
+
)
|
2475
|
+
|
2476
|
+
|
2477
|
+
def phasor_to_principal_plane(
|
2478
|
+
real: ArrayLike,
|
2479
|
+
imag: ArrayLike,
|
2480
|
+
/,
|
2481
|
+
*,
|
2482
|
+
reorient: bool = True,
|
2483
|
+
) -> tuple[NDArray[Any], NDArray[Any], NDArray[Any]]:
|
2484
|
+
"""Return multi-harmonic phasor coordinates projected onto principal plane.
|
2485
|
+
|
2486
|
+
Principal Component Analysis (PCA) is used to project
|
2487
|
+
multi-harmonic phasor coordinates onto a plane, along which
|
2488
|
+
coordinate axes the phasor coordinates have the largest variations.
|
2489
|
+
|
2490
|
+
The transformed coordinates are not phasor coordinates. However, the
|
2491
|
+
coordinates can be used in visualization and cursor analysis since
|
2492
|
+
the transformation is affine (preserving collinearity and ratios
|
2493
|
+
of distances).
|
2494
|
+
|
2495
|
+
Parameters
|
2496
|
+
----------
|
2497
|
+
real : array_like
|
2498
|
+
Real component of multi-harmonic phasor coordinates.
|
2499
|
+
The first axis is the frequency dimension.
|
2500
|
+
If less than 2-dimensional, size-1 dimensions are prepended.
|
2501
|
+
imag : array_like
|
2502
|
+
Imaginary component of multi-harmonic phasor coordinates.
|
2503
|
+
Must be of same shape as `real`.
|
2504
|
+
reorient : bool, optional, default: True
|
2505
|
+
Reorient coordinates for easier visualization.
|
2506
|
+
The projected coordinates are rotated and scaled, such that
|
2507
|
+
the center lies in same quadrant and the projection
|
2508
|
+
of [1, 0] lies at [1, 0].
|
2509
|
+
|
2510
|
+
Returns
|
2511
|
+
-------
|
2512
|
+
x : ndarray
|
2513
|
+
X-coordinates of projected phasor coordinates.
|
2514
|
+
If not `reorient`, this is the coordinate on the first principal axis.
|
2515
|
+
The shape is ``real.shape[1:]``.
|
2516
|
+
y : ndarray
|
2517
|
+
Y-coordinates of projected phasor coordinates.
|
2518
|
+
If not `reorient`, this is the coordinate on the second principal axis.
|
2519
|
+
transformation_matrix : ndarray
|
2520
|
+
Affine transformation matrix used to project phasor coordinates.
|
2521
|
+
The shape is ``(2, 2 * real.shape[0])``.
|
2522
|
+
|
2523
|
+
See Also
|
2524
|
+
--------
|
2525
|
+
:ref:`sphx_glr_tutorials_api_phasorpy_pca.py`
|
2526
|
+
|
2527
|
+
Notes
|
2528
|
+
-----
|
2529
|
+
|
2530
|
+
This implementation does not work with coordinates containing
|
2531
|
+
undefined `NaN` values.
|
2532
|
+
|
2533
|
+
The transformation matrix can be used to project multi-harmonic phasor
|
2534
|
+
coordinates, where the first axis is the frequency:
|
2535
|
+
|
2536
|
+
.. code-block:: python
|
2537
|
+
|
2538
|
+
x, y = numpy.dot(
|
2539
|
+
numpy.vstack(
|
2540
|
+
real.reshape(real.shape[0], -1),
|
2541
|
+
imag.reshape(imag.shape[0], -1),
|
2542
|
+
),
|
2543
|
+
transformation_matrix,
|
2544
|
+
).reshape(2, *real.shape[1:])
|
2545
|
+
|
2546
|
+
An application of PCA to full-harmonic phasor coordinates from MRI signals
|
2547
|
+
can be found in [1]_.
|
2548
|
+
|
2549
|
+
References
|
2550
|
+
----------
|
2551
|
+
|
2552
|
+
.. [1] Franssen WMJ, Vergeldt FJ, Bader AN, van Amerongen H, & Terenzi C.
|
2553
|
+
`Full-harmonics phasor analysis: unravelling multiexponential trends
|
2554
|
+
in magnetic resonance imaging data
|
2555
|
+
<https://doi.org/10.1021/acs.jpclett.0c02319>`_.
|
2556
|
+
*J Phys Chem Lett*, 11(21): 9152-9158 (2020)
|
2557
|
+
|
2558
|
+
Examples
|
2559
|
+
--------
|
2560
|
+
The phasor coordinates of multi-exponential decays may be almost
|
2561
|
+
indistinguishable at certain frequencies but are separated in the
|
2562
|
+
projection on the principal plane:
|
2563
|
+
|
2564
|
+
>>> real = [[0.495, 0.502], [0.354, 0.304]]
|
2565
|
+
>>> imag = [[0.333, 0.334], [0.301, 0.349]]
|
2566
|
+
>>> x, y, transformation_matrix = phasor_to_principal_plane(real, imag)
|
2567
|
+
>>> x, y # doctest: +SKIP
|
2568
|
+
(array([0.294, 0.262]), array([0.192, 0.242]))
|
2569
|
+
>>> transformation_matrix # doctest: +SKIP
|
2570
|
+
array([[0.67, 0.33, -0.09, -0.41], [0.52, -0.52, -0.04, 0.44]])
|
2571
|
+
|
2572
|
+
"""
|
2573
|
+
re, im = numpy.atleast_2d(real, imag)
|
2574
|
+
if re.shape != im.shape:
|
2575
|
+
raise ValueError(f'real={re.shape} != imag={im.shape}')
|
2576
|
+
|
2577
|
+
# reshape to variables in row, observations in column
|
2578
|
+
frequencies = re.shape[0]
|
2579
|
+
shape = re.shape[1:]
|
2580
|
+
re = re.reshape(re.shape[0], -1)
|
2581
|
+
im = im.reshape(im.shape[0], -1)
|
2582
|
+
|
2583
|
+
# vector of multi-frequency phasor coordinates
|
2584
|
+
coordinates = numpy.vstack((re, im))
|
2585
|
+
|
2586
|
+
# vector of centered coordinates
|
2587
|
+
center = numpy.nanmean(coordinates, axis=1, keepdims=True)
|
2588
|
+
coordinates -= center
|
2589
|
+
|
2590
|
+
# covariance matrix (scatter matrix would also work)
|
2591
|
+
cov = numpy.cov(coordinates, rowvar=True)
|
2592
|
+
|
2593
|
+
# calculate eigenvectors
|
2594
|
+
_, eigvec = numpy.linalg.eigh(cov)
|
2595
|
+
|
2596
|
+
# projection matrix: two eigenvectors with largest eigenvalues
|
2597
|
+
transformation_matrix = eigvec.T[-2:][::-1]
|
2598
|
+
|
2599
|
+
if reorient:
|
2600
|
+
# for single harmonic, this should restore original coordinates.
|
2601
|
+
|
2602
|
+
# 1. rotate and scale such that projection of [1, 0] lies at [1, 0]
|
2603
|
+
x, y = numpy.dot(
|
2604
|
+
transformation_matrix,
|
2605
|
+
numpy.vstack(([[1.0]] * frequencies, [[0.0]] * frequencies)),
|
2606
|
+
)
|
2607
|
+
x = x.item()
|
2608
|
+
y = y.item()
|
2609
|
+
angle = -math.atan2(y, x)
|
2610
|
+
if angle < 0:
|
2611
|
+
angle += 2.0 * math.pi
|
2612
|
+
cos = math.cos(angle)
|
2613
|
+
sin = math.sin(angle)
|
2614
|
+
transformation_matrix = numpy.dot(
|
2615
|
+
[[cos, -sin], [sin, cos]], transformation_matrix
|
2616
|
+
)
|
2617
|
+
scale_factor = 1.0 / math.hypot(x, y)
|
2618
|
+
transformation_matrix = numpy.dot(
|
2619
|
+
[[scale_factor, 0], [0, scale_factor]], transformation_matrix
|
2620
|
+
)
|
2621
|
+
|
2622
|
+
# 2. mirror such that projected center lies in same quadrant
|
2623
|
+
cs = math.copysign
|
2624
|
+
x, y = numpy.dot(transformation_matrix, center)
|
2625
|
+
x = x.item()
|
2626
|
+
y = y.item()
|
2627
|
+
transformation_matrix = numpy.dot(
|
2628
|
+
[
|
2629
|
+
[-1 if cs(1, x) != cs(1, center[0][0]) else 1, 0],
|
2630
|
+
[0, -1 if cs(1, y) != cs(1, center[1][0]) else 1],
|
2631
|
+
],
|
2632
|
+
transformation_matrix,
|
2633
|
+
)
|
2634
|
+
|
2635
|
+
# project multi-frequency phasor coordinates onto principal plane
|
2636
|
+
coordinates += center
|
2637
|
+
coordinates = numpy.dot(transformation_matrix, coordinates)
|
2638
|
+
|
2639
|
+
return (
|
2640
|
+
coordinates[0].reshape(shape), # x coordinates
|
2641
|
+
coordinates[1].reshape(shape), # y coordinates
|
2642
|
+
transformation_matrix,
|
2643
|
+
)
|
2644
|
+
|
2645
|
+
|
2646
|
+
def phasor_filter(
|
2647
|
+
real: ArrayLike,
|
2648
|
+
imag: ArrayLike,
|
2649
|
+
/,
|
2650
|
+
*,
|
2651
|
+
method: Literal['median'] = 'median',
|
2652
|
+
repeat: int = 1,
|
2653
|
+
**kwargs: Any,
|
2654
|
+
) -> tuple[NDArray[Any], NDArray[Any]]:
|
2655
|
+
"""Return filtered phasor coordinates.
|
2656
|
+
|
2657
|
+
By default, a median filter is applied to the real and imaginary
|
2658
|
+
components of phasor coordinates once with a kernel size of 3
|
2659
|
+
multiplied by the number of dimensions of the input arrays.
|
2660
|
+
|
2661
|
+
Parameters
|
2662
|
+
----------
|
2663
|
+
real : array_like
|
2664
|
+
Real component of phasor coordinates to be filtered.
|
2665
|
+
imag : array_like
|
2666
|
+
Imaginary component of phasor coordinates to be filtered.
|
2667
|
+
method : str, optional
|
2668
|
+
Method used for filtering:
|
2669
|
+
|
2670
|
+
- ``'median'``: Spatial median of phasor coordinates.
|
2671
|
+
|
2672
|
+
repeat : int, optional
|
2673
|
+
Number of times to apply filter. The default is 1.
|
2674
|
+
**kwargs
|
2675
|
+
Optional arguments passed to :py:func:`scipy.ndimage.median_filter`.
|
2676
|
+
|
2677
|
+
Returns
|
2678
|
+
-------
|
2679
|
+
real : ndarray
|
2680
|
+
Filtered real component of phasor coordinates.
|
2681
|
+
imag : ndarray
|
2682
|
+
Filtered imaginary component of phasor coordinates.
|
2683
|
+
|
2684
|
+
Raises
|
2685
|
+
------
|
2686
|
+
ValueError
|
2687
|
+
If the specified method is not supported.
|
2688
|
+
The array shapes of `real` and `imag` do not match.
|
2689
|
+
If `repeat` is less than 1.
|
2690
|
+
|
2691
|
+
Notes
|
2692
|
+
-----
|
2693
|
+
For now, only the median filter method is implemented.
|
2694
|
+
Additional filtering methods may be added in the future.
|
2695
|
+
|
2696
|
+
The implementation of the median filter method is based on
|
2697
|
+
:py:func:`scipy.ndimage.median_filter`,
|
2698
|
+
which has undefined behavior if the input arrays contain `NaN` values.
|
2699
|
+
See `issue #87 <https://github.com/phasorpy/phasorpy/issues/87>`_.
|
2700
|
+
|
2701
|
+
Examples
|
2702
|
+
--------
|
2703
|
+
Apply three times a median filter with a kernel size of three:
|
2704
|
+
|
2705
|
+
>>> phasor_filter(
|
2706
|
+
... [[0, 0, 0], [5, 5, 5], [2, 2, 2]],
|
2707
|
+
... [[3, 3, 3], [6, 6, 6], [4, 4, 4]],
|
2708
|
+
... size=3,
|
2709
|
+
... repeat=3,
|
2710
|
+
... )
|
2711
|
+
(array([[0, 0, 0],
|
2712
|
+
[2, 2, 2],
|
2713
|
+
[2, 2, 2]]),
|
2714
|
+
array([[3, 3, 3],
|
2715
|
+
[4, 4, 4],
|
2716
|
+
[4, 4, 4]]))
|
2717
|
+
|
2718
|
+
"""
|
2719
|
+
methods = {'median': _median_filter}
|
2720
|
+
if method not in methods:
|
2721
|
+
raise ValueError(
|
2722
|
+
f"Method not supported, supported methods are: "
|
2723
|
+
f"{', '.join(methods)}"
|
2724
|
+
)
|
2725
|
+
real = numpy.asarray(real)
|
2726
|
+
imag = numpy.asarray(imag)
|
2727
|
+
|
2728
|
+
if real.shape != imag.shape:
|
2729
|
+
raise ValueError(f'{real.shape=} != {imag.shape=}')
|
2730
|
+
if repeat < 1:
|
2731
|
+
raise ValueError(f'{repeat=} < 1')
|
2732
|
+
|
2733
|
+
return methods[method](real, imag, repeat, **kwargs)
|
2734
|
+
|
2735
|
+
|
2736
|
+
def phasor_threshold(
|
2737
|
+
mean: ArrayLike,
|
2738
|
+
real: ArrayLike,
|
2739
|
+
imag: ArrayLike,
|
2740
|
+
/,
|
2741
|
+
mean_min: ArrayLike | None = None,
|
2742
|
+
mean_max: ArrayLike | None = None,
|
2743
|
+
*,
|
2744
|
+
real_min: ArrayLike | None = None,
|
2745
|
+
real_max: ArrayLike | None = None,
|
2746
|
+
imag_min: ArrayLike | None = None,
|
2747
|
+
imag_max: ArrayLike | None = None,
|
2748
|
+
phase_min: ArrayLike | None = None,
|
2749
|
+
phase_max: ArrayLike | None = None,
|
2750
|
+
modulation_min: ArrayLike | None = None,
|
2751
|
+
modulation_max: ArrayLike | None = None,
|
2752
|
+
open_interval: bool = False,
|
2753
|
+
**kwargs: Any,
|
2754
|
+
) -> tuple[NDArray[Any], NDArray[Any], NDArray[Any]]:
|
2755
|
+
"""Return phasor coordinates with values out of interval replaced by NaN.
|
2756
|
+
|
2757
|
+
Interval thresholds can be set for mean intensity, real and imaginary
|
2758
|
+
coordinates, and phase and modulation.
|
2759
|
+
Phasor coordinates smaller than minimum thresholds or larger than maximum
|
2760
|
+
thresholds are replaced NaN.
|
2761
|
+
No threshold is applied by default.
|
2762
|
+
|
2763
|
+
Parameters
|
2764
|
+
----------
|
2765
|
+
mean : array_like
|
2766
|
+
Mean intensity of phasor coordinates.
|
2767
|
+
real : array_like
|
2768
|
+
Real component of phasor coordinates.
|
2769
|
+
imag : array_like
|
2770
|
+
Imaginary component of phasor coordinates.
|
2771
|
+
mean_min : array_like, optional
|
2772
|
+
Lower threshold for mean intensity.
|
2773
|
+
mean_max : array_like, optional
|
2774
|
+
Upper threshold for mean intensity.
|
2775
|
+
real_min : array_like, optional
|
2776
|
+
Lower threshold for real coordinates.
|
2777
|
+
real_max : array_like, optional
|
2778
|
+
Upper threshold for real coordinates.
|
2779
|
+
imag_min : array_like, optional
|
2780
|
+
Lower threshold for imaginary coordinates.
|
2781
|
+
imag_max : array_like, optional
|
2782
|
+
Upper threshold for imaginary coordinates.
|
2783
|
+
phase_min : array_like, optional
|
2784
|
+
Lower threshold for phase angle.
|
2785
|
+
phase_max : array_like, optional
|
2786
|
+
Upper threshold for phase angle.
|
2787
|
+
modulation_min : array_like, optional
|
2788
|
+
Lower threshold for modulation.
|
2789
|
+
modulation_max : array_like, optional
|
2790
|
+
Upper threshold for modulation.
|
2791
|
+
open_interval : bool, optional
|
2792
|
+
If true, the interval is open, and the threshold values are
|
2793
|
+
not included in the interval.
|
2794
|
+
If False, the interval is closed, and the threshold values are
|
2795
|
+
included in the interval. The default is False.
|
2796
|
+
**kwargs
|
2797
|
+
Optional `arguments passed to numpy universal functions
|
2798
|
+
<https://numpy.org/doc/stable/reference/ufuncs.html#ufuncs-kwargs>`_.
|
2799
|
+
|
2800
|
+
Returns
|
2801
|
+
-------
|
2802
|
+
mean : ndarray
|
2803
|
+
Thresholded mean intensity of phasor coordinates.
|
2804
|
+
real : ndarray
|
2805
|
+
Thresholded real component of phasor coordinates.
|
2806
|
+
imag : ndarray
|
2807
|
+
Thresholded imaginary component of phasor coordinates.
|
2808
|
+
|
2809
|
+
Examples
|
2810
|
+
--------
|
2811
|
+
Set phasor coordinates to NaN if mean intensity is smaller than 1.1:
|
2812
|
+
|
2813
|
+
>>> phasor_threshold([1, 2, 3], [0.1, 0.2, 0.3], [0.4, 0.5, 0.6], 1.1)
|
2814
|
+
(array([nan, 2, 3]), array([nan, 0.2, 0.3]), array([nan, 0.5, 0.6]))
|
2815
|
+
|
2816
|
+
Set phasor coordinates to NaN if real component is smaller than 0.15 or
|
2817
|
+
larger than 0.25:
|
2818
|
+
|
2819
|
+
>>> phasor_threshold(
|
2820
|
+
... [1.0, 2.0, 3.0],
|
2821
|
+
... [0.1, 0.2, 0.3],
|
2822
|
+
... [0.4, 0.5, 0.6],
|
2823
|
+
... real_min=0.15,
|
2824
|
+
... real_max=0.25,
|
2825
|
+
... )
|
2826
|
+
(array([nan, 2, nan]), array([nan, 0.2, nan]), array([nan, 0.5, nan]))
|
2827
|
+
|
2828
|
+
Apply NaNs to other input arrays:
|
2829
|
+
|
2830
|
+
>>> phasor_threshold(
|
2831
|
+
... [numpy.nan, 2, 3], [0.1, 0.2, 0.3], [0.4, 0.5, numpy.nan]
|
2832
|
+
... )
|
2833
|
+
(array([nan, 2, nan]), array([nan, 0.2, nan]), array([nan, 0.5, nan]))
|
2834
|
+
|
2835
|
+
"""
|
2836
|
+
threshold_mean_only = None
|
2837
|
+
if mean_min is None:
|
2838
|
+
mean_min = numpy.nan
|
2839
|
+
else:
|
2840
|
+
threshold_mean_only = True
|
2841
|
+
if mean_max is None:
|
2842
|
+
mean_max = numpy.nan
|
2843
|
+
else:
|
2844
|
+
threshold_mean_only = True
|
2845
|
+
if real_min is None:
|
2846
|
+
real_min = numpy.nan
|
2847
|
+
else:
|
2848
|
+
threshold_mean_only = False
|
2849
|
+
if real_max is None:
|
2850
|
+
real_max = numpy.nan
|
2851
|
+
else:
|
2852
|
+
threshold_mean_only = False
|
2853
|
+
if imag_min is None:
|
2854
|
+
imag_min = numpy.nan
|
2855
|
+
else:
|
2856
|
+
threshold_mean_only = False
|
2857
|
+
if imag_max is None:
|
2858
|
+
imag_max = numpy.nan
|
2859
|
+
else:
|
2860
|
+
threshold_mean_only = False
|
2861
|
+
if phase_min is None:
|
2862
|
+
phase_min = numpy.nan
|
2863
|
+
else:
|
2864
|
+
threshold_mean_only = False
|
2865
|
+
if phase_max is None:
|
2866
|
+
phase_max = numpy.nan
|
2867
|
+
else:
|
2868
|
+
threshold_mean_only = False
|
2869
|
+
if modulation_min is None:
|
2870
|
+
modulation_min = numpy.nan
|
2871
|
+
else:
|
2872
|
+
threshold_mean_only = False
|
2873
|
+
if modulation_max is None:
|
2874
|
+
modulation_max = numpy.nan
|
2875
|
+
else:
|
2876
|
+
threshold_mean_only = False
|
2877
|
+
|
2878
|
+
if threshold_mean_only is None:
|
2879
|
+
return _phasor_threshold_nan( # type: ignore[no-any-return]
|
2880
|
+
mean, real, imag, **kwargs
|
2881
|
+
)
|
2882
|
+
|
2883
|
+
if threshold_mean_only:
|
2884
|
+
mean_func = (
|
2885
|
+
_phasor_threshold_mean_open
|
2886
|
+
if open_interval
|
2887
|
+
else _phasor_threshold_mean_closed
|
2888
|
+
)
|
2889
|
+
return mean_func( # type: ignore[no-any-return]
|
2890
|
+
mean, real, imag, mean_min, mean_max, **kwargs
|
2891
|
+
)
|
2892
|
+
|
2893
|
+
func = (
|
2894
|
+
_phasor_threshold_open if open_interval else _phasor_threshold_closed
|
2895
|
+
)
|
2896
|
+
return func( # type: ignore[no-any-return]
|
2897
|
+
mean,
|
2898
|
+
real,
|
2899
|
+
imag,
|
2900
|
+
mean_min,
|
2901
|
+
mean_max,
|
2902
|
+
real_min,
|
2903
|
+
real_max,
|
2904
|
+
imag_min,
|
2905
|
+
imag_max,
|
2906
|
+
phase_min,
|
2907
|
+
phase_max,
|
2908
|
+
modulation_min,
|
2909
|
+
modulation_max,
|
2910
|
+
**kwargs,
|
2911
|
+
)
|
2912
|
+
|
2913
|
+
|
2914
|
+
def phasor_center(
|
2915
|
+
real: ArrayLike,
|
2916
|
+
imag: ArrayLike,
|
2917
|
+
/,
|
2918
|
+
*,
|
2919
|
+
skip_axis: int | Sequence[int] | None = None,
|
2920
|
+
method: Literal['mean', 'median'] = 'mean',
|
2921
|
+
**kwargs: Any,
|
2922
|
+
) -> tuple[NDArray[Any], NDArray[Any]]:
|
2923
|
+
"""Return center of phasor coordinates.
|
2924
|
+
|
2925
|
+
Parameters
|
2926
|
+
----------
|
2927
|
+
real : array_like
|
2928
|
+
Real component of phasor coordinates.
|
2929
|
+
imag : array_like
|
2930
|
+
Imaginary component of phasor coordinates.
|
2931
|
+
skip_axis : int or sequence of int, optional
|
2932
|
+
Axes to be excluded during center calculation. If None, all
|
2933
|
+
axes are considered.
|
2934
|
+
method : str, optional
|
2935
|
+
Method used for center calculation:
|
2936
|
+
|
2937
|
+
- ``'mean'``: Arithmetic mean of phasor coordinates.
|
2938
|
+
- ``'median'``: Spatial median of phasor coordinates.
|
2939
|
+
|
2940
|
+
**kwargs
|
2941
|
+
Optional arguments passed to :py:func:`numpy.nanmean` or
|
2942
|
+
:py:func:`numpy.nanmedian`.
|
2943
|
+
|
2944
|
+
Returns
|
2945
|
+
-------
|
2946
|
+
real_center : ndarray
|
2947
|
+
Real center coordinates calculated based on the specified method.
|
2948
|
+
imag_center : ndarray
|
2949
|
+
Imaginary center coordinates calculated based on the specified method.
|
2950
|
+
|
2951
|
+
Raises
|
2952
|
+
------
|
2953
|
+
ValueError
|
2954
|
+
If the specified method is not supported.
|
2955
|
+
If the shapes of the `real` and `imag` do not match.
|
2956
|
+
|
2957
|
+
Examples
|
2958
|
+
--------
|
2959
|
+
Compute center coordinates with the 'mean' method:
|
2960
|
+
|
2961
|
+
>>> phasor_center([1.0, 2.0, 3.0], [4.0, 5.0, 6.0], method='mean')
|
2962
|
+
(2.0, 5.0)
|
2963
|
+
|
2964
|
+
Compute center coordinates with the 'median' method:
|
2965
|
+
|
2966
|
+
>>> phasor_center([1.0, 2.0, 3.0], [4.0, 5.0, 6.0], method='median')
|
2967
|
+
(2.0, 5.0)
|
2968
|
+
|
2969
|
+
"""
|
2970
|
+
methods = {
|
2971
|
+
'mean': _mean,
|
2972
|
+
'median': _median,
|
2973
|
+
}
|
2974
|
+
if method not in methods:
|
2975
|
+
raise ValueError(
|
2976
|
+
f"Method not supported, supported methods are: "
|
2977
|
+
f"{', '.join(methods)}"
|
2978
|
+
)
|
2979
|
+
real = numpy.asarray(real)
|
2980
|
+
imag = numpy.asarray(imag)
|
2981
|
+
if real.shape != imag.shape:
|
2982
|
+
raise ValueError(f'{real.shape=} != {imag.shape=}')
|
2983
|
+
|
2984
|
+
_, axis = _parse_skip_axis(skip_axis, real.ndim)
|
2985
|
+
|
2986
|
+
return methods[method](real, imag, axis=axis, **kwargs)
|
2987
|
+
|
2988
|
+
|
2989
|
+
def _mean(
|
2990
|
+
real: NDArray[Any], imag: NDArray[Any], /, **kwargs: Any
|
2991
|
+
) -> tuple[NDArray[Any], NDArray[Any]]:
|
2992
|
+
"""Return the mean center of phasor coordinates.
|
2993
|
+
|
2994
|
+
Parameters
|
2995
|
+
----------
|
2996
|
+
real : ndarray
|
2997
|
+
Real components of phasor coordinates.
|
2998
|
+
imag : ndarray
|
2999
|
+
Imaginary components of phasor coordinates.
|
3000
|
+
**kwargs
|
3001
|
+
Optional arguments passed to :py:func:`numpy.nanmean`.
|
3002
|
+
|
3003
|
+
Returns
|
3004
|
+
-------
|
3005
|
+
real_center : ndarray
|
3006
|
+
Mean real center coordinates.
|
3007
|
+
imag_center : ndarray
|
3008
|
+
Mean imaginary center coordinates.
|
3009
|
+
|
3010
|
+
Examples
|
3011
|
+
--------
|
3012
|
+
>>> _mean([1.0, 2.0, 3.0], [4.0, 5.0, 6.0])
|
3013
|
+
(2.0, 5.0)
|
3014
|
+
|
3015
|
+
"""
|
3016
|
+
return numpy.nanmean(real, **kwargs), numpy.nanmean(imag, **kwargs)
|
3017
|
+
|
3018
|
+
|
3019
|
+
def _median(
|
3020
|
+
real: NDArray[Any], imag: NDArray[Any], /, **kwargs: Any
|
3021
|
+
) -> tuple[NDArray[Any], NDArray[Any]]:
|
3022
|
+
"""Return the spatial median center of phasor coordinates.
|
3023
|
+
|
3024
|
+
Parameters
|
3025
|
+
----------
|
3026
|
+
real : ndarray
|
3027
|
+
Real components of the phasor coordinates.
|
3028
|
+
imag : ndarray
|
3029
|
+
Imaginary components of the phasor coordinates.
|
3030
|
+
**kwargs
|
3031
|
+
Optional arguments passed to :py:func:`numpy.nanmedian`.
|
3032
|
+
|
3033
|
+
Returns
|
3034
|
+
-------
|
3035
|
+
real_center : ndarray
|
3036
|
+
Spatial median center for real coordinates.
|
3037
|
+
imag_center : ndarray
|
3038
|
+
Spatial median center for imaginary coordinates.
|
3039
|
+
|
3040
|
+
Examples
|
3041
|
+
--------
|
3042
|
+
>>> _median([1.0, 2.0, 3.0], [4.0, 5.0, 6.0])
|
3043
|
+
(2.0, 5.0)
|
3044
|
+
|
3045
|
+
"""
|
3046
|
+
return numpy.nanmedian(real, **kwargs), numpy.nanmedian(imag, **kwargs)
|
3047
|
+
|
3048
|
+
|
3049
|
+
def _median_filter(
|
3050
|
+
real: ArrayLike,
|
3051
|
+
imag: ArrayLike,
|
3052
|
+
repeat: int = 1,
|
3053
|
+
size: int | tuple[int] | None = 3,
|
3054
|
+
**kwargs: Any,
|
3055
|
+
) -> tuple[NDArray[Any], NDArray[Any]]:
|
3056
|
+
"""Return the phasor coordinates after applying a median filter.
|
3057
|
+
|
3058
|
+
Convenience wrapper around :py:func:`scipy.ndimage.median_filter`.
|
3059
|
+
|
3060
|
+
Parameters
|
3061
|
+
----------
|
3062
|
+
real : ndarray
|
3063
|
+
Real components of the phasor coordinates.
|
3064
|
+
imag : ndarray
|
3065
|
+
Imaginary components of the phasor coordinates.
|
3066
|
+
repeat : int, optional
|
3067
|
+
Number of times to apply filter. The default is 1.
|
3068
|
+
size : int or tuple of int, optional
|
3069
|
+
The size of the median filter kernel. Default is 3.
|
3070
|
+
**kwargs
|
3071
|
+
Optional arguments passed to :py:func:`scipy.ndimage.median_filter`.
|
3072
|
+
|
3073
|
+
Returns
|
3074
|
+
-------
|
3075
|
+
real : ndarray
|
3076
|
+
Filtered real component of phasor coordinates.
|
3077
|
+
imag : ndarray
|
3078
|
+
Filtered imaginary component of phasor coordinates.
|
3079
|
+
|
3080
|
+
"""
|
3081
|
+
from scipy.ndimage import median_filter
|
3082
|
+
|
3083
|
+
for _ in range(repeat):
|
3084
|
+
real = median_filter(real, size=size, **kwargs)
|
3085
|
+
imag = median_filter(imag, size=size, **kwargs)
|
3086
|
+
|
3087
|
+
return numpy.asarray(real), numpy.asarray(imag)
|
3088
|
+
|
3089
|
+
|
3090
|
+
def _parse_skip_axis(
|
3091
|
+
skip_axis: int | Sequence[int] | None,
|
3092
|
+
/,
|
3093
|
+
ndim: int,
|
3094
|
+
) -> tuple[tuple[int, ...], tuple[int, ...]]:
|
3095
|
+
"""Return axes to skip and not to skip.
|
3096
|
+
|
3097
|
+
This helper function is used to validate and parse `skip_axis`
|
3098
|
+
parameters.
|
3099
|
+
|
3100
|
+
Parameters
|
3101
|
+
----------
|
3102
|
+
skip_axis : Sequence of int, or None
|
3103
|
+
Axes to skip. If None, no axes are skipped.
|
3104
|
+
ndim : int
|
3105
|
+
Dimensionality of array in which to skip axes.
|
3106
|
+
|
3107
|
+
Returns
|
3108
|
+
-------
|
3109
|
+
skip_axis
|
3110
|
+
Ordered, positive values of `skip_axis`.
|
3111
|
+
other_axis
|
3112
|
+
Axes indices not included in `skip_axis`.
|
3113
|
+
|
3114
|
+
Raises
|
3115
|
+
------
|
3116
|
+
IndexError
|
3117
|
+
If any `skip_axis` value is out of bounds of `ndim`.
|
3118
|
+
|
3119
|
+
Examples
|
3120
|
+
--------
|
3121
|
+
>>> _parse_skip_axis((1, -2), 5)
|
3122
|
+
((1, 3), (0, 2, 4))
|
3123
|
+
|
3124
|
+
"""
|
3125
|
+
if ndim < 0:
|
3126
|
+
raise ValueError(f'invalid {ndim=}')
|
3127
|
+
if skip_axis is None:
|
3128
|
+
return (), tuple(range(ndim))
|
3129
|
+
if not isinstance(skip_axis, Sequence):
|
3130
|
+
skip_axis = (skip_axis,)
|
3131
|
+
if any(i >= ndim or i < -ndim for i in skip_axis):
|
3132
|
+
raise IndexError(f"skip_axis={skip_axis} out of range for {ndim=}")
|
3133
|
+
skip_axis = tuple(sorted(int(i % ndim) for i in skip_axis))
|
3134
|
+
other_axis = tuple(i for i in range(ndim) if i not in skip_axis)
|
3135
|
+
return skip_axis, other_axis
|