phasorpy 0.6__cp311-cp311-win_amd64.whl → 0.7__cp311-cp311-win_amd64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
phasorpy/__init__.py CHANGED
@@ -5,5 +5,5 @@ from __future__ import annotations
5
5
  __all__ = ['__version__']
6
6
 
7
7
 
8
- __version__ = '0.6'
8
+ __version__ = '0.7'
9
9
  """PhasorPy version string."""
Binary file
phasorpy/_phasorpy.pyx CHANGED
@@ -488,10 +488,10 @@ cdef (double, double) _phasor_from_fret_donor(
488
488
  return 1.0, 0.0
489
489
 
490
490
  # phasor of pure donor at frequency
491
- real, imag = phasor_from_lifetime(donor_lifetime, omega)
491
+ real, imag = phasor_from_single_lifetime(donor_lifetime, omega)
492
492
 
493
493
  # phasor of quenched donor
494
- quenched_real, quenched_imag = phasor_from_lifetime(
494
+ quenched_real, quenched_imag = phasor_from_single_lifetime(
495
495
  donor_lifetime * (1.0 - fret_efficiency), omega
496
496
  )
497
497
 
@@ -555,14 +555,14 @@ cdef (double, double) _phasor_from_fret_acceptor(
555
555
  acceptor_background = 0.0
556
556
 
557
557
  # phasor of pure donor at frequency
558
- donor_real, donor_imag = phasor_from_lifetime(donor_lifetime, omega)
558
+ donor_real, donor_imag = phasor_from_single_lifetime(donor_lifetime, omega)
559
559
 
560
560
  if fret_efficiency == 0.0:
561
561
  quenched_real = donor_real
562
562
  quenched_imag = donor_imag
563
563
  else:
564
564
  # phasor of quenched donor
565
- quenched_real, quenched_imag = phasor_from_lifetime(
565
+ quenched_real, quenched_imag = phasor_from_single_lifetime(
566
566
  donor_lifetime * (1.0 - fret_efficiency), omega
567
567
  )
568
568
 
@@ -580,7 +580,7 @@ cdef (double, double) _phasor_from_fret_acceptor(
580
580
  )
581
581
 
582
582
  # phasor of acceptor at frequency
583
- acceptor_real, acceptor_imag = phasor_from_lifetime(
583
+ acceptor_real, acceptor_imag = phasor_from_single_lifetime(
584
584
  acceptor_lifetime, omega
585
585
  )
586
586
 
@@ -646,9 +646,9 @@ cdef inline (double, double) linear_combination(
646
646
  )
647
647
 
648
648
 
649
- cdef inline (float_t, float_t) phasor_from_lifetime(
650
- float_t lifetime,
651
- float_t omega,
649
+ cdef inline (double, double) phasor_from_single_lifetime(
650
+ const double lifetime,
651
+ const double omega,
652
652
  ) noexcept nogil:
653
653
  """Return phasor coordinates from single lifetime component."""
654
654
  cdef:
@@ -656,7 +656,7 @@ cdef inline (float_t, float_t) phasor_from_lifetime(
656
656
  double mod = 1.0 / sqrt(1.0 + t * t)
657
657
  double phi = atan(t)
658
658
 
659
- return <float_t> (mod * cos(phi)), <float_t> (mod * sin(phi))
659
+ return mod * cos(phi), mod * sin(phi)
660
660
 
661
661
 
662
662
  ###############################################################################
@@ -1706,6 +1706,164 @@ cdef (float_t, float_t, float_t, float_t) _intersect_semicircle_line(
1706
1706
  return x0, y0, x1, y1
1707
1707
 
1708
1708
 
1709
+ ###############################################################################
1710
+ # Search functions
1711
+
1712
+
1713
+ def _lifetime_search_2(
1714
+ float_t[:, ::] lifetime, # (num_components, pixels)
1715
+ float_t[:, ::] fraction, # (num_components, pixels)
1716
+ const float_t[:, ::] real, # (num_components, pixels)
1717
+ const float_t[:, ::] imag, # (num_components, pixels)
1718
+ const double[::] candidate, # real coordinates to scan
1719
+ const double omega_sqr,
1720
+ const int num_threads
1721
+ ):
1722
+ """Find two lifetime components and fractions in harmonic coordinates.
1723
+
1724
+ https://doi.org/10.1021/acs.jpcb.0c06946
1725
+
1726
+ """
1727
+ cdef:
1728
+ ssize_t i, u
1729
+ double re1, im1, re2, im2
1730
+ double g0, g1, g0h1, s0h1, g1h1, s1h1, g0h2, s0h2, g1h2, s1h2
1731
+ double x, y, dx, dy, dr, dd, rdd
1732
+ double dmin, d, f, t
1733
+
1734
+ if lifetime.shape[0] != 2 or lifetime.shape[1] != real.shape[1]:
1735
+ raise ValueError('lifetime shape invalid')
1736
+ if fraction.shape[0] != 2 or fraction.shape[1] != real.shape[1]:
1737
+ raise ValueError('fraction shape invalid')
1738
+ if real.shape[0] != imag.shape[0] != 2:
1739
+ raise ValueError('phasor harmonics invalid')
1740
+ if real.shape[1] != imag.shape[1]:
1741
+ raise ValueError('phasor size invalid')
1742
+ if candidate.shape[0] < 1:
1743
+ raise ValueError('candidate size < 1')
1744
+
1745
+ with nogil, parallel(num_threads=num_threads):
1746
+
1747
+ for u in prange(real.shape[1]):
1748
+ # loop over phasor coordinates
1749
+ re1 = real[0, u]
1750
+ re2 = real[1, u]
1751
+ im1 = imag[0, u]
1752
+ im2 = imag[1, u]
1753
+
1754
+ if (
1755
+ isnan(re1)
1756
+ or isnan(im1)
1757
+ or isnan(re2)
1758
+ or isnan(im2)
1759
+ # outside semicircle?
1760
+ or re1 < 0.0
1761
+ or re2 < 0.0
1762
+ or re1 > 1.0
1763
+ or re2 > 1.0
1764
+ or im1 < 0.0
1765
+ or im2 < 0.0
1766
+ or im1 * im1 > re1 - re1 * re1 + 1e-9
1767
+ or im2 * im2 > re2 - re2 * re2 + 1e-9
1768
+ ):
1769
+ lifetime[0, u] = NAN
1770
+ lifetime[1, u] = NAN
1771
+ fraction[0, u] = NAN
1772
+ fraction[1, u] = NAN
1773
+ continue
1774
+
1775
+ dmin = INFINITY
1776
+ g0 = NAN
1777
+ g1 = NAN
1778
+ f = NAN
1779
+
1780
+ for i in range(candidate.shape[0]):
1781
+ # scan first component
1782
+ g0h1 = candidate[i]
1783
+ s0h1 = sqrt(g0h1 - g0h1 * g0h1)
1784
+
1785
+ # second component is intersection of semicircle with line
1786
+ # between first component and phasor coordinate
1787
+ dx = re1 - g0h1
1788
+ dy = im1 - s0h1
1789
+ dr = dx * dx + dy * dy
1790
+ dd = (g0h1 - 0.5) * im1 - (re1 - 0.5) * s0h1
1791
+ rdd = 0.25 * dr - dd * dd # discriminant
1792
+ if rdd < 0.0 or dr <= 0.0:
1793
+ # no intersection
1794
+ g0 = g0h1
1795
+ g1 = g0h1 # NAN?
1796
+ f = 1.0
1797
+ break
1798
+ rdd = sqrt(rdd)
1799
+ g0h1 = (dd * dy - copysign(1.0, dy) * dx * rdd) / dr + 0.5
1800
+ s0h1 = (-dd * dx - fabs(dy) * rdd) / dr
1801
+ g1h1 = (dd * dy + copysign(1.0, dy) * dx * rdd) / dr + 0.5
1802
+ s1h1 = (-dd * dx + fabs(dy) * rdd) / dr
1803
+
1804
+ # this check is numerically unstable if candidate=1.0
1805
+ if s0h1 < 0.0 or s1h1 < 0.0:
1806
+ # no other intersection with semicircle
1807
+ continue
1808
+
1809
+ if g0h1 < g1h1:
1810
+ t = g0h1
1811
+ g0h1 = g1h1
1812
+ g1h1 = t
1813
+ t = s0h1
1814
+ s0h1 = s1h1
1815
+ s1h1 = t
1816
+
1817
+ # second harmonic component coordinates on semicircle
1818
+ g0h2 = g0h1 / (4.0 - 3.0 * g0h1)
1819
+ s0h2 = sqrt(g0h2 - g0h2 * g0h2)
1820
+ g1h2 = g1h1 / (4.0 - 3.0 * g1h1)
1821
+ s1h2 = sqrt(g1h2 - g1h2 * g1h2)
1822
+
1823
+ # distance of phasor coordinates to line between
1824
+ # components at second harmonic
1825
+ # normalize line coordinates
1826
+ dx = g1h2 - g0h2
1827
+ dy = s1h2 - s0h2
1828
+ x = re2 - g0h2
1829
+ y = im2 - s0h2
1830
+ # square of line length
1831
+ t = dx * dx + dy * dy
1832
+ if t == 0.0:
1833
+ continue
1834
+ # projection of point on line using dot product
1835
+ t = (x * dx + y * dy) / t
1836
+ # square of distance of point to line
1837
+ dx = x - t * dx
1838
+ dy = y - t * dy
1839
+ d = dx * dx + dy * dy
1840
+
1841
+ if d < dmin:
1842
+ dmin = d
1843
+ g0 = g0h1
1844
+ g1 = g1h1
1845
+ f = t
1846
+
1847
+ lifetime[0, u] = <float_t> phasor_to_single_lifetime(g0, omega_sqr)
1848
+ lifetime[1, u] = <float_t> phasor_to_single_lifetime(g1, omega_sqr)
1849
+ fraction[0, u] = <float_t> (1.0 - f)
1850
+ fraction[1, u] = <float_t> f
1851
+
1852
+
1853
+ cdef inline double phasor_to_single_lifetime(
1854
+ const double real,
1855
+ const double omega_sqr,
1856
+ ) noexcept nogil:
1857
+ """Return single exponential lifetime from real coordinate."""
1858
+ cdef:
1859
+ double t
1860
+
1861
+ if isnan(real) or real < 0.0 or real > 1.0:
1862
+ return NAN
1863
+ t = real * omega_sqr
1864
+ return sqrt((1.0 - real) / t) if t > 0.0 else INFINITY
1865
+
1866
+
1709
1867
  def _nearest_neighbor_2d(
1710
1868
  int_t[::1] indices,
1711
1869
  const float_t[::1] x0,
@@ -1755,6 +1913,120 @@ def _nearest_neighbor_2d(
1755
1913
  indices[i] = -1 if dmin > distance_max_squared else <int_t> index
1756
1914
 
1757
1915
 
1916
+ ###############################################################################
1917
+ # Interpolation functions
1918
+
1919
+
1920
+ def _mean_value_coordinates(
1921
+ float_t[:, ::1] fraction, # vertices, points
1922
+ const ssize_t[::1] order,
1923
+ const float_t[::1] px, # points
1924
+ const float_t[::1] py,
1925
+ const float_t[::1] vx, # polygon vertices
1926
+ const float_t[::1] vy,
1927
+ const int num_threads
1928
+ ):
1929
+ """Calculate mean value coordinates of points in polygon.
1930
+
1931
+ https://doi.org/10.1016/j.cagd.2024.102310
1932
+
1933
+ """
1934
+ cdef:
1935
+ ssize_t i, j, k, p, nv
1936
+ double x, y, alpha, weight, weight_sum
1937
+ double* weights = NULL
1938
+ double* sigma = NULL
1939
+ double* length = NULL
1940
+
1941
+ if px.shape[0] != py.shape[0]:
1942
+ raise ValueError('px and py shape mismatch')
1943
+ if vx.shape[0] != vy.shape[0]:
1944
+ raise ValueError('vx and vy shape mismatch')
1945
+ if fraction.shape[0] != vx.shape[0] or fraction.shape[1] != px.shape[0]:
1946
+ raise ValueError('fraction, vx or px shape mismatch')
1947
+ if fraction.shape[0] != order.shape[0]:
1948
+ raise ValueError('fraction and order shape mismatch')
1949
+ if fraction.shape[0] < 3:
1950
+ raise ValueError('not a polygon')
1951
+
1952
+ nv = fraction.shape[0]
1953
+
1954
+ with nogil, parallel(num_threads=num_threads):
1955
+ weights = <double *> malloc(3 * nv * sizeof(double))
1956
+ if weights == NULL:
1957
+ with gil:
1958
+ raise MemoryError('failed to allocate thread-local buffer')
1959
+ sigma = &weights[nv]
1960
+ length = &weights[nv * 2]
1961
+
1962
+ for p in prange(px.shape[0]):
1963
+ x = px[p]
1964
+ y = py[p]
1965
+
1966
+ if isnan(x) or isnan(y):
1967
+ for i in range(nv):
1968
+ fraction[i, p] = <float_t> NAN
1969
+ continue
1970
+
1971
+ for i in range(nv):
1972
+ j = (i + 1) % nv # next vertex, wrapped around
1973
+ sigma[i] = (
1974
+ angle(vx[j], vy[j], vx[i], vy[i], x, y) # beta
1975
+ - angle(vx[i], vy[i], vx[j], vy[j], x, y) # gamma
1976
+ )
1977
+ length[i] = hypot(vx[i] - x, vy[i] - y)
1978
+
1979
+ weight_sum = 0.0
1980
+ for i in range(nv):
1981
+ j = (i + 1) % nv # next vertex, wrapped around
1982
+ k = (i - 1 + nv) % nv # previous vertex, wrapped around
1983
+
1984
+ alpha = angle(vx[k], vy[k], x, y, vx[j], vy[j])
1985
+ if sign(alpha) != sign(
1986
+ M_PI * (sign(sigma[k]) + sign(sigma[i]))
1987
+ - sigma[k] - sigma[i]
1988
+ ):
1989
+ alpha = -alpha
1990
+ weight = length[k] * sin(alpha * 0.5)
1991
+ for j in range(nv):
1992
+ if j != k and j != i:
1993
+ weight = weight * length[j] * sin(fabs(sigma[j]) * 0.5)
1994
+ weight_sum = weight_sum + weight
1995
+ weights[i] = weight
1996
+
1997
+ if fabs(weight_sum) > 1e-12:
1998
+ for i in range(nv):
1999
+ fraction[order[i], p] = <float_t> (weights[i] / weight_sum)
2000
+ else:
2001
+ for i in range(nv):
2002
+ fraction[i, p] = <float_t> NAN
2003
+
2004
+ free(weights)
2005
+
2006
+
2007
+ cdef inline int sign(const double x) noexcept nogil:
2008
+ """Return sign of x."""
2009
+ return 0 if fabs(x) < 1e-12 else (1 if x > 0.0 else -1)
2010
+
2011
+
2012
+ cdef inline double angle(
2013
+ const double x0,
2014
+ const double y0,
2015
+ const double x1,
2016
+ const double y1,
2017
+ const double x2,
2018
+ const double y2,
2019
+ ) noexcept nogil:
2020
+ """Return angle at (x1, y1)."""
2021
+ cdef:
2022
+ double ax = x0 - x1
2023
+ double ay = y0 - y1
2024
+ double bx = x2 - x1
2025
+ double by = y2 - y1
2026
+
2027
+ return atan2(ax * by - ay * bx, ax * bx + ay * by)
2028
+
2029
+
1758
2030
  ###############################################################################
1759
2031
  # Blend ufuncs
1760
2032
 
phasorpy/_utils.py CHANGED
@@ -52,6 +52,23 @@ def parse_kwargs(
52
52
 
53
53
  If `_del` is true (default), existing keys are deleted from `kwargs`.
54
54
 
55
+ Parameters
56
+ ----------
57
+ kwargs : dict
58
+ Source dictionary to extract keys from.
59
+ *keys : str
60
+ Keys to extract from kwargs if present.
61
+ _del : bool, default: True
62
+ If True, remove extracted keys from kwargs.
63
+ **keyvalues : Any
64
+ Key-value pairs. If key exists in kwargs, use kwargs value,
65
+ otherwise use provided default value.
66
+
67
+ Returns
68
+ -------
69
+ dict
70
+ Dictionary containing extracted keys and values.
71
+
55
72
  >>> kwargs = {'one': 1, 'two': 2, 'four': 4}
56
73
  >>> kwargs2 = parse_kwargs(kwargs, 'two', 'three', four=None, five=5)
57
74
  >>> kwargs == {'one': 1}
@@ -105,19 +122,19 @@ def scale_matrix(factor: float, origin: Sequence[float]) -> NDArray[Any]:
105
122
 
106
123
  Parameters
107
124
  ----------
108
- factor: float
125
+ factor : float
109
126
  Scale factor.
110
- origin: (float, float)
127
+ origin : (float, float)
111
128
  Coordinates of point around which to scale.
112
129
 
113
130
  Returns
114
131
  -------
115
- matrix: ndarray
132
+ matrix : ndarray
116
133
  A 3x3 homogeneous transformation matrix.
117
134
 
118
135
  Examples
119
136
  --------
120
- >>> scale_matrix(1.1, (0.0, 0.5))
137
+ >>> scale_matrix(1.1, [0.0, 0.5])
121
138
  array([[1.1, 0, -0],
122
139
  [0, 1.1, -0.05],
123
140
  [0, 0, 1]])
@@ -133,7 +150,7 @@ def sort_coordinates(
133
150
  real: ArrayLike,
134
151
  imag: ArrayLike,
135
152
  /,
136
- origin: tuple[float, float] | None = None,
153
+ origin: ArrayLike | None = None,
137
154
  ) -> tuple[NDArray[Any], NDArray[Any], NDArray[Any]]:
138
155
  """Return cartesian coordinates sorted counterclockwise around origin.
139
156
 
@@ -141,15 +158,19 @@ def sort_coordinates(
141
158
  ----------
142
159
  real, imag : array_like
143
160
  Coordinates to be sorted.
144
- origin : (float, float)
161
+ origin : array_like, optional
145
162
  Coordinates around which to sort by angle.
163
+ By default, sort around the mean of `real` and `imag`.
146
164
 
147
165
  Returns
148
166
  -------
149
- real, imag : ndarray
150
- Coordinates sorted by angle.
167
+ real : ndarray
168
+ Sorted real coordinates.
169
+ imag : ndarray
170
+ Sorted imaginary coordinates.
151
171
  indices : ndarray
152
172
  Indices used to reorder coordinates.
173
+ Use ``indices.argsort()`` to get original order.
153
174
 
154
175
  Examples
155
176
  --------
@@ -160,11 +181,15 @@ def sort_coordinates(
160
181
  x, y = numpy.atleast_1d(real, imag)
161
182
  if x.ndim != 1 or x.shape != y.shape:
162
183
  raise ValueError(f'invalid {x.shape=} or {y.shape=}')
163
- if x.size < 4:
184
+ if x.size < 3:
164
185
  return x, y, numpy.arange(x.size)
165
186
  if origin is None:
166
- origin = x.mean(), y.mean()
167
- indices = numpy.argsort(numpy.arctan2(y - origin[1], x - origin[0]))
187
+ ox, oy = x.mean(), y.mean()
188
+ else:
189
+ origin = numpy.asarray(origin, dtype=numpy.float64)
190
+ ox = origin[0]
191
+ oy = origin[1]
192
+ indices = numpy.argsort(numpy.arctan2(y - oy, x - ox))
168
193
  return x[indices], y[indices], indices
169
194
 
170
195
 
@@ -185,8 +210,10 @@ def dilate_coordinates(
185
210
 
186
211
  Returns
187
212
  -------
188
- real, imag : ndarray
189
- Coordinates dilated by offset.
213
+ real : ndarray
214
+ Dilated real coordinates.
215
+ imag : ndarray
216
+ Dilated imaginary coordinates.
190
217
 
191
218
  Examples
192
219
  --------
@@ -225,8 +252,28 @@ def phasor_to_polar_scalar(
225
252
  ) -> tuple[float, float]:
226
253
  """Return polar from scalar phasor coordinates.
227
254
 
228
- >>> phasor_to_polar_scalar(1.0, 0.0, degree=True, percent=True)
229
- (0.0, 100.0)
255
+ Parameters
256
+ ----------
257
+ real : float
258
+ Real component of phasor coordinate.
259
+ imag : float
260
+ Imaginary component of phasor coordinate.
261
+ degree : bool, optional
262
+ If true, return phase in degrees instead of radians.
263
+ percent : bool, optional
264
+ If true, return modulation as percentage instead of fraction.
265
+
266
+ Returns
267
+ -------
268
+ phase : float
269
+ Phase angle in radians (or degrees if degree=True).
270
+ modulation : float
271
+ Modulation depth as fraction (or percentage if percent=True).
272
+
273
+ Examples
274
+ --------
275
+ >>> phasor_to_polar_scalar(0.0, 1.0, degree=True, percent=True)
276
+ (90.0, 100.0)
230
277
 
231
278
  """
232
279
  phi = math.atan2(imag, real)
@@ -248,6 +295,26 @@ def phasor_from_polar_scalar(
248
295
  ) -> tuple[float, float]:
249
296
  """Return phasor from scalar polar coordinates.
250
297
 
298
+ Parameters
299
+ ----------
300
+ phase : float
301
+ Phase angle in radians (or degrees if degree=True).
302
+ modulation : float
303
+ Modulation depth as fraction (or percentage if percent=True).
304
+ degree : bool, optional
305
+ If true, phase is in degrees instead of radians.
306
+ percent : bool, optional
307
+ If true, modulation is as percentage instead of fraction.
308
+
309
+ Returns
310
+ -------
311
+ real : float
312
+ Real component of phasor coordinate.
313
+ imag : float
314
+ Imaginary component of phasor coordinate.
315
+
316
+ Examples
317
+ --------
251
318
  >>> phasor_from_polar_scalar(0.0, 100.0, degree=True, percent=True)
252
319
  (1.0, 0.0)
253
320
 
@@ -273,23 +340,27 @@ def parse_signal_axis(
273
340
  Parameters
274
341
  ----------
275
342
  signal : array_like
276
- Image stack.
277
- axis : int or str, optional
278
- Axis over which phasor coordinates are computed.
279
- By default, the 'H' or 'C' axes if `signal` contains such
280
- dimension names, else the last axis (-1).
343
+ Signal array.
344
+ Axis names are used if it has a `dims` attribute.
345
+ axis : int, str, or None, default: None
346
+ Axis over which to compute phasor coordinates.
347
+ If None, automatically selects 'H' or 'C' axis if available,
348
+ otherwise uses the last axis (-1).
349
+ If int, specifies axis index.
350
+ If str, specifies axis name (requires `signal.dims`).
281
351
 
282
352
  Returns
283
353
  -------
284
354
  axis : int
285
- Axis over which phasor coordinates are computed.
355
+ Index of axis over which phasor coordinates are computed.
286
356
  axis_label : str
287
- Axis label from `signal.dims` if any.
357
+ Label of axis from `signal.dims` if available, empty string otherwise.
288
358
 
289
359
  Raises
290
360
  ------
291
361
  ValueError
292
- Axis not found in signal.dims or invalid for signal type.
362
+ If axis string is not found in signal.dims.
363
+ If axis string is provided but signal has no dims attribute.
293
364
 
294
365
  Examples
295
366
  --------
@@ -356,15 +427,17 @@ def parse_skip_axis(
356
427
 
357
428
  Raises
358
429
  ------
430
+ ValueError
431
+ If ndim is negative.
359
432
  IndexError
360
433
  If any `skip_axis` value is out of bounds of `ndim`.
361
434
 
362
435
  Examples
363
436
  --------
364
- >>> parse_skip_axis((1, -2), 5)
437
+ >>> parse_skip_axis([1, -2], 5)
365
438
  ((1, 3), (0, 2, 4))
366
439
 
367
- >>> parse_skip_axis((1, -2), 5, True)
440
+ >>> parse_skip_axis([1, -2], 5, True)
368
441
  ((0, 2, 4), (1, 3, 5))
369
442
 
370
443
  """
@@ -401,7 +474,7 @@ def parse_harmonic(
401
474
  harmonic : int, sequence of int, 'all', or None
402
475
  Harmonic parameter to parse.
403
476
  harmonic_max : int, optional
404
- Maximum value allowed in `hamonic`. Must be one or greater.
477
+ Maximum value allowed in `harmonic`. Must be one or greater.
405
478
  To verify against known number of signal samples,
406
479
  pass ``samples // 2``.
407
480
  If `harmonic='all'`, a range of harmonics from one to `harmonic_max`
@@ -487,11 +560,11 @@ def chunk_iter(
487
560
  pattern : str, optional
488
561
  String to format chunk indices.
489
562
  If None, use ``_[{dims[index]}{chunk_index[index]}]`` for each axis.
490
- squeeze : bool
563
+ squeeze : bool, optional
491
564
  If true, do not include length-1 chunked dimensions in label
492
565
  unless dimensions are part of `chunk_shape`.
493
566
  Applies only if `pattern` is None.
494
- use_index : bool
567
+ use_index : bool, optional
495
568
  If true, use indices of chunks in `shape` instead of chunk indices to
496
569
  format pattern.
497
570
 
phasorpy/cli.py CHANGED
@@ -86,6 +86,13 @@ def fret(hide: bool) -> None:
86
86
 
87
87
 
88
88
  @main.command(help='Start interactive lifetime plots.')
89
+ @click.argument(
90
+ 'number_lifetimes',
91
+ default=2,
92
+ type=click.IntRange(1, 5),
93
+ required=False,
94
+ # help='Number of preconfigured lifetimes.',
95
+ )
89
96
  @click.option(
90
97
  '-f',
91
98
  '--frequency',
@@ -96,7 +103,7 @@ def fret(hide: bool) -> None:
96
103
  @click.option(
97
104
  '-l',
98
105
  '--lifetime',
99
- default=(4.0, 1.0),
106
+ # default=(4.0, 1.0),
100
107
  type=float,
101
108
  multiple=True,
102
109
  required=False,
@@ -118,14 +125,25 @@ def fret(hide: bool) -> None:
118
125
  help='Do not show interactive plot.',
119
126
  )
120
127
  def lifetime(
128
+ number_lifetimes: int,
121
129
  frequency: float | None,
122
130
  lifetime: tuple[float, ...],
123
131
  fraction: tuple[float, ...],
124
132
  hide: bool,
125
133
  ) -> None:
126
134
  """Lifetime command group."""
135
+ from .lifetime import phasor_semicircle, phasor_to_normal_lifetime
127
136
  from .plot import LifetimePlots
128
137
 
138
+ if not lifetime:
139
+ if number_lifetimes == 2:
140
+ lifetime = (4.0, 1.0)
141
+ else:
142
+ real, imag = phasor_semicircle(number_lifetimes + 2)
143
+ lifetime = phasor_to_normal_lifetime(
144
+ real[1:-1], imag[1:-1], frequency if frequency else 80.0
145
+ ) # type: ignore[assignment]
146
+
129
147
  plot = LifetimePlots(
130
148
  frequency,
131
149
  lifetime,