phasorpy 0.7__cp314-cp314t-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/component.py ADDED
@@ -0,0 +1,707 @@
1
+ """Component analysis of phasor coordinates.
2
+
3
+ The ``phasorpy.component`` module provides functions to:
4
+
5
+ - calculate fractions of two known components by projecting onto the
6
+ line between the components (:py:func:`phasor_component_fraction`)
7
+
8
+ - calculate phasor coordinates of second component if only one is
9
+ known (not implemented)
10
+
11
+ - calculate fractions of multiple known components by using higher
12
+ harmonic information (:py:func:`phasor_component_fit`)
13
+
14
+ - calculate fractions of two or three known components by resolving
15
+ graphically with histogram (:py:func:`phasor_component_graphical`)
16
+
17
+ - calculate mean value coordinates of phasor coordinates with respect
18
+ to three or more components (:py:func:`phasor_component_mvc`)
19
+
20
+ - blindly resolve fractions of multiple components by using harmonic
21
+ information (:py:func:`phasor_component_blind`, not implemented)
22
+
23
+ - calculate phasor coordinates from fractional intensities of
24
+ components (:py:func:`phasor_from_component`)
25
+
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ __all__ = [
31
+ # phasor_component_blind,
32
+ 'phasor_component_fit',
33
+ 'phasor_component_fraction',
34
+ 'phasor_component_graphical',
35
+ 'phasor_component_mvc',
36
+ 'phasor_from_component',
37
+ ]
38
+
39
+ import numbers
40
+ from typing import TYPE_CHECKING
41
+
42
+ if TYPE_CHECKING:
43
+ from ._typing import Any, ArrayLike, DTypeLike, NDArray
44
+
45
+ import numpy
46
+
47
+ from ._phasorpy import (
48
+ _blend_and,
49
+ _fraction_on_segment,
50
+ _is_inside_circle,
51
+ _is_inside_stadium,
52
+ _mean_value_coordinates,
53
+ _segment_direction_and_length,
54
+ )
55
+ from ._utils import sort_coordinates
56
+ from .phasor import phasor_threshold
57
+ from .utils import number_threads
58
+
59
+
60
+ def phasor_from_component(
61
+ component_real: ArrayLike,
62
+ component_imag: ArrayLike,
63
+ fraction: ArrayLike,
64
+ /,
65
+ axis: int = 0,
66
+ dtype: DTypeLike | None = None,
67
+ ) -> tuple[NDArray[Any], NDArray[Any]]:
68
+ """Return phasor coordinates from fractional intensities of components.
69
+
70
+ Return the dot products of the fractional intensities of components
71
+ with the real and imaginary phasor coordinates of the components.
72
+
73
+ Multi-dimensional component arrays are currently not supported.
74
+
75
+ Parameters
76
+ ----------
77
+ component_real : array_like, shape (n,)
78
+ Real coordinates of components.
79
+ At least two components are required.
80
+ component_imag : array_like, shape (n,)
81
+ Imaginary coordinates of components.
82
+ fraction : array_like
83
+ Fractional intensities of components.
84
+ Fractions are normalized to sum to one along `axis`.
85
+ axis : int, optional, default: 0
86
+ Axis of components in `fraction`.
87
+ dtype : dtype_like, optional
88
+ Floating point data type used for calculation and output values.
89
+ Either `float32` or `float64`. The default is `float64`.
90
+
91
+ Returns
92
+ -------
93
+ real : ndarray
94
+ Real component of phasor coordinates.
95
+ imag : ndarray
96
+ Imaginary component of phasor coordinates.
97
+
98
+ Examples
99
+ --------
100
+ Calculate phasor coordinates from two components and their fractional
101
+ intensities:
102
+
103
+ >>> phasor_from_component(
104
+ ... [0.6, 0.4], [0.3, 0.2], [[1.0, 0.2, 0.9], [0.0, 0.8, 0.1]]
105
+ ... )
106
+ (array([0.6, 0.44, 0.58]), array([0.3, 0.22, 0.29]))
107
+
108
+ """
109
+ dtype = numpy.dtype(dtype)
110
+ if dtype.char not in {'f', 'd'}:
111
+ raise ValueError(f'{dtype=} is not a floating point type')
112
+
113
+ fraction = numpy.array(fraction, dtype=dtype, copy=True)
114
+ if fraction.ndim < 1:
115
+ raise ValueError(f'{fraction.ndim=} < 1')
116
+ if fraction.shape[axis] < 2:
117
+ raise ValueError(f'{fraction.shape[axis]=} < 2')
118
+ with numpy.errstate(divide='ignore', invalid='ignore'):
119
+ fraction /= fraction.sum(axis=axis, keepdims=True)
120
+
121
+ component_real = numpy.asarray(component_real, dtype=dtype)
122
+ component_imag = numpy.asarray(component_imag, dtype=dtype)
123
+ if component_real.shape != component_imag.shape:
124
+ raise ValueError(f'{component_real.shape=} != {component_imag.shape=}')
125
+ if component_real.ndim != 1:
126
+ raise ValueError(f'{component_real.ndim=} != 1')
127
+ if component_real.size != fraction.shape[axis]:
128
+ raise ValueError(f'{component_real.size=} != {fraction.shape[axis]=}')
129
+
130
+ fraction = numpy.moveaxis(fraction, axis, -1)
131
+ real = numpy.dot(fraction, component_real)
132
+ imag = numpy.dot(fraction, component_imag)
133
+ return real, imag
134
+
135
+
136
+ def phasor_component_fraction(
137
+ real: ArrayLike,
138
+ imag: ArrayLike,
139
+ component_real: ArrayLike,
140
+ component_imag: ArrayLike,
141
+ /,
142
+ ) -> NDArray[Any]:
143
+ """Return fraction of first of two components from phasor coordinates.
144
+
145
+ Return the relative distance (normalized by the distance between the two
146
+ components) to the second component for each phasor coordinate projected
147
+ onto the line between two components.
148
+
149
+ Parameters
150
+ ----------
151
+ real : array_like
152
+ Real component of phasor coordinates.
153
+ imag : array_like
154
+ Imaginary component of phasor coordinates.
155
+ component_real : array_like, shape (2,)
156
+ Real coordinates of first and second components.
157
+ component_imag : array_like, shape (2,)
158
+ Imaginary coordinates of first and second components.
159
+
160
+ Returns
161
+ -------
162
+ fraction : ndarray
163
+ Fractions of first component.
164
+
165
+ Raises
166
+ ------
167
+ ValueError
168
+ If the real or imaginary coordinates of the known components are
169
+ not of size 2.
170
+
171
+ See Also
172
+ --------
173
+ :ref:`sphx_glr_tutorials_api_phasorpy_component.py`
174
+
175
+ Notes
176
+ -----
177
+ The fraction of the second component is ``1.0 - fraction``.
178
+
179
+ For now, calculation of fraction of components from different
180
+ channels or frequencies is not supported. Only one pair of components can
181
+ be analyzed and will be broadcast to all channels/frequencies.
182
+
183
+ Examples
184
+ --------
185
+ >>> phasor_component_fraction(
186
+ ... [0.6, 0.5, 0.4], [0.4, 0.3, 0.2], [0.2, 0.9], [0.4, 0.3]
187
+ ... )
188
+ array([0.44, 0.56, 0.68])
189
+
190
+ """
191
+ component_real = numpy.asarray(component_real)
192
+ component_imag = numpy.asarray(component_imag)
193
+ if component_real.shape != (2,):
194
+ raise ValueError(f'{component_real.shape=} != (2,)')
195
+ if component_imag.shape != (2,):
196
+ raise ValueError(f'{component_imag.shape=} != (2,)')
197
+ if (
198
+ component_real[0] == component_real[1]
199
+ and component_imag[0] == component_imag[1]
200
+ ):
201
+ raise ValueError('components must have different coordinates')
202
+
203
+ return _fraction_on_segment( # type: ignore[no-any-return]
204
+ real,
205
+ imag,
206
+ component_real[0],
207
+ component_imag[0],
208
+ component_real[1],
209
+ component_imag[1],
210
+ )
211
+
212
+
213
+ def phasor_component_graphical(
214
+ real: ArrayLike,
215
+ imag: ArrayLike,
216
+ component_real: ArrayLike,
217
+ component_imag: ArrayLike,
218
+ /,
219
+ *,
220
+ radius: float = 0.05,
221
+ fractions: ArrayLike | None = None,
222
+ ) -> NDArray[Any]:
223
+ r"""Return fractions of two or three components from phasor coordinates.
224
+
225
+ The graphical method is based on moving circular cursors along the line
226
+ between pairs of components and quantifying the phasors for each
227
+ fraction.
228
+
229
+ Parameters
230
+ ----------
231
+ real : array_like
232
+ Real component of phasor coordinates.
233
+ imag : array_like
234
+ Imaginary component of phasor coordinates.
235
+ component_real : array_like, shape (2,) or (3,)
236
+ Real coordinates for two or three components.
237
+ component_imag : array_like, shape (2,) or (3,)
238
+ Imaginary coordinates for two or three components.
239
+ radius : float, optional, default: 0.05
240
+ Radius of cursor.
241
+ fractions : array_like or int, optional
242
+ Number of equidistant fractions, or 1D array of fraction values.
243
+ Fraction values must be in range [0.0, 1.0].
244
+ If an integer, ``numpy.linspace(0.0, 1.0, fractions)`` fraction values
245
+ are used.
246
+ If None (default), the number of fractions is determined from the
247
+ longest distance between any pair of components and the radius of
248
+ the cursor (see Notes below).
249
+
250
+ Returns
251
+ -------
252
+ counts : ndarray
253
+ Counts along each line segment connecting components.
254
+ Ordered 0-1 (2 components) or 0-1, 0-2, 1-2 (3 components).
255
+ Shaped `(number fractions,)` (2 components) or
256
+ `(3, number fractions)` (3 components).
257
+
258
+ Raises
259
+ ------
260
+ ValueError
261
+ The array shapes of `real` and `imag`, or `component_real` and
262
+ `component_imag` do not match.
263
+ The number of components is not 2 or 3.
264
+ Fraction values are out of range [0.0, 1.0].
265
+
266
+ See Also
267
+ --------
268
+ :ref:`sphx_glr_tutorials_api_phasorpy_component.py`
269
+
270
+ Notes
271
+ -----
272
+ For now, calculation of fraction of components from different
273
+ channels or frequencies is not supported. Only one set of components can
274
+ be analyzed and will be broadcast to all channels/frequencies.
275
+
276
+ The graphical method was first introduced in [1]_.
277
+
278
+ If no `fractions` are provided, the number of fractions (:math:`N`) used
279
+ is determined from the longest distance between any pair of components
280
+ (:math:`D`) and the radius of the cursor (:math:`R`):
281
+
282
+ .. math::
283
+
284
+ N = \frac{2 \cdot D}{R} + 1
285
+
286
+ The fractions can be retrieved by:
287
+
288
+ .. code-block:: python
289
+
290
+ fractions = numpy.linspace(0.0, 1.0, len(counts[0]))
291
+
292
+ References
293
+ ----------
294
+ .. [1] Ranjit S, Datta R, Dvornikov A, and Gratton E.
295
+ `Multicomponent analysis of phasor plot in a single pixel to
296
+ calculate changes of metabolic trajectory in biological systems
297
+ <https://doi.org/10.1021/acs.jpca.9b07880>`_.
298
+ *J Phys Chem A*, 123(45): 9865-9873 (2019)
299
+
300
+ Examples
301
+ --------
302
+ Count the number of phasors between two components:
303
+
304
+ >>> phasor_component_graphical(
305
+ ... [0.6, 0.3], [0.35, 0.38], [0.2, 0.9], [0.4, 0.3], fractions=6
306
+ ... )
307
+ array([0, 0, 1, 0, 1, 0], dtype=uint8)
308
+
309
+ Count the number of phasors between the combinations of three components:
310
+
311
+ >>> phasor_component_graphical(
312
+ ... [0.4, 0.5],
313
+ ... [0.2, 0.3],
314
+ ... [0.0, 0.2, 0.9],
315
+ ... [0.0, 0.4, 0.3],
316
+ ... fractions=6,
317
+ ... )
318
+ array([[0, 1, 1, 1, 1, 0],
319
+ [0, 1, 0, 0, 0, 0],
320
+ [0, 1, 2, 0, 0, 0]], dtype=uint8)
321
+
322
+ """
323
+ real = numpy.asarray(real)
324
+ imag = numpy.asarray(imag)
325
+ component_real = numpy.asarray(component_real)
326
+ component_imag = numpy.asarray(component_imag)
327
+ if (
328
+ real.shape != imag.shape
329
+ or component_real.shape != component_imag.shape
330
+ ):
331
+ raise ValueError('input array shapes must match')
332
+ if component_real.ndim != 1:
333
+ raise ValueError(
334
+ 'component arrays are not one-dimensional: '
335
+ f'{component_real.ndim} dimensions found'
336
+ )
337
+ num_components = len(component_real)
338
+ if num_components not in {2, 3}:
339
+ raise ValueError('number of components must be 2 or 3')
340
+
341
+ if fractions is None:
342
+ longest_distance = 0
343
+ for i in range(num_components):
344
+ a_real = component_real[i]
345
+ a_imag = component_imag[i]
346
+ for j in range(i + 1, num_components):
347
+ b_real = component_real[j]
348
+ b_imag = component_imag[j]
349
+ _, _, length = _segment_direction_and_length(
350
+ a_real, a_imag, b_real, b_imag
351
+ )
352
+ longest_distance = max(longest_distance, length)
353
+ fractions = numpy.linspace(
354
+ 0.0, 1.0, int(round(longest_distance / (radius / 2) + 1))
355
+ )
356
+ elif isinstance(fractions, (int, numbers.Integral)):
357
+ fractions = numpy.linspace(0.0, 1.0, fractions)
358
+ else:
359
+ fractions = numpy.asarray(fractions)
360
+ if fractions.ndim != 1:
361
+ raise ValueError('fractions is not a one-dimensional array')
362
+
363
+ dtype = numpy.min_scalar_type(real.size)
364
+ counts = numpy.empty(
365
+ (1 if num_components == 2 else 3, fractions.size), dtype
366
+ )
367
+
368
+ c = 0
369
+ for i in range(num_components):
370
+ a_real = component_real[i]
371
+ a_imag = component_imag[i]
372
+ for j in range(i + 1, num_components):
373
+ b_real = component_real[j]
374
+ b_imag = component_imag[j]
375
+ ab_real = a_real - b_real
376
+ ab_imag = a_imag - b_imag
377
+
378
+ for k, f in enumerate(fractions):
379
+ if f < 0.0 or f > 1.0:
380
+ raise ValueError(f'fraction {f} out of bounds [0.0, 1.0]')
381
+ if num_components == 2:
382
+ mask = _is_inside_circle(
383
+ real,
384
+ imag,
385
+ b_real + f * ab_real, # cursor_real
386
+ b_imag + f * ab_imag, # cursor_imag
387
+ radius,
388
+ )
389
+ else:
390
+ # num_components == 3
391
+ mask = _is_inside_stadium(
392
+ real,
393
+ imag,
394
+ b_real + f * ab_real, # cursor_real
395
+ b_imag + f * ab_imag, # cursor_imag
396
+ component_real[3 - i - j], # c_real
397
+ component_imag[3 - i - j], # c_imag
398
+ radius,
399
+ )
400
+ counts[c, k] = numpy.sum(mask, dtype=dtype)
401
+ c += 1
402
+
403
+ return counts[0] if num_components == 2 else counts
404
+
405
+
406
+ def phasor_component_fit(
407
+ mean: ArrayLike,
408
+ real: ArrayLike,
409
+ imag: ArrayLike,
410
+ component_real: ArrayLike,
411
+ component_imag: ArrayLike,
412
+ /,
413
+ **kwargs: Any,
414
+ ) -> NDArray[Any]:
415
+ """Return fractions of multiple components from phasor coordinates.
416
+
417
+ Component fractions are obtained from the least-squares solution of a
418
+ linear matrix equation that relates phasor coordinates from one or
419
+ multiple harmonics to component fractions according to [2]_.
420
+
421
+ Up to ``2 * number harmonics + 1`` components can be fit to multi-harmonic
422
+ phasor coordinates, that is up to three components for single harmonic
423
+ phasor coordinates.
424
+
425
+ Parameters
426
+ ----------
427
+ mean : array_like
428
+ Intensity of phasor coordinates.
429
+ real : array_like
430
+ Real component of phasor coordinates.
431
+ Harmonics, if any, must be in the first dimension.
432
+ imag : array_like
433
+ Imaginary component of phasor coordinates.
434
+ Harmonics, if any, must be in the first dimension.
435
+ component_real : array_like
436
+ Real coordinates of components.
437
+ Must be one or two-dimensional with harmonics in the first dimension.
438
+ component_imag : array_like
439
+ Imaginary coordinates of components.
440
+ Must be one or two-dimensional with harmonics in the first dimension.
441
+ **kwargs : optional
442
+ Additional arguments passed to :py:func:`scipy.linalg.lstsq()`.
443
+
444
+ Returns
445
+ -------
446
+ fractions : ndarray
447
+ Component fractions.
448
+ Fractions may not exactly add up to 1.0.
449
+
450
+ Raises
451
+ ------
452
+ ValueError
453
+ The array shapes of `real` and `imag` do not match.
454
+ The array shapes of `component_real` and `component_imag` do not match.
455
+ The number of harmonics in the components does not
456
+ match the ones in the phasor coordinates.
457
+ The system is underdetermined; the component matrix having more
458
+ columns than rows.
459
+
460
+ See Also
461
+ --------
462
+ :ref:`sphx_glr_tutorials_api_phasorpy_component.py`
463
+ :ref:`sphx_glr_tutorials_applications_phasorpy_component_fit.py`
464
+
465
+ Notes
466
+ -----
467
+ For now, calculation of fractions of components from different channels
468
+ or frequencies is not supported. Only one set of components can be
469
+ analyzed and is broadcast to all channels/frequencies.
470
+
471
+ The method builds a linear matrix equation,
472
+ :math:`A\\mathbf{x} = \\mathbf{b}`, where :math:`A` consists of the
473
+ phasor coordinates of individual components, :math:`\\mathbf{x}` are
474
+ the unknown fractions, and :math:`\\mathbf{b}` represents the measured
475
+ phasor coordinates in the mixture. The least-squares solution of this
476
+ linear matrix equation yields the fractions.
477
+
478
+ References
479
+ ----------
480
+ .. [2] Vallmitjana A, Lepanto P, Irigoin F, and Malacrida L.
481
+ `Phasor-based multi-harmonic unmixing for in-vivo hyperspectral
482
+ imaging <https://doi.org/10.1088/2050-6120/ac9ae9>`_.
483
+ *Methods Appl Fluoresc*, 11(1): 014001 (2022)
484
+
485
+ Examples
486
+ --------
487
+ >>> phasor_component_fit(
488
+ ... [1, 1, 1], [0.6, 0.5, 0.4], [0.4, 0.3, 0.2], [0.2, 0.9], [0.4, 0.3]
489
+ ... )
490
+ array([[0.4644, 0.5356, 0.6068],
491
+ [0.5559, 0.4441, 0.3322]])
492
+
493
+ """
494
+ from scipy.linalg import lstsq
495
+
496
+ mean = numpy.atleast_1d(mean)
497
+ real = numpy.atleast_1d(real)
498
+ imag = numpy.atleast_1d(imag)
499
+ component_real = numpy.atleast_1d(component_real)
500
+ component_imag = numpy.atleast_1d(component_imag)
501
+
502
+ if real.shape != imag.shape:
503
+ raise ValueError(f'{real.shape=} != {imag.shape=}')
504
+ if mean.shape != real.shape[-mean.ndim :]:
505
+ raise ValueError(f'{mean.shape=} does not match {real.shape=}')
506
+
507
+ if component_real.shape != component_imag.shape:
508
+ raise ValueError(f'{component_real.shape=} != {component_imag.shape=}')
509
+ if numpy.isnan(component_real).any() or numpy.isnan(component_imag).any():
510
+ raise ValueError(
511
+ 'component phasor coordinates must not contain NaN values'
512
+ )
513
+ if numpy.isinf(component_real).any() or numpy.isinf(component_imag).any():
514
+ raise ValueError(
515
+ 'component phasor coordinates must not contain infinite values'
516
+ )
517
+
518
+ if component_real.ndim == 1:
519
+ component_real = component_real.reshape(1, -1)
520
+ component_imag = component_imag.reshape(1, -1)
521
+ elif component_real.ndim > 2:
522
+ raise ValueError(f'{component_real.ndim=} > 2')
523
+
524
+ num_harmonics, num_components = component_real.shape
525
+
526
+ # create component matrix for least squares solving:
527
+ # [real coordinates of components (for each harmonic)] +
528
+ # [imaginary coordinates of components (for each harmonic)] +
529
+ # [ones for intensity constraint]
530
+ component_matrix = numpy.ones((2 * num_harmonics + 1, num_components))
531
+ component_matrix[:num_harmonics] = component_real
532
+ component_matrix[num_harmonics : 2 * num_harmonics] = component_imag
533
+
534
+ if component_matrix.shape[0] < component_matrix.shape[1]:
535
+ raise ValueError(
536
+ 'the system is undetermined '
537
+ f'({num_components=} > {num_harmonics * 2 + 1=})'
538
+ )
539
+
540
+ has_harmonic_axis = mean.ndim + 1 == real.ndim
541
+ if not has_harmonic_axis:
542
+ real = numpy.expand_dims(real, axis=0)
543
+ imag = numpy.expand_dims(imag, axis=0)
544
+ elif real.shape[0] != num_harmonics:
545
+ raise ValueError(f'{real.shape[0]=} != {component_real.shape[0]=}')
546
+
547
+ # TODO: replace Inf with NaN values?
548
+ mean, real, imag = phasor_threshold(mean, real, imag)
549
+
550
+ # replace NaN values with 0.0 for least squares solving
551
+ real = numpy.nan_to_num(real, nan=0.0, copy=False)
552
+ imag = numpy.nan_to_num(imag, nan=0.0, copy=False)
553
+
554
+ # create coordinates matrix for least squares solving:
555
+ # [real coordinates (for each harmonic)] +
556
+ # [imaginary coordinates (for each harmonic)] +
557
+ # [ones for intensity constraint]
558
+ coords = numpy.ones(
559
+ (2 * num_harmonics + 1,) + real.shape[1:] # type: ignore[union-attr]
560
+ )
561
+ coords[:num_harmonics] = real
562
+ coords[num_harmonics : 2 * num_harmonics] = imag
563
+
564
+ fractions = lstsq(
565
+ component_matrix, coords.reshape(coords.shape[0], -1), **kwargs
566
+ )[0]
567
+
568
+ # reshape to match input dimensions
569
+ fractions = fractions.reshape((num_components,) + coords.shape[1:])
570
+
571
+ # TODO: normalize fractions to sum up to 1.0?
572
+ # fractions /= numpy.sum(fractions, axis=0, keepdims=True)
573
+
574
+ # restore NaN values in fractions from mean
575
+ _blend_and(mean, fractions, out=fractions)
576
+
577
+ return numpy.asarray(fractions)
578
+
579
+
580
+ def phasor_component_mvc(
581
+ real: ArrayLike,
582
+ imag: ArrayLike,
583
+ component_real: ArrayLike,
584
+ component_imag: ArrayLike,
585
+ /,
586
+ *,
587
+ dtype: DTypeLike = None,
588
+ num_threads: int | None = None,
589
+ ) -> NDArray[Any]:
590
+ """Return mean value coordinates of phasor coordinates from components.
591
+
592
+ The mean value coordinates of phasor coordinates with respect to three or
593
+ more components spanning an arbitrary simple polygon are computed using
594
+ the stable method described in [3]_.
595
+ For three components, mean value coordinates are equivalent to
596
+ barycentric coordinates.
597
+
598
+ Parameters
599
+ ----------
600
+ real : array_like
601
+ Real component of phasor coordinates.
602
+ imag : array_like
603
+ Imaginary component of phasor coordinates.
604
+ component_real : array_like
605
+ Real coordinates of at least three components.
606
+ component_imag : array_like
607
+ Imaginary coordinates of at least three components.
608
+ dtype : dtype_like, optional
609
+ Floating point data type used for calculation and output values.
610
+ Either `float32` or `float64`. The default is `float64`.
611
+ num_threads : int, optional
612
+ Number of OpenMP threads to use for parallelization.
613
+ By default, multi-threading is disabled.
614
+ If zero, up to half of logical CPUs are used.
615
+ OpenMP may not be available on all platforms.
616
+
617
+ Returns
618
+ -------
619
+ fractions : ndarray
620
+ Mean value coordinates for each phasor coordinate.
621
+
622
+ Raises
623
+ ------
624
+ ValueError
625
+ The array shapes of `real` and `imag` do not match.
626
+ The array shapes of `component_real` and `component_imag` do not match.
627
+
628
+ Notes
629
+ -----
630
+ Calculation of mean value coordinates for different channels,
631
+ frequencies, or harmonics is not supported. Only one set of components
632
+ can be analyzed and is broadcast to all channels/frequencies/harmonics.
633
+
634
+ For three components, this function returns the same result as
635
+ :py:func:`phasor_component_fit`. For more than three components,
636
+ the system is underdetermined and the mean value coordinates represent
637
+ one of multiple solutions. However, the special properties of the mean
638
+ value coordinates make them particularly useful for interpolating and
639
+ visualizing multi-component data.
640
+
641
+ References
642
+ ----------
643
+ .. [3] Fuda C and Hormann K.
644
+ `A new stable method to compute mean value coordinates
645
+ <https://doi.org/10.1016/j.cagd.2024.102310>`_.
646
+ *Computer Aided Geometric Design*, 111: 102310 (2024)
647
+
648
+ Examples
649
+ --------
650
+ Calculate the barycentric coordinates of a phasor coordinate
651
+ in a triangle defined by three components:
652
+
653
+ >>> phasor_component_mvc(0.6, 0.3, [0.0, 1.0, 0.0], [1.0, 0.0, 0.0])
654
+ array([0.3, 0.6, 0.1])
655
+
656
+ The barycentric coordinates of phasor coordinates outside the polygon
657
+ defined by the components may be outside the range [0.0, 1.0]:
658
+
659
+ >>> phasor_component_mvc(0.6, 0.6, [0.0, 1.0, 0.0], [1.0, 0.0, 0.0])
660
+ array([0.6, 0.6, -0.2])
661
+
662
+ """
663
+ num_threads = number_threads(num_threads)
664
+
665
+ dtype = numpy.dtype(dtype)
666
+ if dtype.char not in {'f', 'd'}:
667
+ raise ValueError(f'{dtype=} is not a floating point type')
668
+
669
+ real = numpy.ascontiguousarray(real, dtype=dtype)
670
+ imag = numpy.ascontiguousarray(imag, dtype=dtype)
671
+ component_real = numpy.ascontiguousarray(component_real, dtype=dtype)
672
+ component_imag = numpy.ascontiguousarray(component_imag, dtype=dtype)
673
+
674
+ if real.shape != imag.shape:
675
+ raise ValueError(f'{real.shape=} != {imag.shape=}')
676
+ if component_real.shape != component_imag.shape:
677
+ raise ValueError(f'{component_real.shape=} != {component_imag.shape=}')
678
+ if component_real.ndim != 1 or component_real.size < 3:
679
+ raise ValueError('number of components must be three or more')
680
+ if numpy.isnan(component_real).any() or numpy.isnan(component_imag).any():
681
+ raise ValueError('component coordinates must not contain NaN values')
682
+ if numpy.isinf(component_real).any() or numpy.isinf(component_imag).any():
683
+ raise ValueError(
684
+ 'component coordinates must not contain infinite values'
685
+ )
686
+
687
+ # TODO:: sorting not strictly required for three components?
688
+ component_real, component_imag, indices = sort_coordinates(
689
+ component_real, component_imag
690
+ )
691
+
692
+ shape = real.shape
693
+ real = real.reshape(-1)
694
+ imag = imag.reshape(-1)
695
+ fraction = numpy.zeros((component_real.size, real.size), dtype=dtype)
696
+
697
+ _mean_value_coordinates(
698
+ fraction,
699
+ indices,
700
+ real,
701
+ imag,
702
+ component_real,
703
+ component_imag,
704
+ num_threads,
705
+ )
706
+
707
+ return numpy.asarray(fraction.reshape((-1, *shape)).squeeze())