phasorpy 0.5__cp313-cp313-win_arm64.whl → 0.6__cp313-cp313-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.
phasorpy/__init__.py CHANGED
@@ -2,9 +2,8 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __all__ = ['__version__', 'versions']
5
+ __all__ = ['__version__']
6
6
 
7
- from .version import __version__, versions
8
7
 
9
- __version__ = __version__ # pylint: disable=self-assigning-variable
8
+ __version__ = '0.6'
10
9
  """PhasorPy version string."""
Binary file
phasorpy/_phasorpy.pyx CHANGED
@@ -50,6 +50,12 @@ ctypedef fused uint_t:
50
50
  uint32_t
51
51
  uint64_t
52
52
 
53
+ ctypedef fused int_t:
54
+ int8_t
55
+ int16_t
56
+ int32_t
57
+ int64_t
58
+
53
59
  ctypedef fused signal_t:
54
60
  uint8_t
55
61
  uint16_t
@@ -767,6 +773,33 @@ cdef (float_t, float_t) _phasor_from_apparent_lifetime(
767
773
  return <float_t> (mod * cos(phi)), <float_t> (mod * sin(phi))
768
774
 
769
775
 
776
+ @cython.ufunc
777
+ cdef float_t _phasor_to_normal_lifetime(
778
+ float_t real,
779
+ float_t imag,
780
+ float_t omega,
781
+ ) noexcept nogil:
782
+ """Return normal lifetimes from phasor coordinates."""
783
+ cdef:
784
+ double taunorm = INFINITY
785
+ double t
786
+
787
+ if isnan(real) or isnan(imag):
788
+ return <float_t> NAN
789
+
790
+ omega *= omega
791
+ if omega > 0.0:
792
+ t = 0.5 * (1.0 + cos(atan2(imag, real - 0.5)))
793
+ if t <= 0.0:
794
+ taunorm = INFINITY
795
+ elif t > 1.0:
796
+ taunorm = NAN
797
+ else:
798
+ taunorm = sqrt((1.0 - t) / (omega * t))
799
+
800
+ return <float_t> taunorm
801
+
802
+
770
803
  @cython.ufunc
771
804
  cdef (float_t, float_t) _phasor_from_single_lifetime(
772
805
  float_t lifetime,
@@ -1204,6 +1237,44 @@ cdef unsigned char _is_inside_stadium(
1204
1237
  _is_near_segment = _is_inside_stadium
1205
1238
 
1206
1239
 
1240
+ @cython.ufunc
1241
+ cdef unsigned char _is_inside_semicircle(
1242
+ float_t x, # point
1243
+ float_t y,
1244
+ float_t r, # distance
1245
+ ) noexcept nogil:
1246
+ """Return whether point is inside universal semicircle."""
1247
+ if r < 0.0 or isnan(x) or isnan(y):
1248
+ return False
1249
+ if y < -r:
1250
+ return False
1251
+ if y <= 0.0:
1252
+ if x >= 0.0 and x <= 1.0:
1253
+ return True
1254
+ # near endpoints?
1255
+ if x > 0.5:
1256
+ x -= <float_t> 1.0
1257
+ return x * x + y * y <= r * r
1258
+ return hypot(x - 0.5, y) <= r + 0.5
1259
+
1260
+
1261
+ @cython.ufunc
1262
+ cdef unsigned char _is_near_semicircle(
1263
+ float_t x, # point
1264
+ float_t y,
1265
+ float_t r, # distance
1266
+ ) noexcept nogil:
1267
+ """Return whether point is near universal semicircle."""
1268
+ if r < 0.0 or isnan(x) or isnan(y):
1269
+ return False
1270
+ if y < 0.0:
1271
+ # near endpoints?
1272
+ if x > 0.5:
1273
+ x -= <float_t> 1.0
1274
+ return x * x + y * y <= r * r
1275
+ return fabs(hypot(x - 0.5, y) - 0.5) <= r
1276
+
1277
+
1207
1278
  @cython.ufunc
1208
1279
  cdef unsigned char _is_near_line(
1209
1280
  float_t x, # point
@@ -1470,6 +1541,22 @@ cdef float_t _distance_from_line(
1470
1541
  return <float_t> hypot(x, y)
1471
1542
 
1472
1543
 
1544
+ @cython.ufunc
1545
+ cdef float_t _distance_from_semicircle(
1546
+ float_t x, # point
1547
+ float_t y,
1548
+ ) noexcept nogil:
1549
+ """Return distance from universal semicircle."""
1550
+ if isnan(x) or isnan(y):
1551
+ return NAN
1552
+ if y < 0.0:
1553
+ # distance to endpoints
1554
+ if x > 0.5:
1555
+ x -= <float_t> 1.0
1556
+ return <float_t> hypot(x, y)
1557
+ return <float_t> fabs(hypot(x - 0.5, y) - 0.5)
1558
+
1559
+
1473
1560
  @cython.ufunc
1474
1561
  cdef (float_t, float_t, float_t) _segment_direction_and_length(
1475
1562
  float_t x0, # segment start
@@ -1495,7 +1582,7 @@ cdef (float_t, float_t, float_t) _segment_direction_and_length(
1495
1582
 
1496
1583
 
1497
1584
  @cython.ufunc
1498
- cdef (float_t, float_t, float_t, float_t) _intersection_circle_circle(
1585
+ cdef (float_t, float_t, float_t, float_t) _intersect_circle_circle(
1499
1586
  float_t x0, # circle 0
1500
1587
  float_t y0,
1501
1588
  float_t r0,
@@ -1541,7 +1628,7 @@ cdef (float_t, float_t, float_t, float_t) _intersection_circle_circle(
1541
1628
 
1542
1629
 
1543
1630
  @cython.ufunc
1544
- cdef (float_t, float_t, float_t, float_t) _intersection_circle_line(
1631
+ cdef (float_t, float_t, float_t, float_t) _intersect_circle_line(
1545
1632
  float_t x, # circle
1546
1633
  float_t y,
1547
1634
  float_t r,
@@ -1583,10 +1670,106 @@ cdef (float_t, float_t, float_t, float_t) _intersection_circle_line(
1583
1670
  )
1584
1671
 
1585
1672
 
1673
+ @cython.ufunc
1674
+ cdef (float_t, float_t, float_t, float_t) _intersect_semicircle_line(
1675
+ float_t x0, # line start
1676
+ float_t y0,
1677
+ float_t x1, # line end
1678
+ float_t y1,
1679
+ ) noexcept nogil:
1680
+ """Return coordinates of intersections of line and universal semicircle."""
1681
+ cdef:
1682
+ double dx, dy, dr, dd, rdd
1683
+
1684
+ if isnan(x0) or isnan(x1) or isnan(y0) or isnan(y1):
1685
+ return NAN, NAN, NAN, NAN
1686
+
1687
+ dx = x1 - x0
1688
+ dy = y1 - y0
1689
+ dr = dx * dx + dy * dy
1690
+ dd = (x0 - 0.5) * y1 - (x1 - 0.5) * y0
1691
+ rdd = 0.25 * dr - dd * dd # discriminant
1692
+ if rdd < 0.0 or dr <= 0.0:
1693
+ # no intersection
1694
+ return NAN, NAN, NAN, NAN
1695
+ rdd = sqrt(rdd)
1696
+ x0 = <float_t> ((dd * dy - copysign(1.0, dy) * dx * rdd) / dr + 0.5)
1697
+ y0 = <float_t> ((-dd * dx - fabs(dy) * rdd) / dr)
1698
+ x1 = <float_t> ((dd * dy + copysign(1.0, dy) * dx * rdd) / dr + 0.5)
1699
+ y1 = <float_t> ((-dd * dx + fabs(dy) * rdd) / dr)
1700
+ if y0 < 0.0:
1701
+ x0 = NAN
1702
+ y0 = NAN
1703
+ if y1 < 0.0:
1704
+ x1 = NAN
1705
+ y1 = NAN
1706
+ return x0, y0, x1, y1
1707
+
1708
+
1709
+ def _nearest_neighbor_2d(
1710
+ int_t[::1] indices,
1711
+ const float_t[::1] x0,
1712
+ const float_t[::1] y0,
1713
+ const float_t[::1] x1,
1714
+ const float_t[::1] y1,
1715
+ const float_t distance_max,
1716
+ const int num_threads
1717
+ ):
1718
+ """Find nearest neighbors in 2D.
1719
+
1720
+ For each point in the first set of arrays (x0, y0) find the nearest point
1721
+ in the second set of arrays (x1, y1) and store the index of the nearest
1722
+ point in the second array in the indices array.
1723
+ If any coordinates are NaN, or the distance to the nearest point
1724
+ is larger than distance_max, the index is set to -1.
1725
+
1726
+ """
1727
+ cdef:
1728
+ ssize_t i, j, index
1729
+ float_t x, y, dmin
1730
+ float_t distance_max_squared = distance_max * distance_max
1731
+
1732
+ if (
1733
+ indices.shape[0] != x0.shape[0]
1734
+ or x0.shape[0] != y0.shape[0]
1735
+ or x1.shape[0] != y1.shape[0]
1736
+ ):
1737
+ raise ValueError('input array size mismatch')
1738
+
1739
+ with nogil, parallel(num_threads=num_threads):
1740
+ for i in prange(x0.shape[0]):
1741
+ x = x0[i]
1742
+ y = y0[i]
1743
+ if isnan(x) or isnan(y):
1744
+ indices[i] = -1
1745
+ continue
1746
+ index = -1
1747
+ dmin = INFINITY
1748
+ for j in range(x1.shape[0]):
1749
+ x = x0[i] - x1[j]
1750
+ y = y0[i] - y1[j]
1751
+ x = x * x + y * y
1752
+ if x < dmin:
1753
+ dmin = x
1754
+ index = j
1755
+ indices[i] = -1 if dmin > distance_max_squared else <int_t> index
1756
+
1757
+
1586
1758
  ###############################################################################
1587
1759
  # Blend ufuncs
1588
1760
 
1589
1761
 
1762
+ @cython.ufunc
1763
+ cdef float_t _blend_and(
1764
+ float_t a, # base layer
1765
+ float_t b, # blend layer
1766
+ ) noexcept nogil:
1767
+ """Return blended layers using `and` mode."""
1768
+ if isnan(a):
1769
+ return NAN
1770
+ return b
1771
+
1772
+
1590
1773
  @cython.ufunc
1591
1774
  cdef float_t _blend_normal(
1592
1775
  float_t a, # base layer
phasorpy/_utils.py CHANGED
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  __all__ = [
6
6
  'chunk_iter',
7
7
  'dilate_coordinates',
8
+ 'init_module',
8
9
  'kwargs_notnone',
9
10
  'parse_harmonic',
10
11
  'parse_kwargs',
@@ -13,18 +14,29 @@ __all__ = [
13
14
  'phasor_from_polar_scalar',
14
15
  'phasor_to_polar_scalar',
15
16
  'scale_matrix',
16
- 'set_module',
17
17
  'sort_coordinates',
18
+ 'squeeze_dims',
18
19
  'update_kwargs',
20
+ 'xarray_metadata',
19
21
  ]
20
22
 
21
23
  import math
22
24
  import numbers
25
+ import os
26
+ import sys
23
27
  from collections.abc import Sequence
24
28
  from typing import TYPE_CHECKING
25
29
 
26
30
  if TYPE_CHECKING:
27
- from ._typing import Any, ArrayLike, Literal, NDArray, Iterator
31
+ from ._typing import (
32
+ Any,
33
+ ArrayLike,
34
+ Literal,
35
+ NDArray,
36
+ Iterator,
37
+ Container,
38
+ PathLike,
39
+ )
28
40
 
29
41
  import numpy
30
42
 
@@ -581,8 +593,8 @@ def chunk_iter(
581
593
  )
582
594
 
583
595
 
584
- def set_module(globs: dict[str, Any], /) -> None:
585
- """Set ``__module__`` attribute for objects in ``__all__``.
596
+ def init_module(globs: dict[str, Any], /) -> None:
597
+ """Add names in module to ``__all__`` and set ``__module__`` attributes.
586
598
 
587
599
  Parameters
588
600
  ----------
@@ -591,11 +603,111 @@ def set_module(globs: dict[str, Any], /) -> None:
591
603
 
592
604
  Examples
593
605
  --------
594
- >>> set_module(globals())
606
+ >>> init_module(globals())
595
607
 
596
608
  """
597
- name = globs['__name__']
598
- for item in globs['__all__']:
599
- obj = globs[item]
609
+ names = globs['__all__']
610
+ module_name = globs['__name__']
611
+ module = sys.modules[module_name]
612
+ for name in dir(module):
613
+ if name.startswith('_') or name in {
614
+ 'annotations',
615
+ 'init_module',
616
+ 'utils', # TODO: where does this come from?
617
+ }:
618
+ continue
619
+ names.append(name)
620
+ obj = getattr(module, name)
600
621
  if hasattr(obj, '__module__'):
601
- obj.__module__ = name
622
+ obj.__module__ = module_name
623
+ globs['__all__'] = sorted(set(names))
624
+
625
+
626
+ def xarray_metadata(
627
+ dims: Sequence[str] | None,
628
+ shape: tuple[int, ...],
629
+ /,
630
+ name: str | PathLike[Any] | None = None,
631
+ attrs: dict[str, Any] | None = None,
632
+ **coords: Any,
633
+ ) -> dict[str, Any]:
634
+ """Return xarray-style dims, coords, and attrs in a dict.
635
+
636
+ >>> xarray_metadata('SYX', (3, 2, 1), S=['0', '1', '2'])
637
+ {'dims': ('S', 'Y', 'X'), 'coords': {'S': ['0', '1', '2']}, 'attrs': {}}
638
+
639
+ """
640
+ assert dims is not None
641
+ dims = tuple(dims)
642
+ if len(dims) != len(shape):
643
+ raise ValueError(
644
+ f'dims do not match shape {len(dims)} != {len(shape)}'
645
+ )
646
+ coords = {dim: coords[dim] for dim in dims if dim in coords}
647
+ if attrs is None:
648
+ attrs = {}
649
+ metadata = {'dims': dims, 'coords': coords, 'attrs': attrs}
650
+ if name:
651
+ metadata['name'] = os.path.basename(name)
652
+ return metadata
653
+
654
+
655
+ def squeeze_dims(
656
+ shape: Sequence[int],
657
+ dims: Sequence[str],
658
+ /,
659
+ skip: Container[str] = 'XY',
660
+ ) -> tuple[tuple[int, ...], tuple[str, ...], tuple[bool, ...]]:
661
+ """Return shape and axes with length-1 dimensions removed.
662
+
663
+ Remove unused dimensions unless their axes are listed in the `skip`
664
+ parameter.
665
+
666
+ Adapted from the tifffile library.
667
+
668
+ Parameters
669
+ ----------
670
+ shape : tuple of ints
671
+ Sequence of dimension sizes.
672
+ dims : sequence of str
673
+ Character codes for dimensions in `shape`.
674
+ skip : container of str, optional
675
+ Character codes for dimensions whose length-1 dimensions are
676
+ not removed. The default is 'XY'.
677
+
678
+ Returns
679
+ -------
680
+ shape : tuple of ints
681
+ Sequence of dimension sizes with length-1 dimensions removed.
682
+ dims : tuple of str
683
+ Character codes for dimensions in output `shape`.
684
+ squeezed : str
685
+ Dimensions were kept (True) or removed (False).
686
+
687
+ Examples
688
+ --------
689
+ >>> squeeze_dims((5, 1, 2, 1, 1), 'TZYXC')
690
+ ((5, 2, 1), ('T', 'Y', 'X'), (True, False, True, True, False))
691
+ >>> squeeze_dims((1,), ('Q',))
692
+ ((1,), ('Q',), (True,))
693
+
694
+ """
695
+ if len(shape) != len(dims):
696
+ raise ValueError(f'{len(shape)=} != {len(dims)=}')
697
+ if not dims:
698
+ return tuple(shape), tuple(dims), ()
699
+ squeezed: list[bool] = []
700
+ shape_squeezed: list[int] = []
701
+ dims_squeezed: list[str] = []
702
+ for size, ax in zip(shape, dims):
703
+ if size > 1 or ax in skip:
704
+ squeezed.append(True)
705
+ shape_squeezed.append(size)
706
+ dims_squeezed.append(ax)
707
+ else:
708
+ squeezed.append(False)
709
+ if len(shape_squeezed) == 0:
710
+ squeezed[-1] = True
711
+ shape_squeezed.append(shape[-1])
712
+ dims_squeezed.append(dims[-1])
713
+ return tuple(shape_squeezed), tuple(dims_squeezed), tuple(squeezed)
phasorpy/cli.py CHANGED
@@ -18,11 +18,11 @@ if TYPE_CHECKING:
18
18
 
19
19
  import click
20
20
 
21
- from . import version
21
+ from . import __version__
22
22
 
23
23
 
24
24
  @click.group(help='PhasorPy package command line interface.')
25
- @click.version_option(version=version.__version__)
25
+ @click.version_option(version=__version__)
26
26
  def main() -> int:
27
27
  """PhasorPy command line interface."""
28
28
  return 0
@@ -38,7 +38,9 @@ def main() -> int:
38
38
  )
39
39
  def versions(verbose: bool) -> None:
40
40
  """Versions command group."""
41
- click.echo(version.versions(verbose=verbose))
41
+ from .utils import versions
42
+
43
+ click.echo(versions(verbose=verbose))
42
44
 
43
45
 
44
46
  @main.command(help='Fetch sample files from remote repositories.')
@@ -83,6 +85,57 @@ def fret(hide: bool) -> None:
83
85
  plot.show()
84
86
 
85
87
 
88
+ @main.command(help='Start interactive lifetime plots.')
89
+ @click.option(
90
+ '-f',
91
+ '--frequency',
92
+ type=float,
93
+ required=False,
94
+ help='Laser/modulation frequency in MHz.',
95
+ )
96
+ @click.option(
97
+ '-l',
98
+ '--lifetime',
99
+ default=(4.0, 1.0),
100
+ type=float,
101
+ multiple=True,
102
+ required=False,
103
+ help='Lifetime in ns.',
104
+ )
105
+ @click.option(
106
+ '-a',
107
+ '--fraction',
108
+ type=float,
109
+ multiple=True,
110
+ required=False,
111
+ help='Fractional intensity of lifetime.',
112
+ )
113
+ @click.option(
114
+ '--hide',
115
+ default=False,
116
+ is_flag=True,
117
+ type=click.BOOL,
118
+ help='Do not show interactive plot.',
119
+ )
120
+ def lifetime(
121
+ frequency: float | None,
122
+ lifetime: tuple[float, ...],
123
+ fraction: tuple[float, ...],
124
+ hide: bool,
125
+ ) -> None:
126
+ """Lifetime command group."""
127
+ from .plot import LifetimePlots
128
+
129
+ plot = LifetimePlots(
130
+ frequency,
131
+ lifetime,
132
+ fraction if len(fraction) > 0 else None,
133
+ interactive=True,
134
+ )
135
+ if not hide:
136
+ plot.show()
137
+
138
+
86
139
  if __name__ == '__main__':
87
140
  import sys
88
141
 
phasorpy/cluster.py CHANGED
@@ -16,7 +16,7 @@ __all__ = ['phasor_cluster_gmm']
16
16
  from typing import TYPE_CHECKING
17
17
 
18
18
  if TYPE_CHECKING:
19
- from ._typing import Any, ArrayLike
19
+ from ._typing import Any, ArrayLike, Literal
20
20
 
21
21
  import math
22
22
 
@@ -31,6 +31,7 @@ def phasor_cluster_gmm(
31
31
  *,
32
32
  sigma: float = 2.0,
33
33
  clusters: int = 1,
34
+ sort: Literal['polar', 'phasor', 'area'] | None = None,
34
35
  **kwargs: Any,
35
36
  ) -> tuple[
36
37
  tuple[float, ...],
@@ -58,6 +59,13 @@ def phasor_cluster_gmm(
58
59
  clusters : int, optional
59
60
  Number of Gaussian distributions to fit to phasor coordinates.
60
61
  Defaults to 1.
62
+ sort: {'polar', 'phasor', 'area'}, optional
63
+ Sorting method for output clusters. Defaults to 'polar'.
64
+
65
+ - 'polar': Sort by polar coordinates (phase, then modulation).
66
+ - 'phasor': Sort by phasor coordinates (real, then imaginary).
67
+ - 'area': Sort by inverse area of ellipse (-major * minor).
68
+
61
69
  **kwargs
62
70
  Additional keyword arguments passed to
63
71
  :py:class:`sklearn.mixture.GaussianMixture`.
@@ -161,10 +169,38 @@ def phasor_cluster_gmm(
161
169
  radius_major.append(sigma * math.sqrt(2 * eigenvalues[0]))
162
170
  radius_minor.append(sigma * math.sqrt(2 * eigenvalues[1]))
163
171
 
172
+ if clusters == 1:
173
+ argsort = [0]
174
+ else:
175
+ if sort is None or sort == 'polar':
176
+
177
+ def sort_key(i: int) -> Any:
178
+ return (
179
+ math.atan2(center_imag[i], center_real[i]),
180
+ math.hypot(center_real[i], center_imag[i]),
181
+ )
182
+
183
+ elif sort == 'phasor':
184
+
185
+ def sort_key(i: int) -> Any:
186
+ return center_imag[i], center_real[i]
187
+
188
+ elif sort == 'area':
189
+
190
+ def sort_key(i: int) -> Any:
191
+ return -radius_major[i] * radius_minor[i]
192
+
193
+ else:
194
+ raise ValueError(
195
+ f"invalid {sort=!r} != 'phasor', 'polar', or 'area'"
196
+ )
197
+
198
+ argsort = sorted(range(len(center_real)), key=sort_key)
199
+
164
200
  return (
165
- tuple(center_real),
166
- tuple(center_imag),
167
- tuple(radius_major),
168
- tuple(radius_minor),
169
- tuple(angle),
201
+ tuple(center_real[i] for i in argsort),
202
+ tuple(center_imag[i] for i in argsort),
203
+ tuple(radius_major[i] for i in argsort),
204
+ tuple(radius_minor[i] for i in argsort),
205
+ tuple(angle[i] for i in argsort),
170
206
  )