phasorpy 0.2__cp312-cp312-macosx_10_13_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,454 @@
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
+ harmonic_max: int | None = None,
253
+ /,
254
+ ) -> tuple[list[int], bool]:
255
+ """Return parsed harmonic parameter.
256
+
257
+ This function performs common, but not necessarily all, verifications
258
+ of user-provided `harmonic` parameter.
259
+
260
+ Parameters
261
+ ----------
262
+ harmonic : int, list of int, 'all', or None
263
+ Harmonic parameter to parse.
264
+ harmonic_max : int, optional
265
+ Maximum value allowed in `hamonic`. Must be one or greater.
266
+ To verify against known number of signal samples,
267
+ pass ``samples // 2``.
268
+ If `harmonic='all'`, a range of harmonics from one to `harmonic_max`
269
+ (included) is returned.
270
+
271
+ Returns
272
+ -------
273
+ harmonic : list of int
274
+ Parsed list of harmonics.
275
+ has_harmonic_axis : bool
276
+ False if `harmonic` input parameter is a scalar integer.
277
+
278
+ Raises
279
+ ------
280
+ IndexError
281
+ Any element is out of range `[1..harmonic_max]`.
282
+ ValueError
283
+ Elements are not unique.
284
+ Harmonic is empty.
285
+ String input is not 'all'.
286
+ `harmonic_max` is smaller than 1.
287
+ TypeError
288
+ Any element is not an integer.
289
+ `harmonic` is `'all'` and `harmonic_max` is None.
290
+
291
+ """
292
+ if harmonic_max is not None and harmonic_max < 1:
293
+ raise ValueError(f'{harmonic_max=} < 1')
294
+
295
+ if harmonic is None:
296
+ return [1], False
297
+
298
+ if isinstance(harmonic, (int, numbers.Integral)):
299
+ if harmonic < 1 or (
300
+ harmonic_max is not None and harmonic > harmonic_max
301
+ ):
302
+ raise IndexError(f'{harmonic=} out of range [1..{harmonic_max}]')
303
+ return [int(harmonic)], False
304
+
305
+ if isinstance(harmonic, str):
306
+ if harmonic == 'all':
307
+ if harmonic_max is None:
308
+ raise TypeError(
309
+ f'maximum harmonic must be specified for {harmonic=!r}'
310
+ )
311
+ return list(range(1, harmonic_max + 1)), True
312
+ raise ValueError(f'{harmonic=!r} is not a valid harmonic')
313
+
314
+ h = numpy.atleast_1d(numpy.asarray(harmonic))
315
+ if h.size == 0:
316
+ raise ValueError(f'{harmonic=} is empty')
317
+ if h.dtype.kind not in 'iu' or h.ndim != 1:
318
+ raise TypeError(f'{harmonic=} element not an integer')
319
+ if numpy.any(h < 1):
320
+ raise IndexError(f'{harmonic=} element < 1')
321
+ if harmonic_max is not None and numpy.any(h > harmonic_max):
322
+ raise IndexError(f'{harmonic=} element > {harmonic_max}]')
323
+ if numpy.unique(h).size != h.size:
324
+ raise ValueError(f'{harmonic=} elements must be unique')
325
+ return h.tolist(), True
326
+
327
+
328
+ def chunk_iter(
329
+ shape: tuple[int, ...],
330
+ chunk_shape: tuple[int, ...],
331
+ /,
332
+ axes: str | Sequence[str] | None = None,
333
+ *,
334
+ pattern: str | None = None,
335
+ squeeze: bool = False,
336
+ use_index: bool = False,
337
+ ) -> Iterator[tuple[tuple[int | slice, ...], str, bool]]:
338
+ """Yield indices and labels of chunks from ndarray's shape.
339
+
340
+ Parameters
341
+ ----------
342
+ shape : tuple of int
343
+ Shape of C-order ndarray to chunk.
344
+ chunk_shape : tuple of int
345
+ Shape of chunks in the most significant dimensions.
346
+ axes : str or sequence of str, optional
347
+ Labels for each axis in shape if `pattern` is None.
348
+ pattern : str, optional
349
+ String to format chunk indices.
350
+ If None, use ``_[{axes[index]}{chunk_index[index]}]`` for each axis.
351
+ squeeze : bool
352
+ If true, do not include length-1 chunked dimensions in label
353
+ unless dimensions are part of `chunk_shape`.
354
+ Applies only if `pattern` is None.
355
+ use_index : bool
356
+ If true, use indices of chunks in `shape` instead of chunk indices to
357
+ format pattern.
358
+
359
+ Yields
360
+ ------
361
+ index : tuple of int or slice
362
+ Indices of chunk in ndarray.
363
+ label : str
364
+ Pattern formatted with chunk indices.
365
+ cropped : bool
366
+ True if chunk exceeds any border of ndarray.
367
+ Indexing ndarray with `index` will yield a slice smaller than
368
+ `chunk_shape`.
369
+
370
+ Examples
371
+ --------
372
+
373
+ >>> list(chunk_iter((2, 2), (2,), pattern='Y{}'))
374
+ [((0, slice(0, 2, 1)), 'Y0', False), ((1, slice(0, 2, 1)), 'Y1', False)]
375
+
376
+ Chunk a four-dimensional image stack into 2x2 sized image tiles:
377
+
378
+ >>> stack = numpy.zeros((2, 3, 4, 5))
379
+ >>> for index, label, cropped in chunk_iter(stack.shape, (2, 2)):
380
+ ... chunk = stack[index]
381
+ ...
382
+
383
+ """
384
+ ndim = len(shape)
385
+
386
+ sep = '_'
387
+ if axes is None:
388
+ axes = sep * ndim
389
+ sep = ''
390
+ elif ndim != len(axes):
391
+ raise ValueError(f'{len(shape)=} != {len(axes)=}')
392
+
393
+ if pattern is not None:
394
+ try:
395
+ pattern.format(*shape)
396
+ except Exception as exc:
397
+ raise ValueError('pattern cannot be formatted') from exc
398
+
399
+ # number of high dimensions not included in chaunk_shape
400
+ hdim = ndim - len(chunk_shape)
401
+ if hdim < 0:
402
+ raise ValueError(f'{len(shape)=} < {len(chunk_shape)=}')
403
+ if hdim > 0:
404
+ # prepend length-1 dimensions
405
+ chunk_shape = ((1,) * hdim) + chunk_shape
406
+
407
+ chunked_shape = []
408
+ pattern_list = []
409
+ for i, (size, chunk_size, ax) in enumerate(zip(shape, chunk_shape, axes)):
410
+ if size <= 0:
411
+ raise ValueError('shape must contain positive sizes')
412
+ if chunk_size <= 0:
413
+ raise ValueError('chunk_shape must contain positive sizes')
414
+ div, mod = divmod(size, chunk_size)
415
+ chunked_shape.append(div + 1 if mod else div)
416
+
417
+ if not squeeze or chunked_shape[-1] > 1:
418
+ if use_index:
419
+ digits = int(math.log10(size)) + 1
420
+ else:
421
+ digits = int(math.log10(chunked_shape[-1])) + 1
422
+ pattern_list.append(f'{sep}{ax}{{{i}:0{digits}d}}')
423
+
424
+ if pattern is None:
425
+ pattern = ''.join(pattern_list)
426
+
427
+ chunk_index: tuple[int, ...]
428
+ for chunk_index in numpy.ndindex(tuple(chunked_shape)):
429
+ index: tuple[int | slice, ...] = tuple(
430
+ (
431
+ chunk_index[i]
432
+ if i < hdim
433
+ else slice(
434
+ chunk_index[i] * chunk_shape[i],
435
+ (chunk_index[i] + 1) * chunk_shape[i],
436
+ 1,
437
+ )
438
+ )
439
+ for i in range(ndim)
440
+ )
441
+ if use_index:
442
+ format_index = tuple(
443
+ chunk_index[i] * chunk_shape[i] for i in range(ndim)
444
+ )
445
+ else:
446
+ format_index = chunk_index
447
+ yield (
448
+ index,
449
+ pattern.format(*format_index),
450
+ any(
451
+ (chunk_index[i] + 1) * chunk_shape[i] > shape[i]
452
+ for i in range(ndim)
453
+ ),
454
+ )
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