phasorpy 0.1__cp311-cp311-manylinux_2_17_x86_64.manylinux2014_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/_typing.py ADDED
@@ -0,0 +1,77 @@
1
+ """Type annotations.
2
+
3
+ This module should only be imported when type-checking, for example::
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import TYPE_CHECKING
8
+
9
+ if TYPE_CHECKING:
10
+ from ._typing import Any, ArrayLike, PathLike
11
+
12
+ """
13
+
14
+ # flake8: noqa: F401
15
+ # pylint: disable=unused-import
16
+ # autoflake: skip_file
17
+
18
+ from __future__ import annotations
19
+
20
+ __all__ = [
21
+ 'Any',
22
+ 'ArrayLike',
23
+ 'Callable',
24
+ 'Collection',
25
+ 'Container',
26
+ 'DTypeLike',
27
+ 'DataArray',
28
+ 'EllipsisType',
29
+ 'IO',
30
+ 'ItemsView',
31
+ 'Iterable',
32
+ 'Iterator',
33
+ 'KeysView',
34
+ 'Literal',
35
+ 'Mapping',
36
+ 'NDArray',
37
+ 'Optional',
38
+ 'PathLike',
39
+ 'Pooch',
40
+ 'Sequence',
41
+ 'TextIO',
42
+ 'Union',
43
+ 'ValuesView',
44
+ 'cast',
45
+ 'final',
46
+ 'overload',
47
+ ]
48
+
49
+ from collections.abc import (
50
+ Callable,
51
+ Collection,
52
+ Container,
53
+ ItemsView,
54
+ Iterable,
55
+ Iterator,
56
+ KeysView,
57
+ Mapping,
58
+ Sequence,
59
+ ValuesView,
60
+ )
61
+ from os import PathLike
62
+ from types import EllipsisType
63
+ from typing import (
64
+ IO,
65
+ Any,
66
+ Literal,
67
+ Optional,
68
+ TextIO,
69
+ Union,
70
+ cast,
71
+ final,
72
+ overload,
73
+ )
74
+
75
+ from numpy.typing import ArrayLike, DTypeLike, NDArray
76
+ from pooch import Pooch
77
+ from xarray import DataArray
phasorpy/_utils.py ADDED
@@ -0,0 +1,441 @@
1
+ """Private auxiliary and convenience functions.
2
+
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ __all__: list[str] = [
8
+ 'chunk_iter',
9
+ 'dilate_coordinates',
10
+ 'kwargs_notnone',
11
+ 'parse_harmonic',
12
+ 'parse_kwargs',
13
+ 'phasor_from_polar_scalar',
14
+ 'phasor_to_polar_scalar',
15
+ 'scale_matrix',
16
+ 'sort_coordinates',
17
+ 'update_kwargs',
18
+ ]
19
+
20
+ import math
21
+ import numbers
22
+ from typing import TYPE_CHECKING
23
+
24
+ if TYPE_CHECKING:
25
+ from ._typing import Any, Sequence, ArrayLike, Literal, NDArray, Iterator
26
+
27
+ import numpy
28
+
29
+
30
+ def parse_kwargs(
31
+ kwargs: dict[str, Any],
32
+ /,
33
+ *keys: str,
34
+ _del: bool = True,
35
+ **keyvalues: Any,
36
+ ) -> dict[str, Any]:
37
+ """Return dict with keys from keys|keyvals and values from kwargs|keyvals.
38
+
39
+ If `_del` is true (default), existing keys are deleted from `kwargs`.
40
+
41
+ >>> kwargs = {'one': 1, 'two': 2, 'four': 4}
42
+ >>> kwargs2 = parse_kwargs(kwargs, 'two', 'three', four=None, five=5)
43
+ >>> kwargs == {'one': 1}
44
+ True
45
+ >>> kwargs2 == {'two': 2, 'four': 4, 'five': 5}
46
+ True
47
+
48
+ """
49
+ result = {}
50
+ for key in keys:
51
+ if key in kwargs:
52
+ result[key] = kwargs[key]
53
+ if _del:
54
+ del kwargs[key]
55
+ for key, value in keyvalues.items():
56
+ if key in kwargs:
57
+ result[key] = kwargs[key]
58
+ if _del:
59
+ del kwargs[key]
60
+ else:
61
+ result[key] = value
62
+ return result
63
+
64
+
65
+ def update_kwargs(kwargs: dict[str, Any], /, **keyvalues: Any) -> None:
66
+ """Update dict with keys and values if keys do not already exist.
67
+
68
+ >>> kwargs = {'one': 1}
69
+ >>> update_kwargs(kwargs, one=None, two=2)
70
+ >>> kwargs == {'one': 1, 'two': 2}
71
+ True
72
+
73
+ """
74
+ for key, value in keyvalues.items():
75
+ if key not in kwargs:
76
+ kwargs[key] = value
77
+
78
+
79
+ def kwargs_notnone(**kwargs: Any) -> dict[str, Any]:
80
+ """Return dict of kwargs which values are not None.
81
+
82
+ >>> kwargs_notnone(one=1, none=None)
83
+ {'one': 1}
84
+
85
+ """
86
+ return dict(item for item in kwargs.items() if item[1] is not None)
87
+
88
+
89
+ def scale_matrix(factor: float, origin: Sequence[float]) -> NDArray[Any]:
90
+ """Return matrix to scale homogeneous coordinates by factor around origin.
91
+
92
+ Parameters
93
+ ----------
94
+ factor: float
95
+ Scale factor.
96
+ origin: (float, float)
97
+ Coordinates of point around which to scale.
98
+
99
+ Returns
100
+ -------
101
+ matrix: ndarray
102
+ A 3x3 homogeneous transformation matrix.
103
+
104
+ Examples
105
+ --------
106
+ >>> scale_matrix(1.1, (0.0, 0.5))
107
+ array([[1.1, 0, -0],
108
+ [0, 1.1, -0.05],
109
+ [0, 0, 1]])
110
+
111
+ """
112
+ mat = numpy.diag((factor, factor, 1.0))
113
+ mat[:2, 2] = origin[:2]
114
+ mat[:2, 2] *= 1.0 - factor
115
+ return mat
116
+
117
+
118
+ def sort_coordinates(
119
+ real: ArrayLike,
120
+ imag: ArrayLike,
121
+ /,
122
+ origin: tuple[float, float] | None = None,
123
+ ) -> tuple[NDArray[Any], NDArray[Any], NDArray[Any]]:
124
+ """Return cartesian coordinates sorted counterclockwise around origin.
125
+
126
+ Parameters
127
+ ----------
128
+ real, imag : array_like
129
+ Coordinates to be sorted.
130
+ origin : (float, float)
131
+ Coordinates around which to sort by angle.
132
+
133
+ Returns
134
+ -------
135
+ real, imag : ndarray
136
+ Coordinates sorted by angle.
137
+ indices : ndarray
138
+ Indices used to reorder coordinates.
139
+
140
+ Examples
141
+ --------
142
+ >>> sort_coordinates([0, 1, 2, 3], [0, 1, -1, 0])
143
+ (array([2, 3, 1, 0]), array([-1, 0, 1, 0]), array([2, 3, 1, 0]...))
144
+
145
+ """
146
+ x, y = numpy.atleast_1d(real, imag)
147
+ if x.ndim != 1 or x.shape != y.shape:
148
+ raise ValueError(f'invalid {x.shape=} or {y.shape=}')
149
+ if x.size < 4:
150
+ return x, y, numpy.arange(x.size)
151
+ if origin is None:
152
+ origin = x.mean(), y.mean()
153
+ indices = numpy.argsort(numpy.arctan2(y - origin[1], x - origin[0]))
154
+ return x[indices], y[indices], indices
155
+
156
+
157
+ def dilate_coordinates(
158
+ real: ArrayLike,
159
+ imag: ArrayLike,
160
+ offset: float,
161
+ /,
162
+ ) -> tuple[NDArray[Any], NDArray[Any]]:
163
+ """Return dilated coordinates.
164
+
165
+ Parameters
166
+ ----------
167
+ real, imag : array_like
168
+ Coordinates of convex hull, sorted by angle.
169
+ offset : float
170
+ Amount by which to dilate coordinates.
171
+
172
+ Returns
173
+ -------
174
+ real, imag : ndarray
175
+ Coordinates dilated by offset.
176
+
177
+ Examples
178
+ --------
179
+ >>> dilate_coordinates([2, 3, 1, 0], [-1, 0, 1, 0], 0.05)
180
+ (array([2.022, 3.05, 0.9776, -0.05]), array([-1.045, 0, 1.045, 0]))
181
+
182
+ """
183
+ x = numpy.asanyarray(real, dtype=numpy.float64)
184
+ y = numpy.asanyarray(imag, dtype=numpy.float64)
185
+ if x.ndim != 1 or x.shape != y.shape or x.size < 1:
186
+ raise ValueError(f'invalid {x.shape=} or {y.shape=}')
187
+ if x.size > 1:
188
+ dx = numpy.diff(numpy.diff(x, prepend=x[-1], append=x[0]))
189
+ dy = numpy.diff(numpy.diff(y, prepend=y[-1], append=y[0]))
190
+ else:
191
+ # TODO: this assumes coordinate on universal semicircle
192
+ dx = numpy.diff(x, append=0.5)
193
+ dy = numpy.diff(y, append=0.0)
194
+ s = numpy.hypot(dx, dy)
195
+ dx /= s
196
+ dx *= -offset
197
+ dx += x
198
+ dy /= s
199
+ dy *= -offset
200
+ dy += y
201
+ return dx, dy
202
+
203
+
204
+ def phasor_to_polar_scalar(
205
+ real: float,
206
+ imag: float,
207
+ /,
208
+ *,
209
+ degree: bool = False,
210
+ percent: bool = False,
211
+ ) -> tuple[float, float]:
212
+ """Return polar from scalar phasor coordinates.
213
+
214
+ >>> phasor_to_polar_scalar(1.0, 0.0, degree=True, percent=True)
215
+ (0.0, 100.0)
216
+
217
+ """
218
+ phi = math.atan2(imag, real)
219
+ mod = math.hypot(imag, real)
220
+ if degree:
221
+ phi = math.degrees(phi)
222
+ if percent:
223
+ mod *= 100.0
224
+ return phi, mod
225
+
226
+
227
+ def phasor_from_polar_scalar(
228
+ phase: float,
229
+ modulation: float,
230
+ /,
231
+ *,
232
+ degree: bool = False,
233
+ percent: bool = False,
234
+ ) -> tuple[float, float]:
235
+ """Return phasor from scalar polar coordinates.
236
+
237
+ >>> phasor_from_polar_scalar(0.0, 100.0, degree=True, percent=True)
238
+ (1.0, 0.0)
239
+
240
+ """
241
+ if degree:
242
+ phase = math.radians(phase)
243
+ if percent:
244
+ modulation /= 100.0
245
+ real = modulation * math.cos(phase)
246
+ imag = modulation * math.sin(phase)
247
+ return real, imag
248
+
249
+
250
+ def parse_harmonic(
251
+ harmonic: int | Sequence[int] | Literal['all'] | str | None,
252
+ samples: int,
253
+ /,
254
+ ) -> tuple[list[int], bool]:
255
+ """Return parsed harmonic parameter.
256
+
257
+ Parameters
258
+ ----------
259
+ harmonic : int, list of int, 'all', or None
260
+ Harmonic parameter to parse.
261
+ samples : int
262
+ Number of samples in signal.
263
+ Used to verify harmonic values and set maximum harmonic value.
264
+
265
+ Returns
266
+ -------
267
+ harmonic : list of int
268
+ Parsed list of harmonics.
269
+ has_harmonic_axis : bool
270
+ If true, `harmonic` input parameter is an integer, else a list.
271
+
272
+ Raises
273
+ ------
274
+ IndexError
275
+ Any element is out of range [1..samples // 2].
276
+ ValueError
277
+ Elements are not unique.
278
+ Harmonic is empty.
279
+ String input is not 'all'.
280
+ TypeError
281
+ Any element is not an integer.
282
+
283
+ """
284
+ if samples < 3:
285
+ raise ValueError(f'{samples=} < 3')
286
+
287
+ if harmonic is None:
288
+ return [1], False
289
+
290
+ harmonic_max = samples // 2
291
+ if isinstance(harmonic, (int, numbers.Integral)):
292
+ if harmonic < 1 or harmonic > harmonic_max:
293
+ raise IndexError(f'{harmonic=} out of range [1..{harmonic_max}]')
294
+ return [int(harmonic)], False
295
+
296
+ if isinstance(harmonic, str):
297
+ if harmonic == 'all':
298
+ return list(range(1, harmonic_max + 1)), True
299
+ raise ValueError(f'{harmonic=!r} is not a valid harmonic')
300
+
301
+ h = numpy.atleast_1d(numpy.asarray(harmonic))
302
+ if h.size == 0:
303
+ raise ValueError(f'{harmonic=} is empty')
304
+ if h.dtype.kind not in 'iu' or h.ndim != 1:
305
+ raise TypeError(f'{harmonic=} element not an integer')
306
+ if numpy.any(h < 1) or numpy.any(h > harmonic_max):
307
+ raise IndexError(
308
+ f'{harmonic=} element out of range [1..{harmonic_max}]'
309
+ )
310
+ if numpy.unique(h).size != h.size:
311
+ raise ValueError(f'{harmonic=} elements must be unique')
312
+ return h.tolist(), True
313
+
314
+
315
+ def chunk_iter(
316
+ shape: tuple[int, ...],
317
+ chunk_shape: tuple[int, ...],
318
+ /,
319
+ axes: str | Sequence[str] | None = None,
320
+ *,
321
+ pattern: str | None = None,
322
+ squeeze: bool = False,
323
+ use_index: bool = False,
324
+ ) -> Iterator[tuple[tuple[int | slice, ...], str, bool]]:
325
+ """Yield indices and labels of chunks from ndarray's shape.
326
+
327
+ Parameters
328
+ ----------
329
+ shape : tuple of int
330
+ Shape of C-order ndarray to chunk.
331
+ chunk_shape : tuple of int
332
+ Shape of chunks in the most significant dimensions.
333
+ axes : str or sequence of str, optional
334
+ Labels for each axis in shape if `pattern` is None.
335
+ pattern : str, optional
336
+ String to format chunk indices.
337
+ If None, use ``_[{axes[index]}{chunk_index[index]}]`` for each axis.
338
+ squeeze : bool
339
+ If true, do not include length-1 chunked dimensions in label
340
+ unless dimensions are part of `chunk_shape`.
341
+ Applies only if `pattern` is None.
342
+ use_index : bool
343
+ If true, use indices of chunks in `shape` instead of chunk indices to
344
+ format pattern.
345
+
346
+ Yields
347
+ ------
348
+ index : tuple of int or slice
349
+ Indices of chunk in ndarray.
350
+ label : str
351
+ Pattern formatted with chunk indices.
352
+ cropped : bool
353
+ True if chunk exceeds any border of ndarray.
354
+ Indexing ndarray with `index` will yield a slice smaller than
355
+ `chunk_shape`.
356
+
357
+ Examples
358
+ --------
359
+
360
+ >>> list(chunk_iter((2, 2), (2,), pattern='Y{}'))
361
+ [((0, slice(0, 2, 1)), 'Y0', False), ((1, slice(0, 2, 1)), 'Y1', False)]
362
+
363
+ Chunk a four-dimensional image stack into 2x2 sized image tiles:
364
+
365
+ >>> stack = numpy.zeros((2, 3, 4, 5))
366
+ >>> for index, label, cropped in chunk_iter(stack.shape, (2, 2)):
367
+ ... chunk = stack[index]
368
+ ...
369
+
370
+ """
371
+ ndim = len(shape)
372
+
373
+ sep = '_'
374
+ if axes is None:
375
+ axes = sep * ndim
376
+ sep = ''
377
+ elif ndim != len(axes):
378
+ raise ValueError(f'{len(shape)=} != {len(axes)=}')
379
+
380
+ if pattern is not None:
381
+ try:
382
+ pattern.format(*shape)
383
+ except Exception as exc:
384
+ raise ValueError('pattern cannot be formatted') from exc
385
+
386
+ # number of high dimensions not included in chaunk_shape
387
+ hdim = ndim - len(chunk_shape)
388
+ if hdim < 0:
389
+ raise ValueError(f'{len(shape)=} < {len(chunk_shape)=}')
390
+ if hdim > 0:
391
+ # prepend length-1 dimensions
392
+ chunk_shape = ((1,) * hdim) + chunk_shape
393
+
394
+ chunked_shape = []
395
+ pattern_list = []
396
+ for i, (size, chunk_size, ax) in enumerate(zip(shape, chunk_shape, axes)):
397
+ if size <= 0:
398
+ raise ValueError('shape must contain positive sizes')
399
+ if chunk_size <= 0:
400
+ raise ValueError('chunk_shape must contain positive sizes')
401
+ div, mod = divmod(size, chunk_size)
402
+ chunked_shape.append(div + 1 if mod else div)
403
+
404
+ if not squeeze or chunked_shape[-1] > 1:
405
+ if use_index:
406
+ digits = int(math.log10(size)) + 1
407
+ else:
408
+ digits = int(math.log10(chunked_shape[-1])) + 1
409
+ pattern_list.append(f'{sep}{ax}{{{i}:0{digits}d}}')
410
+
411
+ if pattern is None:
412
+ pattern = ''.join(pattern_list)
413
+
414
+ chunk_index: tuple[int, ...]
415
+ for chunk_index in numpy.ndindex(tuple(chunked_shape)):
416
+ index: tuple[int | slice, ...] = tuple(
417
+ (
418
+ chunk_index[i]
419
+ if i < hdim
420
+ else slice(
421
+ chunk_index[i] * chunk_shape[i],
422
+ (chunk_index[i] + 1) * chunk_shape[i],
423
+ 1,
424
+ )
425
+ )
426
+ for i in range(ndim)
427
+ )
428
+ if use_index:
429
+ format_index = tuple(
430
+ chunk_index[i] * chunk_shape[i] for i in range(ndim)
431
+ )
432
+ else:
433
+ format_index = chunk_index
434
+ yield (
435
+ index,
436
+ pattern.format(*format_index),
437
+ any(
438
+ (chunk_index[i] + 1) * chunk_shape[i] > shape[i]
439
+ for i in range(ndim)
440
+ ),
441
+ )
phasorpy/cli.py ADDED
@@ -0,0 +1,87 @@
1
+ """PhasorPy package command line interface.
2
+
3
+ Invoke the command line application with::
4
+
5
+ $ python -m phasorpy --help
6
+
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ from typing import TYPE_CHECKING
13
+
14
+ if TYPE_CHECKING:
15
+ from ._typing import Iterable
16
+
17
+ import click
18
+
19
+ from . import version
20
+
21
+
22
+ @click.group(help='PhasorPy package command line interface.')
23
+ @click.version_option(version=version.__version__)
24
+ def main() -> int:
25
+ """PhasorPy command line interface."""
26
+ return 0
27
+
28
+
29
+ @main.command(help='Show runtime versions.')
30
+ @click.option(
31
+ '--verbose',
32
+ default=False,
33
+ is_flag=True,
34
+ type=click.BOOL,
35
+ help='Show module paths.',
36
+ )
37
+ def versions(verbose: bool) -> None:
38
+ """Versions command group."""
39
+ click.echo(version.versions(verbose=verbose))
40
+
41
+
42
+ @main.command(help='Fetch sample files from remote repositories.')
43
+ @click.argument('files', nargs=-1)
44
+ @click.option(
45
+ '--hideprogress',
46
+ default=False,
47
+ is_flag=True,
48
+ type=click.BOOL,
49
+ help='Hide progressbar.',
50
+ )
51
+ def fetch(files: Iterable[str], hideprogress: bool) -> None:
52
+ """Fetch command group."""
53
+ from . import datasets
54
+
55
+ files = datasets.fetch(
56
+ *files, return_scalar=False, progressbar=not hideprogress
57
+ )
58
+ click.echo(f'Cached at {os.path.commonpath(files)}')
59
+
60
+
61
+ @main.command(help='Start interactive FRET phasor plot.')
62
+ @click.option(
63
+ '--hide',
64
+ default=False,
65
+ is_flag=True,
66
+ type=click.BOOL,
67
+ help='Do not show interactive plot.',
68
+ )
69
+ def fret(hide: bool) -> None:
70
+ """FRET command group."""
71
+ from .plot import PhasorPlotFret
72
+
73
+ plot = PhasorPlotFret(
74
+ frequency=60.0,
75
+ donor_lifetime=4.2,
76
+ acceptor_lifetime=3.0,
77
+ fret_efficiency=0.5,
78
+ interactive=True,
79
+ )
80
+ if not hide:
81
+ plot.show()
82
+
83
+
84
+ if __name__ == '__main__':
85
+ import sys
86
+
87
+ sys.exit(main()) # pylint: disable=no-value-for-parameter