phasorpy 0.7__cp314-cp314t-win_arm64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,563 @@
1
+ """LifetimePlots class."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__ = ['LifetimePlots']
6
+
7
+ from typing import TYPE_CHECKING
8
+
9
+ if TYPE_CHECKING:
10
+ from .._typing import Any, NDArray, ArrayLike
11
+
12
+ from matplotlib.axes import Axes
13
+
14
+ import numpy
15
+ from matplotlib import pyplot
16
+ from matplotlib.lines import Line2D
17
+ from matplotlib.widgets import Slider
18
+
19
+ from .._utils import update_kwargs
20
+ from ..lifetime import (
21
+ lifetime_to_frequency,
22
+ lifetime_to_signal,
23
+ phasor_from_lifetime,
24
+ )
25
+ from ..phasor import (
26
+ phasor_to_polar,
27
+ phasor_transform,
28
+ )
29
+ from ..plot._phasorplot import (
30
+ CircleTicks,
31
+ PhasorPlot,
32
+ _semicircle_ticks,
33
+ )
34
+
35
+
36
+ class LifetimePlots:
37
+ """Plot lifetimes in time domain, frequency domain, and phasor plot.
38
+
39
+ Plot the time domain signals, phasor coordinates, and multi-frequency
40
+ phase and modulation curves for a set of lifetime components and their
41
+ mixture at given frequency and fractional intensities.
42
+
43
+ Parameters
44
+ ----------
45
+ frequency : float
46
+ Fundamental laser pulse or modulation frequency in MHz.
47
+ If None, an optimal frequency is calculated from the mean of the
48
+ lifetime components.
49
+ lifetime : array_like
50
+ Lifetime components in ns. Up to 6 components are supported.
51
+ fraction : array_like, optional
52
+ Fractional intensities of lifetime components.
53
+ Fractions are normalized to sum to 1.
54
+ If not given, all components are assumed to have equal fractions.
55
+ frequency_range : tuple[float, float, float], optional
56
+ Range of frequencies in MHz for frequency slider.
57
+ Default is (10.0, 200.0, 1.0).
58
+ lifetime_range : tuple[float, float, float], optional
59
+ Range of lifetimes in ns for lifetime sliders.
60
+ Default is (0.0, 20.0, 0.1).
61
+ interactive: bool
62
+ If True, add sliders to change frequency and lifetimes interactively.
63
+ Default is False.
64
+ **kwargs:
65
+ Additional arguments passed to matplotlib figure.
66
+
67
+ """
68
+
69
+ _samples: int = 256 # number of frequencies and samples in signal
70
+ _frequency: float # current frequency in MHz
71
+ _zero_phase: float | None = None # location of IRF peak in the phase
72
+ _zero_stdev: float | None = None # standard deviation of IRF in radians
73
+ _frequencies: NDArray[Any] # for frequency domain plot
74
+
75
+ _time_plot: Axes
76
+ _phasor_plot: Axes
77
+ _phase_plot: Axes
78
+ _modulation_plot: Axes
79
+
80
+ _frequency_slider: Slider
81
+ _lifetime_sliders: list[Slider]
82
+ _fraction_sliders: list[Slider]
83
+
84
+ _signal_line: Line2D
85
+ _frequency_line: Line2D
86
+ _phase_point: Line2D
87
+ _modulation_point: Line2D
88
+
89
+ _signal_lines: list[Line2D]
90
+ _phasor_lines: list[Line2D]
91
+ _phasor_points: list[Line2D]
92
+ _phase_lines: list[Line2D]
93
+ _modulation_lines: list[Line2D]
94
+
95
+ _semicircle_line: Line2D
96
+ _semicircle_ticks: CircleTicks | None
97
+
98
+ _component_colors = (
99
+ # 'tab:blue', # main
100
+ # 'tab:red', # modulation
101
+ # 'tab:gray', # irf
102
+ 'tab:orange',
103
+ 'tab:green',
104
+ 'tab:purple',
105
+ 'tab:pink',
106
+ 'tab:olive',
107
+ 'tab:cyan',
108
+ 'tab:brown',
109
+ )
110
+
111
+ def __init__(
112
+ self,
113
+ frequency: float | None,
114
+ lifetime: ArrayLike,
115
+ fraction: ArrayLike | None = None,
116
+ *,
117
+ frequency_range: tuple[float, float, float] = (10.0, 200.0, 1.0),
118
+ lifetime_range: tuple[float, float, float] = (0.0, 20.0, 0.1),
119
+ interactive: bool = False,
120
+ **kwargs: Any,
121
+ ) -> None:
122
+ self._frequencies = numpy.logspace(-1, 4, self._samples)
123
+
124
+ (
125
+ frequency,
126
+ lifetimes,
127
+ fractions,
128
+ signal,
129
+ irf,
130
+ times,
131
+ real,
132
+ imag,
133
+ phase,
134
+ modulation,
135
+ phase_,
136
+ modulation_,
137
+ component_signal,
138
+ component_real,
139
+ component_imag,
140
+ component_phase_,
141
+ component_modulation_,
142
+ ) = self._calculate(frequency, lifetime, fraction)
143
+
144
+ self._frequency = frequency
145
+
146
+ num_components = max(lifetimes.size, 1)
147
+ if num_components > 6:
148
+ raise ValueError(f'too many components {num_components} > 6')
149
+
150
+ # create plots
151
+ update_kwargs(kwargs, figsize=(10.24, 7.68))
152
+ fig, ((time_plot, phasor_plot), (phase_plot, ax4)) = pyplot.subplots(
153
+ 2, 2, **kwargs
154
+ )
155
+
156
+ if interactive:
157
+ fcm = fig.canvas.manager
158
+ if fcm is not None:
159
+ fcm.set_window_title('PhasorPy lifetime plots')
160
+
161
+ self._signal_lines = []
162
+ self._phasor_lines = []
163
+ self._phasor_points = []
164
+ self._phase_lines = []
165
+ self._modulation_lines = []
166
+
167
+ # time domain plot
168
+ time_plot.set_title('Time domain')
169
+ time_plot.set_xlabel('Time [ns]')
170
+ time_plot.set_ylabel('Intensity [normalized]')
171
+ lines = time_plot.plot(
172
+ times,
173
+ signal,
174
+ label='Signal',
175
+ color='tab:blue',
176
+ linewidth=2,
177
+ zorder=10,
178
+ )
179
+ self._signal_lines.append(lines[0])
180
+ if num_components > 1:
181
+ for i in range(num_components):
182
+ lines = time_plot.plot(
183
+ times,
184
+ component_signal[i],
185
+ label=f'Lifetime {i}',
186
+ color=self._component_colors[i],
187
+ linewidth=0.8,
188
+ alpha=0.5,
189
+ )
190
+ self._signal_lines.append(lines[0])
191
+ lines = time_plot.plot(
192
+ times,
193
+ irf,
194
+ label='Instrument response',
195
+ color='tab:grey',
196
+ linewidth=0.8,
197
+ alpha=0.5,
198
+ )
199
+ self._signal_lines.append(lines[0])
200
+ time_plot.legend()
201
+
202
+ # phasor plot
203
+ phasorplot = PhasorPlot(ax=phasor_plot)
204
+ lines = phasorplot.semicircle(frequency)
205
+ self._semicircle_line = lines[0]
206
+ self._semicircle_ticks = phasorplot._semicircle_ticks
207
+ lines = phasorplot.plot(
208
+ real, imag, 'o', color='tab:blue', markersize=10, zorder=10
209
+ )
210
+ self._phasor_points.append(lines[0])
211
+ if num_components > 1:
212
+ for i in range(num_components):
213
+ lines = phasorplot.plot(
214
+ (real, component_real[i]),
215
+ (imag, component_imag[i]),
216
+ color=self._component_colors[i],
217
+ linestyle='-',
218
+ linewidth=0.8,
219
+ alpha=0.5,
220
+ )
221
+ self._phasor_lines.append(lines[0])
222
+ lines = phasorplot.plot(
223
+ component_real[i],
224
+ component_imag[i],
225
+ 'o',
226
+ color=self._component_colors[i],
227
+ )
228
+ self._phasor_points.append(lines[0])
229
+
230
+ # frequency domain plot
231
+ phase_plot.set_title('Frequency domain')
232
+ phase_plot.set_xscale('log', base=10)
233
+ phase_plot.set_xlabel('Frequency (MHz)')
234
+ phase_plot.set_ylabel('Phase (°)', color='tab:blue')
235
+ phase_plot.set_yticks((0.0, 30.0, 60.0, 90.0))
236
+ phase_plot.plot((1, 1), (0.0, 90.0), alpha=0.0) # set autoscale
237
+ lines = phase_plot.plot(
238
+ (frequency, frequency),
239
+ (0, 90),
240
+ '--',
241
+ color='gray',
242
+ linewidth=0.8,
243
+ alpha=0.5,
244
+ )
245
+ self._frequency_line = lines[0]
246
+ lines = phase_plot.plot(
247
+ frequency, phase, 'o', color='tab:blue', markersize=8, zorder=2
248
+ )
249
+ self._phase_point = lines[0]
250
+ lines = phase_plot.plot(
251
+ self._frequencies, phase_, color='tab:blue', linewidth=2, zorder=2
252
+ )
253
+ self._phase_lines.append(lines[0])
254
+ if num_components > 1:
255
+ for i in range(num_components):
256
+ lines = phase_plot.plot(
257
+ self._frequencies,
258
+ component_phase_[i],
259
+ color=self._component_colors[i],
260
+ linewidth=0.5,
261
+ alpha=0.5,
262
+ )
263
+ self._phase_lines.append(lines[0])
264
+ # phase_plot.text(0.1, 1, 'Phase', ha='left', va='bottom')
265
+
266
+ # TODO: zorder doesn't work.
267
+ # twinx modulation_plot is always plotted on top of phase_plot
268
+ modulation_plot = phase_plot.twinx()
269
+ modulation_plot.set_ylabel('Modulation (%)', color='tab:red')
270
+ modulation_plot.set_yticks((0.0, 25.0, 50.0, 75.0, 100.0))
271
+ modulation_plot.plot((1, 1), (0.0, 100.0), alpha=0.0) # set autoscale
272
+ lines = modulation_plot.plot(
273
+ frequency, modulation, 'o', color='tab:red', markersize=8, zorder=2
274
+ )
275
+ self._modulation_point = lines[0]
276
+ lines = modulation_plot.plot(
277
+ self._frequencies,
278
+ modulation_,
279
+ color='tab:red',
280
+ linewidth=2,
281
+ zorder=2,
282
+ )
283
+ self._modulation_lines.append(lines[0])
284
+ if num_components > 1:
285
+ for i in range(num_components):
286
+ lines = modulation_plot.plot(
287
+ self._frequencies,
288
+ component_modulation_[i],
289
+ color=self._component_colors[i],
290
+ linewidth=0.5,
291
+ alpha=0.5,
292
+ )
293
+ self._modulation_lines.append(lines[0])
294
+ # modulation_plot.text(0.1, 98, 'Modulation', ha='left', va='top')
295
+
296
+ ax4.axis('off')
297
+ self._time_plot = time_plot
298
+ self._phasor_plot = phasor_plot
299
+ self._phase_plot = phase_plot
300
+ self._modulation_plot = modulation_plot
301
+
302
+ fig.tight_layout()
303
+
304
+ if not interactive:
305
+ return
306
+
307
+ # add sliders
308
+ axes = (
309
+ fig.add_axes((0.65, 0.45 - i * 0.035, 0.25, 0.01))
310
+ for i in range(1 + 2 * num_components)
311
+ )
312
+
313
+ self._frequency_slider = Slider(
314
+ ax=next(axes),
315
+ label='Frequency ',
316
+ valfmt=' %.0f MHz',
317
+ valmin=frequency_range[0],
318
+ valmax=frequency_range[1],
319
+ valstep=frequency_range[2],
320
+ valinit=frequency,
321
+ )
322
+ self._frequency_slider.on_changed(self._on_changed)
323
+
324
+ self._lifetime_sliders = []
325
+ for i, (lifetime, color) in enumerate(
326
+ zip(numpy.atleast_1d(lifetimes), self._component_colors)
327
+ ):
328
+ slider = Slider(
329
+ ax=next(axes),
330
+ label=f'Lifetime {i} ',
331
+ valfmt=' %.2f ns',
332
+ valmin=lifetime_range[0],
333
+ valmax=lifetime_range[1],
334
+ valstep=lifetime_range[2],
335
+ valinit=lifetime, # type: ignore[arg-type]
336
+ facecolor=color,
337
+ )
338
+ slider.on_changed(self._on_changed)
339
+ self._lifetime_sliders.append(slider)
340
+
341
+ self._fraction_sliders = []
342
+ for i, (fraction, color) in enumerate(
343
+ zip(numpy.atleast_1d(fractions), self._component_colors)
344
+ ):
345
+ if num_components == 1 or (i == 1 and num_components == 2):
346
+ break
347
+ slider = Slider(
348
+ ax=next(axes),
349
+ label=f'Fraction {i} ',
350
+ valfmt=' %.2f',
351
+ valmin=0.0,
352
+ valmax=1.0,
353
+ valstep=0.01,
354
+ valinit=fraction, # type: ignore[arg-type]
355
+ facecolor=color,
356
+ )
357
+ slider.on_changed(self._on_changed)
358
+ self._fraction_sliders.append(slider)
359
+
360
+ def _calculate(
361
+ self,
362
+ frequency: float | None,
363
+ lifetimes: ArrayLike,
364
+ fractions: ArrayLike | None,
365
+ /,
366
+ ) -> tuple[
367
+ float, # frequency
368
+ NDArray[Any], # lifetimes
369
+ NDArray[Any], # fractions
370
+ NDArray[Any], # signal
371
+ NDArray[Any], # irf
372
+ NDArray[Any], # times
373
+ float, # real
374
+ float, # imag
375
+ float, # phase
376
+ float, # modulation
377
+ NDArray[Any], # phase_
378
+ NDArray[Any], # modulation_
379
+ NDArray[Any], # component_signal
380
+ NDArray[Any], # component_real
381
+ NDArray[Any], # component_imag
382
+ NDArray[Any], # component_phase_
383
+ NDArray[Any], # component_modulation_
384
+ ]:
385
+ """Return values for plotting."""
386
+ lifetimes = numpy.asarray(lifetimes)
387
+ num_components = max(lifetimes.size, 1)
388
+
389
+ if fractions is None:
390
+ fractions = numpy.ones(num_components) / num_components
391
+ else:
392
+ fractions = numpy.asarray(fractions)
393
+ num_fractions = max(fractions.size, 1)
394
+ if num_fractions != num_components:
395
+ raise ValueError(f'{num_fractions=} != {num_components=}')
396
+ s = fractions.sum()
397
+ if s > 0.0:
398
+ fractions = numpy.clip(fractions / s, 0.0, 1.0)
399
+ else:
400
+ fractions = numpy.ones(num_components) / num_components
401
+
402
+ if frequency is None:
403
+ frequency = float(
404
+ # lifetime_to_frequency(numpy.atleast_1d(lifetimes)[0])
405
+ # lifetime_to_frequency(numpy.mean(lifetimes * fractions))
406
+ lifetime_to_frequency(numpy.mean(lifetimes))
407
+ )
408
+
409
+ signal, irf, times = lifetime_to_signal(
410
+ frequency,
411
+ lifetimes,
412
+ fractions,
413
+ mean=1.0,
414
+ samples=self._samples,
415
+ zero_phase=self._zero_phase,
416
+ zero_stdev=self._zero_stdev,
417
+ )
418
+ signal_max = signal.max()
419
+ signal /= signal_max
420
+ irf /= signal_max
421
+
422
+ component_signal = lifetime_to_signal(
423
+ frequency,
424
+ lifetimes,
425
+ mean=fractions,
426
+ samples=self._samples,
427
+ zero_phase=self._zero_phase,
428
+ zero_stdev=self._zero_stdev,
429
+ )[0]
430
+ component_signal /= signal_max
431
+
432
+ real, imag = phasor_from_lifetime(frequency, lifetimes, fractions)
433
+ component_real, component_imag = phasor_from_lifetime(
434
+ frequency, lifetimes
435
+ )
436
+
437
+ phase, modulation = _degpct(*phasor_to_polar(real, imag))
438
+ phase_, modulation_ = _degpct(
439
+ *phasor_to_polar(
440
+ *phasor_from_lifetime(self._frequencies, lifetimes, fractions)
441
+ )
442
+ )
443
+ component_phase_, component_modulation_ = phasor_to_polar(
444
+ *phasor_from_lifetime(self._frequencies, lifetimes)
445
+ )
446
+ component_phase_, component_modulation_ = _degpct(
447
+ component_phase_.T, component_modulation_.T
448
+ )
449
+
450
+ return (
451
+ frequency,
452
+ lifetimes,
453
+ fractions,
454
+ signal,
455
+ irf,
456
+ times,
457
+ float(real),
458
+ float(imag),
459
+ float(phase),
460
+ float(modulation),
461
+ phase_,
462
+ modulation_,
463
+ component_signal,
464
+ component_real,
465
+ component_imag,
466
+ component_phase_,
467
+ component_modulation_,
468
+ ) # type: ignore[return-value]
469
+
470
+ def _on_changed(self, value: Any) -> None:
471
+ """Callback function to update plot with current slider values."""
472
+ frequency = self._frequency_slider.val
473
+
474
+ if frequency != self._frequency:
475
+ if self._semicircle_ticks is not None:
476
+ lifetime, labels = _semicircle_ticks(frequency)
477
+ self._semicircle_ticks.labels = labels
478
+ self._semicircle_line.set_data(
479
+ *phasor_transform(
480
+ *phasor_from_lifetime(frequency, lifetime)
481
+ )
482
+ )
483
+ self._frequency_line.set_data([frequency, frequency], [0, 90])
484
+ # self._time_plot.set_title(f'Time domain ({frequency:.0f} MHz)')
485
+ # self._phasor_plot.set_title(f'Phasor plot ({frequency:.0f} MHz)')
486
+
487
+ lifetimes = numpy.asarray([s.val for s in self._lifetime_sliders])
488
+ fractions = numpy.asarray([s.val for s in self._fraction_sliders])
489
+
490
+ num_components = len(lifetimes)
491
+ if num_components == 1:
492
+ fractions = numpy.asarray([1.0])
493
+ elif num_components == 2:
494
+ fractions = numpy.asarray([fractions[0], 1.0 - fractions[0]])
495
+
496
+ (
497
+ frequency,
498
+ lifetimes,
499
+ fractions,
500
+ signal,
501
+ irf,
502
+ times,
503
+ real,
504
+ imag,
505
+ phase,
506
+ modulation,
507
+ phase_,
508
+ modulation_,
509
+ component_signal,
510
+ component_real,
511
+ component_imag,
512
+ component_phase_,
513
+ component_modulation_,
514
+ ) = self._calculate(frequency, lifetimes, fractions)
515
+
516
+ # time domain plot
517
+ self._signal_lines[0].set_data(times, signal)
518
+ if num_components > 1:
519
+ for i in range(num_components):
520
+ self._signal_lines[i + 1].set_data(times, component_signal[i])
521
+ self._signal_lines[-1].set_data(times, irf)
522
+ if frequency != self._frequency:
523
+ self._time_plot.relim()
524
+ self._time_plot.autoscale_view()
525
+
526
+ # phasor plot
527
+ self._phasor_points[0].set_data([real], [imag])
528
+ if num_components > 1:
529
+ for i in range(num_components):
530
+ self._phasor_lines[i].set_data(
531
+ [real, component_real[i]], [imag, component_imag[i]]
532
+ )
533
+ self._phasor_points[i + 1].set_data(
534
+ [component_real[i]], [component_imag[i]]
535
+ )
536
+
537
+ # frequency domain plot
538
+ self._frequency_line.set_data([frequency, frequency], [0, 90])
539
+ self._phase_point.set_data([frequency], [phase])
540
+ self._modulation_point.set_data([frequency], [modulation])
541
+ self._phase_lines[0].set_data(self._frequencies, phase_)
542
+ self._modulation_lines[0].set_data(self._frequencies, modulation_)
543
+ if num_components > 1:
544
+ for i in range(num_components):
545
+ self._phase_lines[i + 1].set_data(
546
+ self._frequencies, component_phase_[i]
547
+ )
548
+ self._modulation_lines[i + 1].set_data(
549
+ self._frequencies, component_modulation_[i]
550
+ )
551
+
552
+ self._frequency = frequency
553
+
554
+ def show(self) -> None:
555
+ """Display all open figures. Call :py:func:`matplotlib.pyplot.show`."""
556
+ pyplot.show()
557
+
558
+
559
+ def _degpct(
560
+ phase: ArrayLike, modulation: ArrayLike, /
561
+ ) -> tuple[NDArray[Any], NDArray[Any]]:
562
+ """Return phase in degrees and modulation in percent."""
563
+ return numpy.rad2deg(phase), numpy.multiply(modulation, 100.0)