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