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