phasorpy 0.1__cp310-cp310-win_amd64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
phasorpy/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