From b23889e4a5e915c89be811c11b54c5d7049bbb2a Mon Sep 17 00:00:00 2001 From: bruno-pannunzio Date: Tue, 16 Jan 2024 14:04:13 -0300 Subject: [PATCH 01/21] Added phasorpy_dev virtual environment to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 47896e06..88a03493 100644 --- a/.gitignore +++ b/.gitignore @@ -157,6 +157,7 @@ venv/ ENV/ env.bak/ venv.bak/ +phasorpy_dev # Spyder project settings .spyderproject From 8f86c20c0f63fbc5c542c0eeba7ea755f43c0255 Mon Sep 17 00:00:00 2001 From: bruno-pannunzio Date: Wed, 3 Apr 2024 12:26:56 -0300 Subject: [PATCH 02/21] First version of projection to line between components --- src/phasorpy/components.py | 65 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 src/phasorpy/components.py diff --git a/src/phasorpy/components.py b/src/phasorpy/components.py new file mode 100644 index 00000000..09552de9 --- /dev/null +++ b/src/phasorpy/components.py @@ -0,0 +1,65 @@ +"""Component analysis of phasor coordinates. + +The ``phasorpy.components`` module provides functions to: + + +""" + +from __future__ import annotations + +__all__ = [ + 'distance_point_to_line_segment', + 'calculate_nearness', +] + +import numpy +import matplotlib.pyplot as plt + +def project_points_to_line(x_points, y_points, B, C): + # Convert B and C points to numpy arrays for vector operations + B = numpy.array(B) + C = numpy.array(C) + + # Calculate direction vector of line segment BC + BC = C - B + + # Normalize BC vector + BC_normalized = BC / numpy.linalg.norm(BC) + + # Calculate vector from B to P + BPx = x_points - B[0] + BPy = y_points - B[1] + # Calculate projection of BP onto BC + projection_lengths = BPx * BC_normalized[0] + BPy * BC_normalized[1] + + # Calculate projection points + projected_points = B + numpy.outer(projection_lengths, BC_normalized) + + # Extract x and y coordinates of projected points + projected_x_points, projected_y_points = projected_points[:, 0], projected_points[:, 1] + + return projected_x_points, projected_y_points + + +def create_histogram_along_line(x_points, y_points, B, C, bins=10): + """ + Creates a histogram of distribution of points along the line between points B and C. + + Parameters: + x_points (array): Array of x coordinates. + y_points (array): Array of y coordinates. + B (tuple): Coordinates of point B (x, y). + C (tuple): Coordinates of point C (x, y). + bins (int): Number of bins for the histogram. + + Returns: + tuple: Histogram values and bin edges. + """ + projected_x, projected_y = project_points_to_line(x_points, y_points, B, C) + distances_from_B = numpy.sqrt((projected_x - B[0])**2 + (projected_y - B[1])**2) + histogram_values, bin_edges, _ = plt.hist(distances_from_B, bins=bins, edgecolor='black') + plt.xlabel('Distance from B') + plt.ylabel('Frequency') + plt.title('Histogram of Distribution along Line BC') + plt.show() + return histogram_values, bin_edges, projected_x, projected_y From 12597342753484ea8f34bbb93d8a3b29e114d6ce Mon Sep 17 00:00:00 2001 From: bruno-pannunzio Date: Wed, 3 Apr 2024 20:48:40 -0300 Subject: [PATCH 03/21] Divided functions into modules.`project_phasor_to_line` implemented in _utils module. `two_components_fractions_from_phasor` is implemented to return fractions. Plot of distribution of fractions is still in progress. --- src/phasorpy/_utils.py | 31 +++++++++++ src/phasorpy/components.py | 103 ++++++++++++++++++++----------------- src/phasorpy/plot.py | 36 +++++++++++++ 3 files changed, 124 insertions(+), 46 deletions(-) diff --git a/src/phasorpy/_utils.py b/src/phasorpy/_utils.py index 4f91b27f..1ad041e9 100644 --- a/src/phasorpy/_utils.py +++ b/src/phasorpy/_utils.py @@ -17,6 +17,7 @@ 'phasor_from_polar_scalar', 'circle_line_intersection', 'circle_circle_intersection', + 'project_phasor_to_line', ] import math @@ -260,3 +261,33 @@ def circle_line_intersection( y + (-dd * dx - abs(dy) * rdd) / dr, ), ) + + +def project_phasor_to_line( + real: ArrayLike, imag: ArrayLike, first_component_phasor: ArrayLike, second_component_phasor: ArrayLike, / +) -> tuple[NDArray, NDArray]: + """Return projected phasor coordinates to the line that joins two components based on their phasor location. + + >>> project_phasor_to_line( + ... [0.6, 0.5, 0.4], [0.4, 0.3, 0.2], [0.2, 0.4], [0.9, 0.3] + ... ) # doctest: +NUMBER + (array([0.592, 0.508, 0.424]), array([0.344, 0.356, 0.368])) + + """ + real = numpy.copy(real) + imag = numpy.copy(imag) + first_component_phasor = numpy.array(first_component_phasor) + second_component_phasor = numpy.array(second_component_phasor) + line_between_components = second_component_phasor - first_component_phasor + line_between_components /= numpy.linalg.norm(line_between_components) + real -= first_component_phasor[0] + imag -= first_component_phasor[1] + projection_lengths = ( + real * line_between_components[0] + imag * line_between_components[1] + ) + projected_points = first_component_phasor + numpy.outer( + projection_lengths, line_between_components + ) + projected_points_real = projected_points[:, 0].reshape(real.shape) + projected_points_imag = projected_points[:, 1].reshape(imag.shape) + return projected_points_real, projected_points_imag diff --git a/src/phasorpy/components.py b/src/phasorpy/components.py index 09552de9..3efc3e56 100644 --- a/src/phasorpy/components.py +++ b/src/phasorpy/components.py @@ -8,58 +8,69 @@ from __future__ import annotations __all__ = [ - 'distance_point_to_line_segment', - 'calculate_nearness', + 'component_fractions_from_phasor' ] -import numpy -import matplotlib.pyplot as plt - -def project_points_to_line(x_points, y_points, B, C): - # Convert B and C points to numpy arrays for vector operations - B = numpy.array(B) - C = numpy.array(C) - - # Calculate direction vector of line segment BC - BC = C - B - - # Normalize BC vector - BC_normalized = BC / numpy.linalg.norm(BC) +from typing import TYPE_CHECKING - # Calculate vector from B to P - BPx = x_points - B[0] - BPy = y_points - B[1] - # Calculate projection of BP onto BC - projection_lengths = BPx * BC_normalized[0] + BPy * BC_normalized[1] +if TYPE_CHECKING: + from ._typing import Any, ArrayLike, NDArray - # Calculate projection points - projected_points = B + numpy.outer(projection_lengths, BC_normalized) - - # Extract x and y coordinates of projected points - projected_x_points, projected_y_points = projected_points[:, 0], projected_points[:, 1] - - return projected_x_points, projected_y_points +import numpy +import math +import matplotlib.pyplot as plt +from ._utils import project_phasor_to_line + + +def two_components_fractions_from_phasor( + real: ArrayLike, imag: ArrayLike, first_component_phasor: ArrayLike, second_component_phasor: ArrayLike, /, +) -> tuple[NDArray[Any], NDArray[Any]]: + """Return fractions of two components from phasor coordinates. + + Parameters + ---------- + real : array_like + Real component of phasor coordinates. + imag : array_like + Imaginary component of phasor coordinates. + first_component_phasor: array_like + Coordinates from the first component + second_component_phasor: array_like + Coordinates from the second component + + Returns + ------- + fractions_of_first_component : ndarray + Fractions of the first component. + fractions_of_second_component : ndarray + Fractions of the second component + + Raises + ------ + ValueError + The `signal` has less than three samples along `axis`. + + Examples + -------- + >>> two_components_fractions_from_phasor(...) # doctest: +NUMBER + (...) -def create_histogram_along_line(x_points, y_points, B, C, bins=10): """ - Creates a histogram of distribution of points along the line between points B and C. + projected_real, projected_imag = project_phasor_to_line( + real, imag, first_component_phasor, second_component_phasor + ) + total_distance_between_components = math.sqrt( + (second_component_phasor[0] - first_component_phasor[0]) ** 2 + + (second_component_phasor[1] - first_component_phasor[1]) ** 2 + ) + distances_to_first_component = numpy.sqrt( + (numpy.array(projected_real) - first_component_phasor[0]) ** 2 + + (numpy.array(projected_imag) - first_component_phasor[1]) ** 2 + ) + fraction_of_first_component = ( + distances_to_first_component / total_distance_between_components + ) + return fraction_of_first_component, 1 - fraction_of_first_component - Parameters: - x_points (array): Array of x coordinates. - y_points (array): Array of y coordinates. - B (tuple): Coordinates of point B (x, y). - C (tuple): Coordinates of point C (x, y). - bins (int): Number of bins for the histogram. - Returns: - tuple: Histogram values and bin edges. - """ - projected_x, projected_y = project_points_to_line(x_points, y_points, B, C) - distances_from_B = numpy.sqrt((projected_x - B[0])**2 + (projected_y - B[1])**2) - histogram_values, bin_edges, _ = plt.hist(distances_from_B, bins=bins, edgecolor='black') - plt.xlabel('Distance from B') - plt.ylabel('Frequency') - plt.title('Histogram of Distribution along Line BC') - plt.show() - return histogram_values, bin_edges, projected_x, projected_y diff --git a/src/phasorpy/plot.py b/src/phasorpy/plot.py index e96d98f9..2dc34ca6 100644 --- a/src/phasorpy/plot.py +++ b/src/phasorpy/plot.py @@ -13,6 +13,7 @@ 'plot_phasor_image', 'plot_signal_image', 'plot_polar_frequency', + 'two_components_histogram', ] import math @@ -42,6 +43,7 @@ parse_kwargs, phasor_from_polar_scalar, phasor_to_polar_scalar, + project_phasor_to_line, sort_coordinates, update_kwargs, ) @@ -1166,6 +1168,40 @@ def plot_polar_frequency( if show: pyplot.show() +def two_components_histogram( + real: ArrayLike, imag: ArrayLike, first_component_phasor: ArrayLike, second_component_phasor: ArrayLike, show: bool = True, **kwargs: Any, +) -> tuple: + """ + Creates a histogram of distribution of points along the line between two components. + + Parameters: + real (array): Array of x coordinates. + imag (array): Array of y coordinates. + first_component_phasor (tuple): Phasor coordinates of first component. + second_component_phasor (tuple): Phasorcoordinates of second component. + bins (int): Number of bins for the histogram. + + Returns: + tuple: Histogram values and bin edges. + + """ + projected_real, projected_imag = project_phasor_to_line( + real, imag, first_component_phasor, second_component_phasor + ) + distances_from_first_component = numpy.sqrt( + (projected_real - first_component_phasor[0]) ** 2 + + (projected_imag - first_component_phasor[1]) ** 2 + ) + bins = kwargs.get('bins', 128) + histogram_values, bin_edges, _ = pyplot.hist( + distances_from_first_component, bins=bins + ) + pyplot.xlabel('Distance from first component') + pyplot.ylabel('Frequency') + pyplot.title('Distribution along components') + pyplot.show() + return histogram_values, bin_edges + def _imshow( ax: Axes, From f7f73268dc8cdf200547deaed919df18ca86d316 Mon Sep 17 00:00:00 2001 From: bruno-pannunzio Date: Mon, 8 Apr 2024 11:46:34 -0300 Subject: [PATCH 04/21] Update on linear fraction functions --- src/phasorpy/_utils.py | 2 +- src/phasorpy/components.py | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/phasorpy/_utils.py b/src/phasorpy/_utils.py index 1ad041e9..b1685eef 100644 --- a/src/phasorpy/_utils.py +++ b/src/phasorpy/_utils.py @@ -266,7 +266,7 @@ def circle_line_intersection( def project_phasor_to_line( real: ArrayLike, imag: ArrayLike, first_component_phasor: ArrayLike, second_component_phasor: ArrayLike, / ) -> tuple[NDArray, NDArray]: - """Return projected phasor coordinates to the line that joins two components based on their phasor location. + """Return projected phasor coordinates to the line that joins two phasors. >>> project_phasor_to_line( ... [0.6, 0.5, 0.4], [0.4, 0.3, 0.2], [0.2, 0.4], [0.9, 0.3] diff --git a/src/phasorpy/components.py b/src/phasorpy/components.py index 3efc3e56..5f828299 100644 --- a/src/phasorpy/components.py +++ b/src/phasorpy/components.py @@ -18,12 +18,11 @@ import numpy import math -import matplotlib.pyplot as plt from ._utils import project_phasor_to_line -def two_components_fractions_from_phasor( +def linear_fractions_from_phasor( real: ArrayLike, imag: ArrayLike, first_component_phasor: ArrayLike, second_component_phasor: ArrayLike, /, ) -> tuple[NDArray[Any], NDArray[Any]]: """Return fractions of two components from phasor coordinates. @@ -53,7 +52,7 @@ def two_components_fractions_from_phasor( Examples -------- - >>> two_components_fractions_from_phasor(...) # doctest: +NUMBER + >>> component_fractions_from_phasor(...) # doctest: +NUMBER (...) """ From 634b22f44a152c1de942f5c6d0ea89201a7044e5 Mon Sep 17 00:00:00 2001 From: bruno-pannunzio Date: Tue, 16 Apr 2024 15:11:50 -0300 Subject: [PATCH 05/21] Update fractions function --- docs/api/components.rst | 5 ++ docs/api/index.rst | 1 + prueba_tutorial_components.py | 83 +++++++++++++++++++ src/phasorpy/_utils.py | 12 +-- src/phasorpy/components.py | 85 +++++++++++++++---- src/phasorpy/plot.py | 2 +- tutorials/phasorpy_components.py | 138 +++++++++++++++++++++++++++++++ 7 files changed, 303 insertions(+), 23 deletions(-) create mode 100644 docs/api/components.rst create mode 100644 prueba_tutorial_components.py create mode 100644 tutorials/phasorpy_components.py diff --git a/docs/api/components.rst b/docs/api/components.rst new file mode 100644 index 00000000..128bfc44 --- /dev/null +++ b/docs/api/components.rst @@ -0,0 +1,5 @@ +phasorpy.components +--------------- + +.. automodule:: phasorpy.components + :members: diff --git a/docs/api/index.rst b/docs/api/index.rst index de77ef5b..c0148417 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -16,6 +16,7 @@ PhasorPy library version |version|. phasor plot io + components color datasets utils diff --git a/prueba_tutorial_components.py b/prueba_tutorial_components.py new file mode 100644 index 00000000..1f0a9d4d --- /dev/null +++ b/prueba_tutorial_components.py @@ -0,0 +1,83 @@ +#%% +import numpy +from phasorpy.components import fractional_intensities_from_phasor, fractional_intensities_from_phasor_old +from phasorpy.plot import PhasorPlot, components_histogram +from phasorpy.phasor import phasor_from_lifetime +import time + +fractions = numpy.array( + [[1, 0], [0.25, 0.75], [0, 1]] +) +frequency = 80.0 +components_lifetimes = [8.0, 1.0] +coordinates = phasor_from_lifetime( + frequency, components_lifetimes, fractions + ) +#%% +plot = PhasorPlot(frequency=frequency, title = 'Point lying on the line between components') +plot.plot(*phasor_from_lifetime(frequency, components_lifetimes), fmt= 'o-') +plot.plot(coordinates[0][1], coordinates[1][1]) +fractions = fractional_intensities_from_phasor(coordinates[0][1], coordinates[1][1], *phasor_from_lifetime(frequency, components_lifetimes)) +print (fractions) +# %% +real, imag = numpy.random.multivariate_normal( + (0.6, 0.35), [[8e-3, 1e-3], [1e-3, 1e-3]], (10, 10) +).T +plot = PhasorPlot(frequency=frequency, title = 'Point lying on the line between components') +plot.plot(*phasor_from_lifetime(frequency, components_lifetimes), fmt= 'o-') +plot.plot(real, imag, c='orange', fmt='.') +start = time.time() +fractions = fractional_intensities_from_phasor(real, imag, *phasor_from_lifetime(frequency, components_lifetimes)) +end = time.time() +print(end-start) +#%% +print (fractions) +import matplotlib.pyplot as plt +plt.figure() +plt.hist(fractions[0].flatten(), range=(-1,1.5), bins=100) +plt.title('Histogram of 1D array') +plt.xlabel('Value') +plt.ylabel('Frequency') +plt.grid(True) +plt.show() +# %% +#OLD IMPLEMENTATION +fractions = numpy.array( + [[1, 0], [0.25, 0.75], [0, 1]] +) +frequency = 80.0 +components_lifetimes = [8.0, 1.0] +coordinates = phasor_from_lifetime( + frequency, components_lifetimes, fractions + ) + +plot = PhasorPlot(frequency=frequency, title = 'Point lying on the line between components') +plot.plot(*phasor_from_lifetime(frequency, components_lifetimes), fmt= 'o-') +plot.plot(coordinates[0][1], coordinates[1][1]) +start = time.time() +fractions = fractional_intensities_from_phasor_old(coordinates[0][1], coordinates[1][1], *phasor_from_lifetime(frequency, components_lifetimes)) +end = time.time() +print(end-start) +print (fractions) +# %% +real, imag = numpy.random.multivariate_normal( + (0.6, 0.35), [[8e-3, 1e-3], [1e-3, 1e-3]], (20000, 20000) +).T +# plot = PhasorPlot(frequency=frequency, title = 'Point lying on the line between components') +# plot.plot(*phasor_from_lifetime(frequency, components_lifetimes), fmt= 'o-') +# plot.plot(real, imag, c='orange') +start = time.time() +fractions = fractional_intensities_from_phasor_old(real, imag, *phasor_from_lifetime(frequency, components_lifetimes)) +end = time.time() +print(end-start) +#%% +print (fractions) +import matplotlib.pyplot as plt +plt.figure() +plt.hist(fractions[0].flatten(), range=(0,1), bins=100) +plt.title('Histogram of 1D array') +plt.xlabel('Value') +plt.ylabel('Frequency') +plt.grid(True) +plt.show() +# %% diff --git a/src/phasorpy/_utils.py b/src/phasorpy/_utils.py index b1685eef..fc6b47ac 100644 --- a/src/phasorpy/_utils.py +++ b/src/phasorpy/_utils.py @@ -264,7 +264,7 @@ def circle_line_intersection( def project_phasor_to_line( - real: ArrayLike, imag: ArrayLike, first_component_phasor: ArrayLike, second_component_phasor: ArrayLike, / + real: ArrayLike, imag: ArrayLike, real_components: ArrayLike, imaginary_components: ArrayLike, / ) -> tuple[NDArray, NDArray]: """Return projected phasor coordinates to the line that joins two phasors. @@ -276,9 +276,11 @@ def project_phasor_to_line( """ real = numpy.copy(real) imag = numpy.copy(imag) - first_component_phasor = numpy.array(first_component_phasor) - second_component_phasor = numpy.array(second_component_phasor) - line_between_components = second_component_phasor - first_component_phasor + first_component_phasor = numpy.array([real_components[0], imaginary_components[0]]) + second_component_phasor = numpy.array([real_components[1], imaginary_components[1]]) + if numpy.all(first_component_phasor == second_component_phasor): + raise ValueError('The two components must have different coordinates') + line_between_components = second_component_phasor.astype(float) - first_component_phasor.astype(float) line_between_components /= numpy.linalg.norm(line_between_components) real -= first_component_phasor[0] imag -= first_component_phasor[1] @@ -290,4 +292,4 @@ def project_phasor_to_line( ) projected_points_real = projected_points[:, 0].reshape(real.shape) projected_points_imag = projected_points[:, 1].reshape(imag.shape) - return projected_points_real, projected_points_imag + return numpy.asarray(projected_points_real), numpy.asarray(projected_points_imag) diff --git a/src/phasorpy/components.py b/src/phasorpy/components.py index 5f828299..241759ee 100644 --- a/src/phasorpy/components.py +++ b/src/phasorpy/components.py @@ -8,7 +8,8 @@ from __future__ import annotations __all__ = [ - 'component_fractions_from_phasor' + 'fractional_intensities_from_phasor' + 'fractional_intensities_from_phasor_old' ] from typing import TYPE_CHECKING @@ -22,10 +23,10 @@ from ._utils import project_phasor_to_line -def linear_fractions_from_phasor( - real: ArrayLike, imag: ArrayLike, first_component_phasor: ArrayLike, second_component_phasor: ArrayLike, /, +def fractional_intensities_from_phasor( + real: ArrayLike, imag: ArrayLike, real_components: ArrayLike, imaginary_components: ArrayLike, /, ) -> tuple[NDArray[Any], NDArray[Any]]: - """Return fractions of two components from phasor coordinates. + """Return fractions of two components from phasor coordinates. Parameters ---------- @@ -33,17 +34,15 @@ def linear_fractions_from_phasor( Real component of phasor coordinates. imag : array_like Imaginary component of phasor coordinates. - first_component_phasor: array_like - Coordinates from the first component - second_component_phasor: array_like - Coordinates from the second component + real_components: array_like + Real coordinates of the components. + imaginary_components: array_like + Imaginary coordinates of the components. Returns ------- - fractions_of_first_component : ndarray - Fractions of the first component. - fractions_of_second_component : ndarray - Fractions of the second component + fractions : ndarray + Fractions for all components. Raises ------ @@ -52,13 +51,67 @@ def linear_fractions_from_phasor( Examples -------- - >>> component_fractions_from_phasor(...) # doctest: +NUMBER + >>> fractional_intensities_from_phasor( + ... [0.6, 0.5, 0.4], [0.4, 0.3, 0.2], [0.2, 0.4], [0.9, 0.3] + ... ) # doctest: +NUMBER (...) """ projected_real, projected_imag = project_phasor_to_line( - real, imag, first_component_phasor, second_component_phasor + real, imag, real_components, imaginary_components ) + components_coordinates = numpy.array([real_components, imaginary_components]) + fraction1 = [] + fraction2 = [] + projected_real = numpy.atleast_1d(projected_real) + projected_imag = numpy.atleast_1d(projected_imag) + for element in zip(projected_real,projected_imag): + fractions = numpy.linalg.solve(components_coordinates, element) + fraction1.append(fractions[0]) + fraction2.append(fractions[1]) + fraction1 = numpy.asarray(fraction1) + fraction2 = numpy.asarray(fraction2) + return fraction1.reshape(projected_real.shape), fraction2.reshape(projected_real.shape) + +def fractional_intensities_from_phasor_old( + real: ArrayLike, imag: ArrayLike, real_components: ArrayLike, imaginary_components: ArrayLike, /, +) -> tuple[NDArray[Any], NDArray[Any]]: + """Return fractions of two components from phasor coordinates. + + Parameters + ---------- + real : array_like + Real component of phasor coordinates. + imag : array_like + Imaginary component of phasor coordinates. + real_components: array_like + Real coordinates of the components. + imaginary_components: array_like + Imaginary coordinates of the components. + + Returns + ------- + fractions : ndarray + Fractions for all components. + + Raises + ------ + ValueError + The `signal` has less than three samples along `axis`. + + Examples + -------- + >>> fractional_intensities_from_phasor( + ... [0.6, 0.5, 0.4], [0.4, 0.3, 0.2], [0.2, 0.4], [0.9, 0.3] + ... ) # doctest: +NUMBER + (...) + + """ + projected_real, projected_imag = project_phasor_to_line( + real, imag, real_components, imaginary_components + ) + first_component_phasor = numpy.array([real_components[0], imaginary_components[0]]) + second_component_phasor = numpy.array([real_components[1], imaginary_components[1]]) total_distance_between_components = math.sqrt( (second_component_phasor[0] - first_component_phasor[0]) ** 2 + (second_component_phasor[1] - first_component_phasor[1]) ** 2 @@ -70,6 +123,4 @@ def linear_fractions_from_phasor( fraction_of_first_component = ( distances_to_first_component / total_distance_between_components ) - return fraction_of_first_component, 1 - fraction_of_first_component - - + return fraction_of_first_component, 1 - fraction_of_first_component \ No newline at end of file diff --git a/src/phasorpy/plot.py b/src/phasorpy/plot.py index 2dc34ca6..045fdb3d 100644 --- a/src/phasorpy/plot.py +++ b/src/phasorpy/plot.py @@ -1168,7 +1168,7 @@ def plot_polar_frequency( if show: pyplot.show() -def two_components_histogram( +def components_histogram( real: ArrayLike, imag: ArrayLike, first_component_phasor: ArrayLike, second_component_phasor: ArrayLike, show: bool = True, **kwargs: Any, ) -> tuple: """ diff --git a/tutorials/phasorpy_components.py b/tutorials/phasorpy_components.py new file mode 100644 index 00000000..d6d155bc --- /dev/null +++ b/tutorials/phasorpy_components.py @@ -0,0 +1,138 @@ +""" +Component analysis +=========== + +An introduction to component analysis in the phasor space. + + + +""" + +# %% +# Import required modules, functions, and classes: + +import math + +import numpy + +from phasorpy.components import two_fractions_from_phasor +from phasorpy.plot import PhasorPlot, components_histogram + +# %% +# Component mixtures +# ------------------ +# +# Show linear combinations of phasor coordinates or ranges thereof: + +real, imag, weights = numpy.array( + [[0.1, 0.2, 0.5, 0.9], [0.3, 0.4, 0.5, 0.3], [2, 1, 2, 1]] +) + +plot = PhasorPlot(frequency=80.0, title='Component mixtures') +plot.components(real, imag, linestyle='', fill=True, facecolor='lightyellow') +plot.components(real, imag, weights) +plot.show() + +# %% +# 2D Histogram +# ------------ +# +# Plot large number of phasor coordinates as a 2D histogram: + +real, imag = numpy.random.multivariate_normal( + (0.6, 0.4), [[3e-3, -1e-3], [-1e-3, 1e-3]], (256, 256) +).T +plot = PhasorPlot(frequency=80.0, title='2D Histogram') +plot.hist2d(real, imag) +plot.show() + +# %% +# Contours +# -------- +# +# Plot the contours of the density of phasor coordinates: + +plot = PhasorPlot(frequency=80.0, title='Contours') +plot.contour(real, imag) +plot.show() + + +# %% +# Image +# ----- +# +# Plot the image of a custom-colored 2D histogram: + +plot = PhasorPlot(frequency=80.0, title='Image (not implemented yet)') +# plot.imshow(image) +plot.show() + +# %% +# Combined plots +# -------------- +# +# Multiple plots can be combined: + +real2, imag2 = numpy.random.multivariate_normal( + (0.9, 0.2), [[2e-4, -1e-4], [-1e-4, 2e-4]], 4096 +).T + +plot = PhasorPlot( + title='Combined plots', xlim=(0.35, 1.03), ylim=(0.1, 0.59), grid=False +) +plot.hist2d(real, imag, bins=64, cmap='Blues') +plot.contour(real, imag, bins=48, levels=3, cmap='summer_r', norm='log') +plot.hist2d(real2, imag2, bins=64, cmap='Oranges') +plot.plot(0.6, 0.4, '.', color='tab:blue') +plot.plot(0.9, 0.2, '.', color='tab:orange') +plot.polar_cursor(math.atan(0.4 / 0.6), math.hypot(0.6, 0.4), color='tab:blue') +plot.polar_cursor( + math.atan(0.2 / 0.9), math.hypot(0.9, 0.2), color='tab:orange' +) +plot.semicircle(frequency=80.0, color='tab:purple') +plot.show() + +# %% +# All quadrants +# ------------- +# +# Create an empty phasor plot showing all four quadrants: + +plot = PhasorPlot(allquadrants=True, title='All quadrants') +plot.show() + +# %% +# Matplotlib axes +# --------------- +# +# The PhasorPlot class can use an existing matlotlib axes. +# The `PhasorPlot.ax` attribute provides access to the underlying +# matplotlib axes, for example, to add annotations: + +from matplotlib import pyplot + +ax = pyplot.subplot(1, 1, 1) +plot = PhasorPlot(ax=ax, allquadrants=True, title='Matplotlib axes') +plot.hist2d(real, imag, cmap='Blues') +plot.ax.annotate( + '0.6, 0.4', + xy=(0.6, 0.4), + xytext=(0.2, 0.2), + arrowprops=dict(arrowstyle='->'), +) +pyplot.show() + + +# %% +# plot_phasor function +# -------------------- +# +# The :py:func:`phasorpy.plot.plot_phasor` function provides a simpler +# alternative to plot phasor coordinates in a single statement: + +from phasorpy.plot import plot_phasor + +plot_phasor(real[0, :32], imag[0, :32], fmt='.', frequency=80.0) + +# %% +# sphinx_gallery_thumbnail_number = 9 From d1beda5bf1f259db9f1ea63b84bf32a793a87dcf Mon Sep 17 00:00:00 2001 From: bruno-pannunzio Date: Tue, 16 Apr 2024 19:01:45 -0300 Subject: [PATCH 06/21] update fractional intensities function --- prueba_tutorial_components.py | 16 +++++++-------- src/phasorpy/components.py | 37 +++++++++++++++++++++++++---------- 2 files changed, 35 insertions(+), 18 deletions(-) diff --git a/prueba_tutorial_components.py b/prueba_tutorial_components.py index 1f0a9d4d..c5df63d9 100644 --- a/prueba_tutorial_components.py +++ b/prueba_tutorial_components.py @@ -23,9 +23,9 @@ real, imag = numpy.random.multivariate_normal( (0.6, 0.35), [[8e-3, 1e-3], [1e-3, 1e-3]], (10, 10) ).T -plot = PhasorPlot(frequency=frequency, title = 'Point lying on the line between components') -plot.plot(*phasor_from_lifetime(frequency, components_lifetimes), fmt= 'o-') -plot.plot(real, imag, c='orange', fmt='.') +# plot = PhasorPlot(frequency=frequency, title = 'Point lying on the line between components') +# plot.plot(*phasor_from_lifetime(frequency, components_lifetimes), fmt= 'o-') +# plot.plot(real, imag, c='orange', fmt='.') start = time.time() fractions = fractional_intensities_from_phasor(real, imag, *phasor_from_lifetime(frequency, components_lifetimes)) end = time.time() @@ -34,7 +34,7 @@ print (fractions) import matplotlib.pyplot as plt plt.figure() -plt.hist(fractions[0].flatten(), range=(-1,1.5), bins=100) +plt.hist(fractions[1].flatten(), range=(0,1), bins=100) plt.title('Histogram of 1D array') plt.xlabel('Value') plt.ylabel('Frequency') @@ -60,9 +60,9 @@ print(end-start) print (fractions) # %% -real, imag = numpy.random.multivariate_normal( - (0.6, 0.35), [[8e-3, 1e-3], [1e-3, 1e-3]], (20000, 20000) -).T +# real, imag = numpy.random.multivariate_normal( +# (0.6, 0.35), [[8e-3, 1e-3], [1e-3, 1e-3]], (1000, 1000) +# ).T # plot = PhasorPlot(frequency=frequency, title = 'Point lying on the line between components') # plot.plot(*phasor_from_lifetime(frequency, components_lifetimes), fmt= 'o-') # plot.plot(real, imag, c='orange') @@ -74,7 +74,7 @@ print (fractions) import matplotlib.pyplot as plt plt.figure() -plt.hist(fractions[0].flatten(), range=(0,1), bins=100) +plt.hist(fractions[1].flatten(), range=(0,1), bins=100) plt.title('Histogram of 1D array') plt.xlabel('Value') plt.ylabel('Frequency') diff --git a/src/phasorpy/components.py b/src/phasorpy/components.py index 241759ee..8f88f745 100644 --- a/src/phasorpy/components.py +++ b/src/phasorpy/components.py @@ -61,17 +61,33 @@ def fractional_intensities_from_phasor( real, imag, real_components, imaginary_components ) components_coordinates = numpy.array([real_components, imaginary_components]) - fraction1 = [] - fraction2 = [] + # fraction1 = [] + # fraction2 = [] + # projected_real = numpy.atleast_1d(projected_real) + # projected_imag = numpy.atleast_1d(projected_imag) + # for element in zip(projected_real,projected_imag): + # fractions = numpy.linalg.solve(components_coordinates, element) + # fraction1.append(fractions[0]) + # fraction2.append(fractions[1]) + # fraction1 = numpy.asarray(fraction1) + # fraction2 = numpy.asarray(fraction2) + # fraction1 = numpy.clip(fraction1, 0, 1) + # fraction2 = numpy.clip(fraction2, 0, 1) + # return fraction1.reshape(projected_real.shape), fraction2.reshape(projected_real.shape) projected_real = numpy.atleast_1d(projected_real) projected_imag = numpy.atleast_1d(projected_imag) + i = 0 + fractions = [] for element in zip(projected_real,projected_imag): - fractions = numpy.linalg.solve(components_coordinates, element) - fraction1.append(fractions[0]) - fraction2.append(fractions[1]) - fraction1 = numpy.asarray(fraction1) - fraction2 = numpy.asarray(fraction2) - return fraction1.reshape(projected_real.shape), fraction2.reshape(projected_real.shape) + row_fractions = numpy.linalg.solve(components_coordinates, element) + fractions.append(row_fractions) + i += 1 + print('i=',i) + fractions = numpy.stack(fractions, axis=2) + return fractions + + + def fractional_intensities_from_phasor_old( real: ArrayLike, imag: ArrayLike, real_components: ArrayLike, imaginary_components: ArrayLike, /, @@ -120,7 +136,8 @@ def fractional_intensities_from_phasor_old( (numpy.array(projected_real) - first_component_phasor[0]) ** 2 + (numpy.array(projected_imag) - first_component_phasor[1]) ** 2 ) - fraction_of_first_component = ( + fraction_of_second_component = ( distances_to_first_component / total_distance_between_components ) - return fraction_of_first_component, 1 - fraction_of_first_component \ No newline at end of file + fraction_of_second_component = numpy.clip(fraction_of_second_component, 0 , 1) + return 1 - fraction_of_second_component, fraction_of_second_component \ No newline at end of file From 24f161cbbd7cb883320207f139d08dd22657fd85 Mon Sep 17 00:00:00 2001 From: bruno-pannunzio Date: Thu, 18 Apr 2024 09:09:31 -0300 Subject: [PATCH 07/21] update fraction function --- prueba_tutorial_components.py | 54 ++++++++++++++++++++--- src/phasorpy/components.py | 80 +++++++++++++++++++++++++++-------- 2 files changed, 110 insertions(+), 24 deletions(-) diff --git a/prueba_tutorial_components.py b/prueba_tutorial_components.py index c5df63d9..128e6fcb 100644 --- a/prueba_tutorial_components.py +++ b/prueba_tutorial_components.py @@ -1,8 +1,8 @@ #%% import numpy -from phasorpy.components import fractional_intensities_from_phasor, fractional_intensities_from_phasor_old +from phasorpy.components import fractional_intensities_from_phasor, fractional_intensities_from_phasor_old, fractional_intensities_polar from phasorpy.plot import PhasorPlot, components_histogram -from phasorpy.phasor import phasor_from_lifetime +from phasorpy.phasor import phasor_from_lifetime, polar_from_reference_phasor import time fractions = numpy.array( @@ -10,18 +10,32 @@ ) frequency = 80.0 components_lifetimes = [8.0, 1.0] +coordinates = phasor_from_lifetime( + frequency, components_lifetimes, fractions + ) +# plot = PhasorPlot(frequency=frequency, title = 'Point lying on the line between components') +# plot.plot(*coordinates, fmt= 'o-') +# fractions = fractional_intensities_from_phasor(coordinates[1][0], coordinates[1][1], *phasor_from_lifetime(frequency, components_lifetimes)) +# print (fractions) +fractions = fractional_intensities_polar(coordinates[1][0], coordinates[1][1], *phasor_from_lifetime(frequency, components_lifetimes)) +print (fractions) +#%% +fractions = numpy.array( + [[1, 0, 0], [0.25 ,0.25, 0.50], [0, 1, 0],[0, 0, 1]] +) +frequency = 80.0 +components_lifetimes = [8.0, 3.0, 1.0] coordinates = phasor_from_lifetime( frequency, components_lifetimes, fractions ) #%% plot = PhasorPlot(frequency=frequency, title = 'Point lying on the line between components') -plot.plot(*phasor_from_lifetime(frequency, components_lifetimes), fmt= 'o-') -plot.plot(coordinates[0][1], coordinates[1][1]) -fractions = fractional_intensities_from_phasor(coordinates[0][1], coordinates[1][1], *phasor_from_lifetime(frequency, components_lifetimes)) +plot.plot(*coordinates, fmt= 'o') +fractions = fractional_intensities_from_phasor(*coordinates, *phasor_from_lifetime(frequency, components_lifetimes)) print (fractions) # %% real, imag = numpy.random.multivariate_normal( - (0.6, 0.35), [[8e-3, 1e-3], [1e-3, 1e-3]], (10, 10) + (0.6, 0.35), [[8e-3, 1e-3], [1e-3, 1e-3]], (100, 100) ).T # plot = PhasorPlot(frequency=frequency, title = 'Point lying on the line between components') # plot.plot(*phasor_from_lifetime(frequency, components_lifetimes), fmt= 'o-') @@ -81,3 +95,31 @@ plt.grid(True) plt.show() # %% +# TIMING IMPLEMENTATIONS +fractions = numpy.array( + [[1, 0], [0.25, 0.75], [0, 1]] +) +frequency = 80.0 +components_lifetimes = [8.0, 1.0] +coordinates = phasor_from_lifetime( + frequency, components_lifetimes, fractions + ) +real, imag = numpy.random.multivariate_normal( + (0.6, 0.35), [[8e-3, 1e-3], [1e-3, 1e-3]], (5000, 5000) +).T +new_times = [] +old_times = [] +for i in range(20): + start = time.time() + fractions = fractional_intensities_from_phasor(real, imag, *phasor_from_lifetime(frequency, components_lifetimes)) + end = time.time() + new_times.append(end-start) + start = time.time() + fractions = fractional_intensities_from_phasor_old(real, imag, *phasor_from_lifetime(frequency, components_lifetimes)) + end = time.time() + old_times.append(end-start) +print('AVG NEW: ', numpy.mean(numpy.asarray(new_times))) +print('AVG old: ', numpy.mean(numpy.asarray(old_times))) +# %% + +# %% diff --git a/src/phasorpy/components.py b/src/phasorpy/components.py index 8f88f745..3c6511fa 100644 --- a/src/phasorpy/components.py +++ b/src/phasorpy/components.py @@ -21,6 +21,7 @@ import math from ._utils import project_phasor_to_line +from .phasor import polar_from_reference, phasor_to_polar def fractional_intensities_from_phasor( @@ -61,29 +62,13 @@ def fractional_intensities_from_phasor( real, imag, real_components, imaginary_components ) components_coordinates = numpy.array([real_components, imaginary_components]) - # fraction1 = [] - # fraction2 = [] - # projected_real = numpy.atleast_1d(projected_real) - # projected_imag = numpy.atleast_1d(projected_imag) - # for element in zip(projected_real,projected_imag): - # fractions = numpy.linalg.solve(components_coordinates, element) - # fraction1.append(fractions[0]) - # fraction2.append(fractions[1]) - # fraction1 = numpy.asarray(fraction1) - # fraction2 = numpy.asarray(fraction2) - # fraction1 = numpy.clip(fraction1, 0, 1) - # fraction2 = numpy.clip(fraction2, 0, 1) - # return fraction1.reshape(projected_real.shape), fraction2.reshape(projected_real.shape) projected_real = numpy.atleast_1d(projected_real) projected_imag = numpy.atleast_1d(projected_imag) - i = 0 fractions = [] for element in zip(projected_real,projected_imag): row_fractions = numpy.linalg.solve(components_coordinates, element) fractions.append(row_fractions) - i += 1 - print('i=',i) - fractions = numpy.stack(fractions, axis=2) + fractions = numpy.stack(fractions, axis=-1) return fractions @@ -140,4 +125,63 @@ def fractional_intensities_from_phasor_old( distances_to_first_component / total_distance_between_components ) fraction_of_second_component = numpy.clip(fraction_of_second_component, 0 , 1) - return 1 - fraction_of_second_component, fraction_of_second_component \ No newline at end of file + return 1 - fraction_of_second_component, fraction_of_second_component + +def fractional_intensities_polar( + real: ArrayLike, imag: ArrayLike, real_components: ArrayLike, imaginary_components: ArrayLike, /, +) -> tuple[NDArray[Any], NDArray[Any]]: + """Return fractions of two components from phasor coordinates. + + Parameters + ---------- + real : array_like + Real component of phasor coordinates. + imag : array_like + Imaginary component of phasor coordinates. + real_components: array_like + Real coordinates of the components. + imaginary_components: array_like + Imaginary coordinates of the components. + + Returns + ------- + fractions : ndarray + Fractions for all components. + + Raises + ------ + ValueError + The `signal` has less than three samples along `axis`. + + Examples + -------- + >>> fractional_intensities_from_phasor( + ... [0.6, 0.5, 0.4], [0.4, 0.3, 0.2], [0.2, 0.4], [0.9, 0.3] + ... ) # doctest: +NUMBER + (...) + + """ + # projected_real, projected_imag = project_phasor_to_line( + # real, imag, real_components, imaginary_components + # ) + phase, mod = phasor_to_polar(real, imag) + first_component = phasor_to_polar(real_components[0], imaginary_components[0]) + second_component = phasor_to_polar(real_components[1], imaginary_components[1]) + # phi0_first_component, mod0_first_component = polar_from_reference(phase, mod, *first_component) + # phi0_second_component, mod0_second_component = polar_from_reference(phase, mod, *second_component) + # Reshape single values to arrays with one column + # first_component = numpy.array([first_component[0]]).reshape(-1, 1) + # second_component = numpy.array([second_component[0]]).reshape(-1, 1) + phase = numpy.array([phase]).reshape(-1, 1) + mod = numpy.array([mod]).reshape(-1, 1) + # Create coefficient matrix + # A = numpy.concatenate((first_component, second_component), axis=1) + # Augment coefficient matrix with the equation weight1 + weight2 = 1 + A = numpy.vstack([first_component, second_component, [1, 1]]) + + # Create phase vector + b = numpy.concatenate((phase, mod, numpy.array([[1]]))) + + # Solve the augmented system of equations + fractions = numpy.linalg.solve(A, b) + return fractions \ No newline at end of file From 635b69ca4a67baa8599b13c3061a824422bf2b87 Mon Sep 17 00:00:00 2001 From: bruno-pannunzio Date: Mon, 22 Apr 2024 11:48:50 -0300 Subject: [PATCH 08/21] Update tutorial --- prueba_tutorial_components.py | 181 +++++++++++++------------------ src/phasorpy/_utils.py | 15 ++- src/phasorpy/components.py | 159 ++++++--------------------- src/phasorpy/plot.py | 35 ------ tutorials/phasorpy_components.py | 167 +++++++++++----------------- 5 files changed, 182 insertions(+), 375 deletions(-) diff --git a/prueba_tutorial_components.py b/prueba_tutorial_components.py index 128e6fcb..140b19ea 100644 --- a/prueba_tutorial_components.py +++ b/prueba_tutorial_components.py @@ -1,125 +1,90 @@ #%% import numpy -from phasorpy.components import fractional_intensities_from_phasor, fractional_intensities_from_phasor_old, fractional_intensities_polar -from phasorpy.plot import PhasorPlot, components_histogram -from phasorpy.phasor import phasor_from_lifetime, polar_from_reference_phasor -import time +import matplotlib.pyplot as plt +from phasorpy.components import two_fractions_from_phasor +from phasorpy.plot import PhasorPlot +from phasorpy.phasor import phasor_from_lifetime -fractions = numpy.array( - [[1, 0], [0.25, 0.75], [0, 1]] -) frequency = 80.0 -components_lifetimes = [8.0, 1.0] -coordinates = phasor_from_lifetime( - frequency, components_lifetimes, fractions +components_lifetimes = [[8.0, 1.0],[4.0, 0.5]] +real, imag = phasor_from_lifetime( + frequency, components_lifetimes[0], [0.25, 0.75] ) -# plot = PhasorPlot(frequency=frequency, title = 'Point lying on the line between components') -# plot.plot(*coordinates, fmt= 'o-') -# fractions = fractional_intensities_from_phasor(coordinates[1][0], coordinates[1][1], *phasor_from_lifetime(frequency, components_lifetimes)) -# print (fractions) -fractions = fractional_intensities_polar(coordinates[1][0], coordinates[1][1], *phasor_from_lifetime(frequency, components_lifetimes)) -print (fractions) -#%% -fractions = numpy.array( - [[1, 0, 0], [0.25 ,0.25, 0.50], [0, 1, 0],[0, 0, 1]] -) -frequency = 80.0 -components_lifetimes = [8.0, 3.0, 1.0] -coordinates = phasor_from_lifetime( - frequency, components_lifetimes, fractions - ) -#%% -plot = PhasorPlot(frequency=frequency, title = 'Point lying on the line between components') -plot.plot(*coordinates, fmt= 'o') -fractions = fractional_intensities_from_phasor(*coordinates, *phasor_from_lifetime(frequency, components_lifetimes)) -print (fractions) +components_real, components_imag = phasor_from_lifetime(frequency, components_lifetimes[0]) +plot = PhasorPlot(frequency=frequency, title = 'Phasor lying on the line between components') +plot.plot(components_real, components_imag, fmt= 'o-') +plot.plot(real, imag) +plot.show() +fraction_from_first_component, fraction_from_second_component = two_fractions_from_phasor(real, imag, components_real, components_imag) +print ('Fraction from first component: ', fraction_from_first_component) +print ('Fraction from second component: ', fraction_from_second_component) # %% -real, imag = numpy.random.multivariate_normal( +real1, imag1 = numpy.random.multivariate_normal( (0.6, 0.35), [[8e-3, 1e-3], [1e-3, 1e-3]], (100, 100) ).T -# plot = PhasorPlot(frequency=frequency, title = 'Point lying on the line between components') -# plot.plot(*phasor_from_lifetime(frequency, components_lifetimes), fmt= 'o-') -# plot.plot(real, imag, c='orange', fmt='.') -start = time.time() -fractions = fractional_intensities_from_phasor(real, imag, *phasor_from_lifetime(frequency, components_lifetimes)) -end = time.time() -print(end-start) -#%% -print (fractions) -import matplotlib.pyplot as plt +real2, imag2 = numpy.random.multivariate_normal( + (0.4, 0.3), [[8e-3, 1e-3], [1e-3, 1e-3]], (100, 100) +).T +real = numpy.stack((real1, real2), axis=0) +imag = numpy.stack((imag1, imag2), axis=0) +components_real2, components_imag2 = phasor_from_lifetime(frequency, components_lifetimes[1]) +components_real3 = numpy.stack((components_real, components_real2), axis=0) +components_imag3 = numpy.stack((components_imag, components_imag2), axis=0) +plot = PhasorPlot(frequency=frequency, title = 'Phasor lying on the line between components') +plot.plot(components_real3, components_imag3, fmt= 'o-') +plot.plot(real[0], imag[0], c='blue') +plot.plot(real[1], imag[1], c='orange') +plot.show() +fraction_from_first_component, fraction_from_second_component = two_fractions_from_phasor(real, imag, components_real3, components_imag3) plt.figure() -plt.hist(fractions[1].flatten(), range=(0,1), bins=100) -plt.title('Histogram of 1D array') -plt.xlabel('Value') -plt.ylabel('Frequency') -plt.grid(True) +plt.hist(fraction_from_first_component[0].flatten(), range=(0,1), bins=100) +plt.title('Histogram of fractions of first component ch1') +plt.xlabel('Fraction of first component') +plt.ylabel('Counts') plt.show() -# %% -#OLD IMPLEMENTATION -fractions = numpy.array( - [[1, 0], [0.25, 0.75], [0, 1]] -) -frequency = 80.0 -components_lifetimes = [8.0, 1.0] -coordinates = phasor_from_lifetime( - frequency, components_lifetimes, fractions - ) -plot = PhasorPlot(frequency=frequency, title = 'Point lying on the line between components') -plot.plot(*phasor_from_lifetime(frequency, components_lifetimes), fmt= 'o-') -plot.plot(coordinates[0][1], coordinates[1][1]) -start = time.time() -fractions = fractional_intensities_from_phasor_old(coordinates[0][1], coordinates[1][1], *phasor_from_lifetime(frequency, components_lifetimes)) -end = time.time() -print(end-start) -print (fractions) -# %% -# real, imag = numpy.random.multivariate_normal( -# (0.6, 0.35), [[8e-3, 1e-3], [1e-3, 1e-3]], (1000, 1000) -# ).T -# plot = PhasorPlot(frequency=frequency, title = 'Point lying on the line between components') -# plot.plot(*phasor_from_lifetime(frequency, components_lifetimes), fmt= 'o-') -# plot.plot(real, imag, c='orange') -start = time.time() -fractions = fractional_intensities_from_phasor_old(real, imag, *phasor_from_lifetime(frequency, components_lifetimes)) -end = time.time() -print(end-start) -#%% -print (fractions) -import matplotlib.pyplot as plt plt.figure() -plt.hist(fractions[1].flatten(), range=(0,1), bins=100) -plt.title('Histogram of 1D array') -plt.xlabel('Value') -plt.ylabel('Frequency') -plt.grid(True) +plt.hist(fraction_from_first_component[1].flatten(), range=(0,1), bins=100) +plt.title('Histogram of fractions of first component ch2') +plt.xlabel('Fraction of first component') +plt.ylabel('Counts') plt.show() -# %% -# TIMING IMPLEMENTATIONS -fractions = numpy.array( - [[1, 0], [0.25, 0.75], [0, 1]] -) -frequency = 80.0 -components_lifetimes = [8.0, 1.0] -coordinates = phasor_from_lifetime( - frequency, components_lifetimes, fractions - ) + +plt.figure() +plt.hist(fraction_from_second_component[0].flatten(), range=(0,1), bins=100) +plt.title('Histogram of fractions of second component ch1') +plt.xlabel('Fraction of second component') +plt.ylabel('Counts') +plt.show() +plt.figure() +plt.hist(fraction_from_second_component[1].flatten(), range=(0,1), bins=100) +plt.title('Histogram of fractions of second component ch2') +plt.xlabel('Fraction of second component') +plt.ylabel('Counts') +plt.show() + +#%% real, imag = numpy.random.multivariate_normal( - (0.6, 0.35), [[8e-3, 1e-3], [1e-3, 1e-3]], (5000, 5000) + (0.6, 0.35), [[8e-3, 1e-3], [1e-3, 1e-3]], (100, 100) ).T -new_times = [] -old_times = [] -for i in range(20): - start = time.time() - fractions = fractional_intensities_from_phasor(real, imag, *phasor_from_lifetime(frequency, components_lifetimes)) - end = time.time() - new_times.append(end-start) - start = time.time() - fractions = fractional_intensities_from_phasor_old(real, imag, *phasor_from_lifetime(frequency, components_lifetimes)) - end = time.time() - old_times.append(end-start) -print('AVG NEW: ', numpy.mean(numpy.asarray(new_times))) -print('AVG old: ', numpy.mean(numpy.asarray(old_times))) -# %% +plot = PhasorPlot(frequency=frequency, title = 'Point lying on the line between components') +plot.hist2d(real, imag) +plot.plot(*phasor_from_lifetime(frequency, components_lifetimes), fmt= 'o-') +plot.show() +fraction_from_first_component, fraction_from_second_component = two_fractions_from_phasor(real, imag, components_real, components_imag) + +plt.figure() +plt.hist(fraction_from_first_component.flatten(), range=(0,1), bins=100) +plt.title('Histogram of fractions of first component') +plt.xlabel('Fraction of first component') +plt.ylabel('Counts') +plt.show() + +plt.figure() +plt.hist(fraction_from_second_component.flatten(), range=(0,1), bins=100) +plt.title('Histogram of fractions of second component') +plt.xlabel('Fraction of second component') +plt.ylabel('Counts') +plt.show() # %% diff --git a/src/phasorpy/_utils.py b/src/phasorpy/_utils.py index fc6b47ac..910d94e2 100644 --- a/src/phasorpy/_utils.py +++ b/src/phasorpy/_utils.py @@ -276,14 +276,21 @@ def project_phasor_to_line( """ real = numpy.copy(real) imag = numpy.copy(imag) - first_component_phasor = numpy.array([real_components[0], imaginary_components[0]]) - second_component_phasor = numpy.array([real_components[1], imaginary_components[1]]) + diff_dims = len(real.shape) - len(real_components[0].shape) + axis=tuple(range(len(real_components[0].shape), len(real_components[0].shape)+diff_dims)) + # Expand dimensions for the first component phasor + first_component_phasor = numpy.array([numpy.expand_dims(real_components[0], axis=axis), + numpy.expand_dims(imaginary_components[0], axis=axis)]) + + # Expand dimensions for the second component phasor + second_component_phasor = numpy.array([numpy.expand_dims(real_components[1], axis=axis), + numpy.expand_dims(imaginary_components[1], axis=axis)]) if numpy.all(first_component_phasor == second_component_phasor): raise ValueError('The two components must have different coordinates') - line_between_components = second_component_phasor.astype(float) - first_component_phasor.astype(float) - line_between_components /= numpy.linalg.norm(line_between_components) real -= first_component_phasor[0] imag -= first_component_phasor[1] + line_between_components = second_component_phasor.astype(float) - first_component_phasor.astype(float) + line_between_components /= numpy.linalg.norm(line_between_components) projection_lengths = ( real * line_between_components[0] + imag * line_between_components[1] ) diff --git a/src/phasorpy/components.py b/src/phasorpy/components.py index 3c6511fa..efe88675 100644 --- a/src/phasorpy/components.py +++ b/src/phasorpy/components.py @@ -7,25 +7,26 @@ from __future__ import annotations -__all__ = [ - 'fractional_intensities_from_phasor' - 'fractional_intensities_from_phasor_old' -] +__all__ = ['two_fractions_from_phasor'] from typing import TYPE_CHECKING if TYPE_CHECKING: from ._typing import Any, ArrayLike, NDArray -import numpy import math +import numpy + from ._utils import project_phasor_to_line -from .phasor import polar_from_reference, phasor_to_polar -def fractional_intensities_from_phasor( - real: ArrayLike, imag: ArrayLike, real_components: ArrayLike, imaginary_components: ArrayLike, /, +def two_fractions_from_phasor( + real: ArrayLike, + imag: ArrayLike, + real_components: ArrayLike, + imag_components: ArrayLike, + /, ) -> tuple[NDArray[Any], NDArray[Any]]: """Return fractions of two components from phasor coordinates. @@ -36,9 +37,9 @@ def fractional_intensities_from_phasor( imag : array_like Imaginary component of phasor coordinates. real_components: array_like - Real coordinates of the components. - imaginary_components: array_like - Imaginary coordinates of the components. + Real coordinates of the pair of components. + imag_components: array_like + Imaginary coordinates of the pair of components. Returns ------- @@ -48,71 +49,34 @@ def fractional_intensities_from_phasor( Raises ------ ValueError - The `signal` has less than three samples along `axis`. - + If the real and/or imaginary coordinates of the known components are not of size 2. + If the + Examples -------- - >>> fractional_intensities_from_phasor( + >>> two_fractions_from_phasor( ... [0.6, 0.5, 0.4], [0.4, 0.3, 0.2], [0.2, 0.4], [0.9, 0.3] ... ) # doctest: +NUMBER (...) """ + real_components = numpy.asarray(real_components) + imag_components = numpy.asarray(imag_components) + if real_components.size < 2: + raise ValueError(f'{real_components.size=} must have at least two coordinates') + if imag_components.size < 2: + raise ValueError(f'{imag_components.size=} must have at least two coordinates') + if real_components.all == imag_components.all: + raise ValueError('components must have different coordinates') projected_real, projected_imag = project_phasor_to_line( - real, imag, real_components, imaginary_components + real, imag, real_components, imag_components ) - components_coordinates = numpy.array([real_components, imaginary_components]) - projected_real = numpy.atleast_1d(projected_real) - projected_imag = numpy.atleast_1d(projected_imag) - fractions = [] - for element in zip(projected_real,projected_imag): - row_fractions = numpy.linalg.solve(components_coordinates, element) - fractions.append(row_fractions) - fractions = numpy.stack(fractions, axis=-1) - return fractions - - - - -def fractional_intensities_from_phasor_old( - real: ArrayLike, imag: ArrayLike, real_components: ArrayLike, imaginary_components: ArrayLike, /, -) -> tuple[NDArray[Any], NDArray[Any]]: - """Return fractions of two components from phasor coordinates. - - Parameters - ---------- - real : array_like - Real component of phasor coordinates. - imag : array_like - Imaginary component of phasor coordinates. - real_components: array_like - Real coordinates of the components. - imaginary_components: array_like - Imaginary coordinates of the components. - - Returns - ------- - fractions : ndarray - Fractions for all components. - - Raises - ------ - ValueError - The `signal` has less than three samples along `axis`. - - Examples - -------- - >>> fractional_intensities_from_phasor( - ... [0.6, 0.5, 0.4], [0.4, 0.3, 0.2], [0.2, 0.4], [0.9, 0.3] - ... ) # doctest: +NUMBER - (...) - - """ - projected_real, projected_imag = project_phasor_to_line( - real, imag, real_components, imaginary_components + first_component_phasor = numpy.array( + [real_components[0], imag_components[0]] + ) + second_component_phasor = numpy.array( + [real_components[1], imag_components[1]] ) - first_component_phasor = numpy.array([real_components[0], imaginary_components[0]]) - second_component_phasor = numpy.array([real_components[1], imaginary_components[1]]) total_distance_between_components = math.sqrt( (second_component_phasor[0] - first_component_phasor[0]) ** 2 + (second_component_phasor[1] - first_component_phasor[1]) ** 2 @@ -124,64 +88,7 @@ def fractional_intensities_from_phasor_old( fraction_of_second_component = ( distances_to_first_component / total_distance_between_components ) - fraction_of_second_component = numpy.clip(fraction_of_second_component, 0 , 1) + fraction_of_second_component = numpy.clip( + fraction_of_second_component, 0, 1 + ) return 1 - fraction_of_second_component, fraction_of_second_component - -def fractional_intensities_polar( - real: ArrayLike, imag: ArrayLike, real_components: ArrayLike, imaginary_components: ArrayLike, /, -) -> tuple[NDArray[Any], NDArray[Any]]: - """Return fractions of two components from phasor coordinates. - - Parameters - ---------- - real : array_like - Real component of phasor coordinates. - imag : array_like - Imaginary component of phasor coordinates. - real_components: array_like - Real coordinates of the components. - imaginary_components: array_like - Imaginary coordinates of the components. - - Returns - ------- - fractions : ndarray - Fractions for all components. - - Raises - ------ - ValueError - The `signal` has less than three samples along `axis`. - - Examples - -------- - >>> fractional_intensities_from_phasor( - ... [0.6, 0.5, 0.4], [0.4, 0.3, 0.2], [0.2, 0.4], [0.9, 0.3] - ... ) # doctest: +NUMBER - (...) - - """ - # projected_real, projected_imag = project_phasor_to_line( - # real, imag, real_components, imaginary_components - # ) - phase, mod = phasor_to_polar(real, imag) - first_component = phasor_to_polar(real_components[0], imaginary_components[0]) - second_component = phasor_to_polar(real_components[1], imaginary_components[1]) - # phi0_first_component, mod0_first_component = polar_from_reference(phase, mod, *first_component) - # phi0_second_component, mod0_second_component = polar_from_reference(phase, mod, *second_component) - # Reshape single values to arrays with one column - # first_component = numpy.array([first_component[0]]).reshape(-1, 1) - # second_component = numpy.array([second_component[0]]).reshape(-1, 1) - phase = numpy.array([phase]).reshape(-1, 1) - mod = numpy.array([mod]).reshape(-1, 1) - # Create coefficient matrix - # A = numpy.concatenate((first_component, second_component), axis=1) - # Augment coefficient matrix with the equation weight1 + weight2 = 1 - A = numpy.vstack([first_component, second_component, [1, 1]]) - - # Create phase vector - b = numpy.concatenate((phase, mod, numpy.array([[1]]))) - - # Solve the augmented system of equations - fractions = numpy.linalg.solve(A, b) - return fractions \ No newline at end of file diff --git a/src/phasorpy/plot.py b/src/phasorpy/plot.py index 045fdb3d..71406e19 100644 --- a/src/phasorpy/plot.py +++ b/src/phasorpy/plot.py @@ -1168,41 +1168,6 @@ def plot_polar_frequency( if show: pyplot.show() -def components_histogram( - real: ArrayLike, imag: ArrayLike, first_component_phasor: ArrayLike, second_component_phasor: ArrayLike, show: bool = True, **kwargs: Any, -) -> tuple: - """ - Creates a histogram of distribution of points along the line between two components. - - Parameters: - real (array): Array of x coordinates. - imag (array): Array of y coordinates. - first_component_phasor (tuple): Phasor coordinates of first component. - second_component_phasor (tuple): Phasorcoordinates of second component. - bins (int): Number of bins for the histogram. - - Returns: - tuple: Histogram values and bin edges. - - """ - projected_real, projected_imag = project_phasor_to_line( - real, imag, first_component_phasor, second_component_phasor - ) - distances_from_first_component = numpy.sqrt( - (projected_real - first_component_phasor[0]) ** 2 - + (projected_imag - first_component_phasor[1]) ** 2 - ) - bins = kwargs.get('bins', 128) - histogram_values, bin_edges, _ = pyplot.hist( - distances_from_first_component, bins=bins - ) - pyplot.xlabel('Distance from first component') - pyplot.ylabel('Frequency') - pyplot.title('Distribution along components') - pyplot.show() - return histogram_values, bin_edges - - def _imshow( ax: Axes, image: NDArray[Any], diff --git a/tutorials/phasorpy_components.py b/tutorials/phasorpy_components.py index d6d155bc..b97dedf1 100644 --- a/tutorials/phasorpy_components.py +++ b/tutorials/phasorpy_components.py @@ -2,137 +2,100 @@ Component analysis =========== -An introduction to component analysis in the phasor space. - - +An introduction to component analysis in the phasor space. The +:py:func:`phasorpy.phasor.phasor_from_lifetime` function is used to calculate +phasor coordinates as a function of frequency, single or multiple lifetime +components, and the pre-exponential amplitudes or fractional intensities of the +components. """ # %% # Import required modules, functions, and classes: -import math - +import matplotlib.pyplot as plt import numpy from phasorpy.components import two_fractions_from_phasor -from phasorpy.plot import PhasorPlot, components_histogram +from phasorpy.phasor import phasor_from_lifetime +from phasorpy.plot import PhasorPlot # %% -# Component mixtures +# Fractions of combination of two components # ------------------ # -# Show linear combinations of phasor coordinates or ranges thereof: +# A phasor that lies in the line between two components with 0.25 contribution +# of the first components and 0.75 contribution of the second component: -real, imag, weights = numpy.array( - [[0.1, 0.2, 0.5, 0.9], [0.3, 0.4, 0.5, 0.3], [2, 1, 2, 1]] +frequency = 80.0 +components_lifetimes = [8.0, 1.0] +real, imag = phasor_from_lifetime( + frequency, components_lifetimes, [0.25, 0.75] ) - -plot = PhasorPlot(frequency=80.0, title='Component mixtures') -plot.components(real, imag, linestyle='', fill=True, facecolor='lightyellow') -plot.components(real, imag, weights) -plot.show() - -# %% -# 2D Histogram -# ------------ -# -# Plot large number of phasor coordinates as a 2D histogram: - -real, imag = numpy.random.multivariate_normal( - (0.6, 0.4), [[3e-3, -1e-3], [-1e-3, 1e-3]], (256, 256) -).T -plot = PhasorPlot(frequency=80.0, title='2D Histogram') -plot.hist2d(real, imag) +components_real, components_imag = phasor_from_lifetime( + frequency, components_lifetimes +) +plot = PhasorPlot( + frequency=frequency, title='Phasor lying on the line between components' +) +plot.plot(components_real, components_imag, fmt='o-') +plot.plot(real, imag) plot.show() # %% -# Contours -# -------- -# -# Plot the contours of the density of phasor coordinates: -plot = PhasorPlot(frequency=80.0, title='Contours') -plot.contour(real, imag) -plot.show() - - -# %% -# Image -# ----- -# -# Plot the image of a custom-colored 2D histogram: +# If we know the location of both components, we can compute the contribution +# of both components to the phasor point that lies in the line between the two +# components: -plot = PhasorPlot(frequency=80.0, title='Image (not implemented yet)') -# plot.imshow(image) -plot.show() +( + fraction_of_first_component, + fraction_of_second_component, +) = two_fractions_from_phasor(real, imag, components_real, components_imag) +print('Fraction of first component: ', fraction_of_first_component) +print('Fraction of second component: ', fraction_of_second_component) # %% -# Combined plots -# -------------- +# Contribution of two known components in multiple phasors +# ------------------ # -# Multiple plots can be combined: +# Phasors can have different contributions of two components with known phasor +# coordinates: -real2, imag2 = numpy.random.multivariate_normal( - (0.9, 0.2), [[2e-4, -1e-4], [-1e-4, 2e-4]], 4096 +real, imag = numpy.random.multivariate_normal( + (0.6, 0.35), [[8e-3, 1e-3], [1e-3, 1e-3]], (100, 100) ).T - plot = PhasorPlot( - title='Combined plots', xlim=(0.35, 1.03), ylim=(0.1, 0.59), grid=False + frequency=frequency, + title='Phasor with contibution of two known components', ) -plot.hist2d(real, imag, bins=64, cmap='Blues') -plot.contour(real, imag, bins=48, levels=3, cmap='summer_r', norm='log') -plot.hist2d(real2, imag2, bins=64, cmap='Oranges') -plot.plot(0.6, 0.4, '.', color='tab:blue') -plot.plot(0.9, 0.2, '.', color='tab:orange') -plot.polar_cursor(math.atan(0.4 / 0.6), math.hypot(0.6, 0.4), color='tab:blue') -plot.polar_cursor( - math.atan(0.2 / 0.9), math.hypot(0.9, 0.2), color='tab:orange' -) -plot.semicircle(frequency=80.0, color='tab:purple') +plot.hist2d(real, imag, cmap='plasma') +plot.plot(*phasor_from_lifetime(frequency, components_lifetimes), fmt='o-') plot.show() # %% -# All quadrants -# ------------- -# -# Create an empty phasor plot showing all four quadrants: - -plot = PhasorPlot(allquadrants=True, title='All quadrants') -plot.show() - -# %% -# Matplotlib axes -# --------------- -# -# The PhasorPlot class can use an existing matlotlib axes. -# The `PhasorPlot.ax` attribute provides access to the underlying -# matplotlib axes, for example, to add annotations: - -from matplotlib import pyplot - -ax = pyplot.subplot(1, 1, 1) -plot = PhasorPlot(ax=ax, allquadrants=True, title='Matplotlib axes') -plot.hist2d(real, imag, cmap='Blues') -plot.ax.annotate( - '0.6, 0.4', - xy=(0.6, 0.4), - xytext=(0.2, 0.2), - arrowprops=dict(arrowstyle='->'), -) -pyplot.show() - - -# %% -# plot_phasor function -# -------------------- -# -# The :py:func:`phasorpy.plot.plot_phasor` function provides a simpler -# alternative to plot phasor coordinates in a single statement: - -from phasorpy.plot import plot_phasor - -plot_phasor(real[0, :32], imag[0, :32], fmt='.', frequency=80.0) +# If we know the phasor coordinates of two components that contribute to +# multiple phasors, we can compute the contribution of both components for each +# phasor and plot the distributions: + +( + fraction_from_first_component, + fraction_from_second_component, +) = two_fractions_from_phasor(real, imag, components_real, components_imag) + +plt.figure() +plt.hist(fraction_from_first_component.flatten(), range=(0, 1), bins=100) +plt.title('Histogram of fractions of first component') +plt.xlabel('Fraction of first component') +plt.ylabel('Counts') +plt.show() + +plt.figure() +plt.hist(fraction_from_second_component.flatten(), range=(0, 1), bins=100) +plt.title('Histogram of fractions of second component') +plt.xlabel('Fraction of second component') +plt.ylabel('Counts') +plt.show() # %% -# sphinx_gallery_thumbnail_number = 9 +# sphinx_gallery_thumbnail_number = 2 From 6153e99cdd7b1a6799aaab4d9511cd479ad6be9b Mon Sep 17 00:00:00 2001 From: bruno-pannunzio Date: Mon, 22 Apr 2024 18:19:26 -0300 Subject: [PATCH 09/21] Update to `project_phasor_to_line` and add tests for `project_phasor_to_line` and `two_fractions_from_phasor` --- src/phasorpy/_utils.py | 64 ++++++++++++++++++++++++-------------- src/phasorpy/components.py | 34 ++++++++++---------- tests/test__utils.py | 28 +++++++++++++++++ tests/test_components.py | 25 +++++++++++++++ 4 files changed, 110 insertions(+), 41 deletions(-) create mode 100644 tests/test_components.py diff --git a/src/phasorpy/_utils.py b/src/phasorpy/_utils.py index 910d94e2..c70c938f 100644 --- a/src/phasorpy/_utils.py +++ b/src/phasorpy/_utils.py @@ -264,39 +264,55 @@ def circle_line_intersection( def project_phasor_to_line( - real: ArrayLike, imag: ArrayLike, real_components: ArrayLike, imaginary_components: ArrayLike, / + real: ArrayLike, + imag: ArrayLike, + real_components: ArrayLike, + imag_components: ArrayLike, + /, + *, + clip: bool = True, + axis: int = -1, ) -> tuple[NDArray, NDArray]: """Return projected phasor coordinates to the line that joins two phasors. + By default, the points are clipped to the line segment between components + and the axis into which project the phasor can also be selected. + >>> project_phasor_to_line( - ... [0.6, 0.5, 0.4], [0.4, 0.3, 0.2], [0.2, 0.4], [0.9, 0.3] + ... [0.6, 0.5, 0.4], [0.4, 0.3, 0.2], [0.2, 0.9], [0.4, 0.3] ... ) # doctest: +NUMBER (array([0.592, 0.508, 0.424]), array([0.344, 0.356, 0.368])) """ real = numpy.copy(real) imag = numpy.copy(imag) - diff_dims = len(real.shape) - len(real_components[0].shape) - axis=tuple(range(len(real_components[0].shape), len(real_components[0].shape)+diff_dims)) - # Expand dimensions for the first component phasor - first_component_phasor = numpy.array([numpy.expand_dims(real_components[0], axis=axis), - numpy.expand_dims(imaginary_components[0], axis=axis)]) - - # Expand dimensions for the second component phasor - second_component_phasor = numpy.array([numpy.expand_dims(real_components[1], axis=axis), - numpy.expand_dims(imaginary_components[1], axis=axis)]) - if numpy.all(first_component_phasor == second_component_phasor): - raise ValueError('The two components must have different coordinates') - real -= first_component_phasor[0] - imag -= first_component_phasor[1] - line_between_components = second_component_phasor.astype(float) - first_component_phasor.astype(float) - line_between_components /= numpy.linalg.norm(line_between_components) - projection_lengths = ( - real * line_between_components[0] + imag * line_between_components[1] + real_components = numpy.asarray(real_components) + imag_components = numpy.asarray(imag_components) + if real_components.size != 2: + raise ValueError(f'{real_components.size=} must have two coordinates') + if imag_components.size != 2: + raise ValueError(f'{imag_components.size=} must have two coordinates') + if numpy.all(real_components == imag_components): + raise ValueError('components must have different coordinates') + first_component_phasor = numpy.array( + [real_components[0], imag_components[0]] + ) + second_component_phasor = numpy.array( + [real_components[1], imag_components[1]] + ) + line_vector = second_component_phasor - first_component_phasor + line_length = numpy.linalg.norm(line_vector) + line_direction = line_vector / line_length + projected_points = ( + numpy.stack((real, imag), axis=axis) - first_component_phasor ) - projected_points = first_component_phasor + numpy.outer( - projection_lengths, line_between_components + projection_lengths = numpy.dot(projected_points, line_direction) + if clip: + projection_lengths = numpy.clip(projection_lengths, 0, line_length) + projected_points = ( + first_component_phasor + + numpy.expand_dims(projection_lengths, axis=axis) * line_direction ) - projected_points_real = projected_points[:, 0].reshape(real.shape) - projected_points_imag = projected_points[:, 1].reshape(imag.shape) - return numpy.asarray(projected_points_real), numpy.asarray(projected_points_imag) + projected_points_real = projected_points[..., 0] + projected_points_imag = projected_points[..., 1] + return projected_points_real, projected_points_imag diff --git a/src/phasorpy/components.py b/src/phasorpy/components.py index efe88675..26121a93 100644 --- a/src/phasorpy/components.py +++ b/src/phasorpy/components.py @@ -37,36 +37,39 @@ def two_fractions_from_phasor( imag : array_like Imaginary component of phasor coordinates. real_components: array_like - Real coordinates of the pair of components. + Real coordinates of the first and second components. imag_components: array_like - Imaginary coordinates of the pair of components. + Imaginary coordinates of the first and second components. Returns ------- - fractions : ndarray - Fractions for all components. + fraction_of_first_component : ndarray + Fractions of the first component. + fraction_of_second_component : ndarray + Fractions of the second component. Raises ------ ValueError - If the real and/or imaginary coordinates of the known components are not of size 2. - If the - + If the real and/or imaginary coordinates of the known components are + not of size 2. + If the two components have the same coordinates. + Examples -------- >>> two_fractions_from_phasor( - ... [0.6, 0.5, 0.4], [0.4, 0.3, 0.2], [0.2, 0.4], [0.9, 0.3] + ... [0.6, 0.5, 0.4], [0.4, 0.3, 0.2], [0.2, 0.9], [0.4, 0.3] ... ) # doctest: +NUMBER - (...) + (array([0.44, 0.56, 0.68]), array([0.56, 0.44, 0.32])) """ real_components = numpy.asarray(real_components) imag_components = numpy.asarray(imag_components) - if real_components.size < 2: - raise ValueError(f'{real_components.size=} must have at least two coordinates') - if imag_components.size < 2: - raise ValueError(f'{imag_components.size=} must have at least two coordinates') - if real_components.all == imag_components.all: + if real_components.size != 2: + raise ValueError(f'{real_components.size=} must have two coordinates') + if imag_components.size != 2: + raise ValueError(f'{imag_components.size=} must have two coordinates') + if numpy.all(real_components == imag_components): raise ValueError('components must have different coordinates') projected_real, projected_imag = project_phasor_to_line( real, imag, real_components, imag_components @@ -88,7 +91,4 @@ def two_fractions_from_phasor( fraction_of_second_component = ( distances_to_first_component / total_distance_between_components ) - fraction_of_second_component = numpy.clip( - fraction_of_second_component, 0, 1 - ) return 1 - fraction_of_second_component, fraction_of_second_component diff --git a/tests/test__utils.py b/tests/test__utils.py index 0287eccf..27d9ce16 100644 --- a/tests/test__utils.py +++ b/tests/test__utils.py @@ -3,6 +3,7 @@ import math import pytest +import numpy from numpy.testing import assert_allclose from phasorpy._utils import ( @@ -15,6 +16,7 @@ scale_matrix, sort_coordinates, update_kwargs, + project_phasor_to_line, ) @@ -132,3 +134,29 @@ def test_circle_circle_intersection(): 1e-3, ) assert not circle_line_intersection(0.6, 0.4, 0.2, 0.0, 0.0, 0.6, 0.1) + + +def test_project_phasor_to_line(): + """Test project_phasor_to_line function.""" + assert_allclose( + project_phasor_to_line([0.7, 0.5, 0.3],[0.3, 0.4, 0.3],[0.2, 0.9],[0.4, 0.3]), + (numpy.array([0.704, 0.494, 0.312]), numpy.array([0.328, 0.358, 0.384])) + ) + assert_allclose( + project_phasor_to_line([0.1, 1.0],[0.5, 0.5],[0.2, 0.9],[0.4, 0.3]), + (numpy.array([0.2, 0.9]), numpy.array([0.4, 0.3])) + ) + assert_allclose( + project_phasor_to_line([0.1, 1.0],[0.5, 0.5],[0.2, 0.9],[0.4, 0.3], clip=False), + (numpy.array([0.088, 0.97 ]), numpy.array([0.416, 0.29 ])) + ) + with pytest.raises(ValueError): + project_phasor_to_line([0],[0],[0.1, 0.2],[0.1, 0.2]) + with pytest.raises(ValueError): + project_phasor_to_line([0],[0],[0.3],[0.1, 0.2]) + with pytest.raises(ValueError): + project_phasor_to_line([0],[0],[0.1, 0.2],[0.3]) + with pytest.raises(ValueError): + project_phasor_to_line([0],[0],[0.1],[0.3]) + with pytest.raises(ValueError): + project_phasor_to_line([0],[0],[0.1, 0.1, 0,1],[0.1, 0,2]) \ No newline at end of file diff --git a/tests/test_components.py b/tests/test_components.py new file mode 100644 index 00000000..e0b5d95d --- /dev/null +++ b/tests/test_components.py @@ -0,0 +1,25 @@ +"""Tests for the phasorpy.components module.""" + +import numpy +import pytest +from numpy.testing import ( + assert_allclose, +) +from phasorpy.components import ( + two_fractions_from_phasor, +) + +def test_two_fractions_from_phasor(): + """Test two_fractions_from_phasor function.""" + assert_allclose(two_fractions_from_phasor([0.2, 0.5, 0.7],[0.2, 0.4, 0.3],[0.0582399, 0.79830002],[0.23419652, 0.40126936]), (numpy.array([0.82766281, 0.38389704, 0.15577992]), numpy.array([0.17233719, 0.61610296, 0.84422008]))) + assert_allclose(two_fractions_from_phasor([0.0, 0.5, 0.9],[0.4, 0.4, 0.6],[0.0582399, 0.79830002],[0.23419652, 0.40126936]), (numpy.array([1., 0.38389704, 0.]), numpy.array([0., 0.61610296, 1.]))) + with pytest.raises(ValueError): + two_fractions_from_phasor([0],[0],[0.1, 0.2],[0.1, 0.2]) + with pytest.raises(ValueError): + two_fractions_from_phasor([0],[0],[0.3],[0.1, 0.2]) + with pytest.raises(ValueError): + two_fractions_from_phasor([0],[0],[0.1, 0.2],[0.3]) + with pytest.raises(ValueError): + two_fractions_from_phasor([0],[0],[0.1],[0.3]) + with pytest.raises(ValueError): + two_fractions_from_phasor([0],[0],[0.1, 0.1, 0,1],[0.1, 0,2]) \ No newline at end of file From 4f472d18f6b67be1a2cccc855baf2dfbd02947ce Mon Sep 17 00:00:00 2001 From: bruno-pannunzio Date: Mon, 22 Apr 2024 18:22:58 -0300 Subject: [PATCH 10/21] Discard `plot` changes --- src/phasorpy/plot.py | 1896 ------------------------------------------ 1 file changed, 1896 deletions(-) delete mode 100644 src/phasorpy/plot.py diff --git a/src/phasorpy/plot.py b/src/phasorpy/plot.py deleted file mode 100644 index e55814fa..00000000 --- a/src/phasorpy/plot.py +++ /dev/null @@ -1,1896 +0,0 @@ -"""Plot phasor coordinates and related data. - -The ``phasorpy.plot`` module provides functions and classes to visualize -phasor coordinates and related data using the matplotlib library. - -""" - -from __future__ import annotations - -__all__ = [ - 'PhasorPlot', - 'PhasorPlotFret', - 'plot_phasor', - 'plot_phasor_image', - 'plot_signal_image', - 'plot_polar_frequency', - 'two_components_histogram', -] - -import math -import os -from collections.abc import Sequence -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from ._typing import Any, ArrayLike, NDArray, Literal, BinaryIO - - from matplotlib.axes import Axes - from matplotlib.image import AxesImage - from matplotlib.figure import Figure - -import numpy -from matplotlib import pyplot -from matplotlib.font_manager import FontProperties -from matplotlib.gridspec import GridSpec -from matplotlib.lines import Line2D -from matplotlib.patches import Arc, Circle, Polygon -from matplotlib.path import Path -from matplotlib.patheffects import AbstractPathEffect -from matplotlib.widgets import Slider - -from ._utils import ( - circle_circle_intersection, - circle_line_intersection, - parse_kwargs, - phasor_from_polar_scalar, - phasor_to_polar_scalar, - project_phasor_to_line, - sort_coordinates, - update_kwargs, -) -from .phasor import ( - phasor_from_fret_acceptor, - phasor_from_fret_donor, - phasor_from_lifetime, - phasor_semicircle, - phasor_to_apparent_lifetime, - phasor_to_polar, - phasor_transform, -) - -GRID_COLOR = '0.5' -GRID_LINESTYLE = ':' -GRID_LINESTYLE_MAJOR = '-' -GRID_LINEWIDH = 1.0 -GRID_LINEWIDH_MINOR = 0.5 -GRID_FILL = False - - -class PhasorPlot: - """Phasor plot. - - Create publication quality visualizations of phasor coordinates. - - Parameters - ---------- - allquadrants : bool, optional - Show all quandrants of phasor space. - By default, only the first quadrant with universal semicircle is shown. - ax : matplotlib axes, optional - Matplotlib axes used for plotting. - By default, a new subplot axes is created. - frequency : float, optional - Laser pulse or modulation frequency in MHz. - grid : bool, optional, default: True - Display polar grid or semicircle. - **kwargs - Additional properties to set on `ax`. - - See Also - -------- - phasorpy.plot.plot_phasor - :ref:`sphx_glr_tutorials_phasorpy_phasorplot.py` - - """ - - _ax: Axes - """Matplotlib axes.""" - - _limits: tuple[tuple[float, float], tuple[float, float]] - """Axes limits (xmin, xmax), (ymin, ymax).""" - - _full: bool - """Show all quadrants of phasor space.""" - - _lines: list[Line2D] - """Last lines created.""" - - _semicircle_ticks: SemicircleTicks | None - """Last SemicircleTicks instance created.""" - - _frequency: float - """Laser pulse or modulation frequency in MHz.""" - - def __init__( - self, - /, - allquadrants: bool | None = None, - ax: Axes | None = None, - *, - frequency: float | None = None, - grid: bool = True, - **kwargs, - ) -> None: - # initialize empty phasor plot - self._ax = pyplot.subplots()[1] if ax is None else ax - self._ax.format_coord = self._on_format_coord # type: ignore - - self._lines = [] - self._semicircle_ticks = None - - self._full = bool(allquadrants) - if self._full: - xlim = (-1.05, 1.05) - ylim = (-1.05, 1.05) - xticks: tuple[float, ...] = (-1.0, -0.5, 0.0, 0.5, 1.0) - yticks: tuple[float, ...] = (-1.0, -0.5, 0.0, 0.5, 1.0) - if grid: - self.polar_grid() - else: - xlim = (-0.05, 1.05) - ylim = (-0.05, 0.7) - xticks = (0.0, 0.2, 0.4, 0.6, 0.8, 1.0) - yticks = (0.0, 0.2, 0.4, 0.6) - if grid: - self.semicircle(frequency=frequency) - - title = 'Phasor plot' - if frequency is not None: - self._frequency = float(frequency) - title += f' ({frequency:g} MHz)' - else: - self._frequency = 0.0 - - update_kwargs( - kwargs, - title=title, - xlabel='G, real', - ylabel='S, imag', - aspect='equal', - xlim=xlim, - ylim=ylim, - xticks=xticks, - yticks=yticks, - ) - self._limits = (kwargs['xlim'], kwargs['ylim']) - self._ax.set(**kwargs) - - @property - def ax(self) -> Axes: - """Matplotlib :py:class:`matplotlib.axes.Axes`.""" - return self._ax - - @property - def fig(self) -> Figure | None: - """Matplotlib :py:class:`matplotlib.figure.Figure`.""" - return self._ax.get_figure() - - def show(self) -> None: - """Display all open figures. Call :py:func:`matplotlib.pyplot.show`.""" - # self.fig.show() - pyplot.show() - - def save( - self, - file: str | os.PathLike[Any] | BinaryIO | None, - /, - **kwargs: Any, - ) -> None: - """Save current figure to file. - - Parameters - ---------- - file : str, path-like, or binary file-like - Path or Python file-like object to write the current figure to. - **kwargs - Additional keyword arguments passed to - :py:func:`matplotlib:pyplot.savefig`. - - """ - pyplot.savefig(file, **kwargs) - - def plot( - self, - real: ArrayLike, - imag: ArrayLike, - /, - fmt='o', - *, - label: str | Sequence[str] | None = None, - **kwargs: Any, - ) -> None: - """Plot imag versus real coordinates as markers and/or lines. - - Parameters - ---------- - real : array_like - Real component of phasor coordinates. - Must be one or two dimensional. - imag : array_like - Imaginary component of phasor coordinates. - Must be of same shape as `real`. - fmt : str, optional, default: 'o' - Matplotlib style format string. - label : str or sequence of str, optional - Plot label. - May be a sequence if phasor coordinates are two dimensional arrays. - **kwargs - Additional parameters passed to - :py:meth:`matplotlib.axes.Axes.plot`. - - """ - ax = self._ax - if label is not None and ( - isinstance(label, str) or not isinstance(label, Sequence) - ): - label = (label,) - for ( - i, - (re, im), - ) in enumerate( - zip(numpy.array(real, ndmin=2), numpy.array(imag, ndmin=2)) - ): - lbl = None - if label is not None: - try: - lbl = label[i] - except IndexError: - pass - self._lines = ax.plot(re, im, fmt, label=lbl, **kwargs) - if label is not None: - ax.legend() - self._reset_limits() - - def _histogram2d( - self, - real: ArrayLike, - imag: ArrayLike, - /, - **kwargs: Any, - ) -> tuple[NDArray[Any], NDArray[Any], NDArray[Any]]: - """Return 2D histogram of imag versus real coordinates.""" - update_kwargs(kwargs, range=self._limits) - (xmin, xmax), (ymin, ymax) = kwargs['range'] - assert xmax > xmin and ymax > ymin - bins = kwargs.get('bins', 128) - if isinstance(bins, int): - assert bins > 0 - aspect = (xmax - xmin) / (ymax - ymin) - if aspect > 1: - bins = (bins, max(int(bins / aspect), 1)) - else: - bins = (max(int(bins * aspect), 1), bins) - kwargs['bins'] = bins - return numpy.histogram2d( - numpy.asanyarray(real).reshape(-1), - numpy.asanyarray(imag).reshape(-1), - **kwargs, - ) - - def _reset_limits(self) -> None: - """Reset axes limits.""" - try: - self._ax.set(xlim=self._limits[0], ylim=self._limits[1]) - except AttributeError: - pass - - def hist2d( - self, - real: ArrayLike, - imag: ArrayLike, - /, - **kwargs: Any, - ) -> None: - """Plot 2D histogram of imag versus real coordinates. - - Parameters - ---------- - real : array_like - Real component of phasor coordinates. - imag : array_like - Imaginary component of phasor coordinates. - Must be of same shape as `real`. - **kwargs - Additional parameters passed to :py:meth:`numpy.histogram2d` - and :py:meth:`matplotlib.axes.Axes.pcolormesh`. - - """ - kwargs_hist2d = parse_kwargs( - kwargs, 'bins', 'range', 'density', 'weights' - ) - h, xedges, yedges = self._histogram2d(real, imag, **kwargs_hist2d) - - update_kwargs(kwargs, cmap='Blues', norm='log') - cmin = kwargs.pop('cmin', 1) - cmax = kwargs.pop('cmax', None) - if cmin is not None: - h[h < cmin] = None - if cmax is not None: - h[h > cmax] = None - self._ax.pcolormesh(xedges, yedges, h.T, **kwargs) - self._reset_limits() - - def contour( - self, - real: ArrayLike, - imag: ArrayLike, - /, - **kwargs: Any, - ) -> None: - """Plot contours of imag versus real coordinates (not implemented). - - Parameters - ---------- - real : array_like - Real component of phasor coordinates. - imag : array_like - Imaginary component of phasor coordinates. - Must be of same shape as `real`. - **kwargs - Additional parameters passed to :py:func:`numpy.histogram2d` - and :py:meth:`matplotlib.axes.Axes.contour`. - - """ - update_kwargs(kwargs, cmap='Blues', norm='log') - kwargs_hist2d = parse_kwargs( - kwargs, 'bins', 'range', 'density', 'weights' - ) - h, xedges, yedges = self._histogram2d(real, imag, **kwargs_hist2d) - xedges = xedges[:-1] + ((xedges[1] - xedges[0]) / 2.0) - yedges = yedges[:-1] + ((yedges[1] - yedges[0]) / 2.0) - self._ax.contour(xedges, yedges, h.T, **kwargs) - self._reset_limits() - - def imshow( - self, - image: ArrayLike, - /, - **kwargs: Any, - ) -> None: - """Plot an image, for example, a 2D histogram (not implemented). - - Parameters - ---------- - image : array_like - Image to display. - **kwargs - Additional parameters passed to - :py:meth:`matplotlib.axes.Axes.imshow`. - - """ - raise NotImplementedError - - def components( - self, - real: ArrayLike, - imag: ArrayLike, - /, - fraction: ArrayLike | None = None, - **kwargs: Any, - ) -> None: - """Plot linear combinations of phasor coordinates or ranges thereof. - - Parameters - ---------- - real : (N,) array_like - Real component of phasor coordinates. - imag : (N,) array_like - Imaginary component of phasor coordinates. - fraction : (N,) array_like, optional - Weight associated with each component. - If None (default), outline the polygon area of possible linear - combinations of components. - Else, draw lines from the component coordinates to the weighted - average. - **kwargs - Additional parameters passed to - :py:class:`matplotlib.patches.Polygon` or - :py:class:`matplotlib.lines.Line2D`. - - """ - real = numpy.asanyarray(real) - imag = numpy.asanyarray(imag) - if real.ndim != 1 or real.shape != imag.shape: - raise ValueError(f'invalid {real.shape=} or {imag.shape=}') - if fraction is None: - update_kwargs( - kwargs, - edgecolor=GRID_COLOR, - linestyle=GRID_LINESTYLE, - linewidth=GRID_LINEWIDH, - fill=GRID_FILL, - ) - self._ax.add_patch( - Polygon(numpy.vstack(sort_coordinates(real, imag)).T, **kwargs) - ) - return - - update_kwargs( - kwargs, - color=GRID_COLOR, - linestyle=GRID_LINESTYLE, - linewidth=GRID_LINEWIDH, - ) - center_re, center_im = numpy.average( - numpy.vstack((real, imag)), axis=-1, weights=fraction - ) - for re, im in zip(real, imag): - self._ax.add_line( - Line2D([center_re, re], [center_im, im], **kwargs) - ) - # TODO: add fraction labels? - - def line( - self, - real: ArrayLike, - imag: ArrayLike, - /, - **kwargs: Any, - ) -> None: - """Draw grid line. - - Parameters - ---------- - real : array_like, shape (n, ) - Real components of line start and end coordinates. - imag : array_like, shape (n, ) - Imaginary components of line start and end coordinates. - **kwargs - Additional parameters passed to - :py:class:`matplotlib.lines.Line2D`. - - """ - update_kwargs( - kwargs, - color=GRID_COLOR, - linestyle=GRID_LINESTYLE, - linewidth=GRID_LINEWIDH, - ) - self._lines = [self._ax.add_line(Line2D(real, imag, **kwargs))] - - def circle( - self, - real: float, - imag: float, - /, - radius: float, - **kwargs: Any, - ) -> None: - """Draw grid circle of radius around center. - - Parameters - ---------- - real : float - Real component of circle center coordinate. - imag : float - Imaginary component of circle center coordinate. - radius : float - Circle radius. - **kwargs - Additional parameters passed to - :py:class:`matplotlib.patches.Circle`. - - """ - update_kwargs( - kwargs, - color=GRID_COLOR, - linestyle=GRID_LINESTYLE, - linewidth=GRID_LINEWIDH, - fill=GRID_FILL, - ) - self._ax.add_patch(Circle((real, imag), radius, **kwargs)) - - def cursor( - self, - real: float, - imag: float, - /, - real_limit: float | None = None, - imag_limit: float | None = None, - radius: float | None = None, - **kwargs: Any, - ) -> None: - """Plot phase and modulation grid lines and arcs at phasor coordinates. - - Parameters - ---------- - real : float - Real component of phasor coordinate. - imag : float - Imaginary component of phasor coordinate. - real_limit : float, optional - Real component of limiting phasor coordinate. - imag_limit : float, optional - Imaginary component of limiting phasor coordinate. - radius : float, optional - Radius of circle limiting phase and modulation grid lines and arcs. - **kwargs - Additional parameters passed to - :py:class:`matplotlib.lines.Line2D`, - :py:class:`matplotlib.patches.Circle`, and - :py:class:`matplotlib.patches.Arc`. - - See Also - -------- - phasorpy.plot.PhasorPlot.polar_cursor - - """ - if real_limit is not None and imag_limit is not None: - return self.polar_cursor( - *phasor_to_polar_scalar(real, imag), - *phasor_to_polar_scalar(real_limit, imag_limit), - radius=radius, - **kwargs, - ) - return self.polar_cursor( - *phasor_to_polar_scalar(real, imag), - radius=radius, - # _circle_only=True, - **kwargs, - ) - - def polar_cursor( - self, - phase: float | None = None, - modulation: float | None = None, - phase_limit: float | None = None, - modulation_limit: float | None = None, - radius: float | None = None, - **kwargs: Any, - ) -> None: - """Plot phase and modulation grid lines and arcs. - - Parameters - ---------- - phase : float, optional - Angular component of polar coordinate in radians. - modulation : float, optional - Radial component of polar coordinate. - phase_limit : float, optional - Angular component of limiting polar coordinate (in radians). - Modulation grid arcs are drawn between `phase` and `phase_limit`. - modulation_limit : float, optional - Radial component of limiting polar coordinate. - Phase grid lines are drawn from `modulation` to `modulation_limit`. - radius : float, optional - Radius of circle limiting phase and modulation grid lines and arcs. - **kwargs - Additional parameters passed to - :py:class:`matplotlib.lines.Line2D`, - :py:class:`matplotlib.patches.Circle`, and - :py:class:`matplotlib.patches.Arc`. - - See Also - -------- - phasorpy.plot.PhasorPlot.cursor - - """ - update_kwargs( - kwargs, - color=GRID_COLOR, - linestyle=GRID_LINESTYLE, - linewidth=GRID_LINEWIDH, - fill=GRID_FILL, - ) - _circle_only = kwargs.pop('_circle_only', False) - ax = self._ax - if radius is not None and phase is not None and modulation is not None: - x = modulation * math.cos(phase) - y = modulation * math.sin(phase) - ax.add_patch(Circle((x, y), radius, **kwargs)) - if _circle_only: - return - del kwargs['fill'] - p0, p1 = circle_line_intersection(x, y, radius, 0, 0, x, y) - ax.add_line(Line2D((p0[0], p1[0]), (p0[1], p1[1]), **kwargs)) - p0, p1 = circle_circle_intersection(0, 0, modulation, x, y, radius) - ax.add_patch( - Arc( - (0, 0), - modulation * 2, - modulation * 2, - theta1=math.degrees(math.atan2(p0[1], p0[0])), - theta2=math.degrees(math.atan2(p1[1], p1[0])), - fill=False, - **kwargs, - ) - ) - return - - del kwargs['fill'] - for phi in (phase, phase_limit): - if phi is not None: - if modulation is not None and modulation_limit is not None: - x0 = modulation * math.cos(phi) - y0 = modulation * math.sin(phi) - x1 = modulation_limit * math.cos(phi) - y1 = modulation_limit * math.sin(phi) - else: - x0 = 0 - y0 = 0 - x1 = math.cos(phi) - y1 = math.sin(phi) - ax.add_line(Line2D((x0, x1), (y0, y1), **kwargs)) - for mod in (modulation, modulation_limit): - if mod is not None: - if phase is not None and phase_limit is not None: - theta1 = math.degrees(min(phase, phase_limit)) - theta2 = math.degrees(max(phase, phase_limit)) - else: - theta1 = 0.0 - theta2 = 360.0 if self._full else 90.0 - ax.add_patch( - Arc( - (0, 0), - mod * 2, - mod * 2, - theta1=theta1, - theta2=theta2, - fill=False, # filling arc objects is not supported - **kwargs, - ) - ) - - def polar_grid(self, **kwargs) -> None: - """Draw polar coordinate system. - - Parameters - ---------- - **kwargs - Parameters passed to - :py:class:`matplotlib.patches.Circle` and - :py:class:`matplotlib.lines.Line2D`. - - """ - ax = self._ax - # major gridlines - kwargs_copy = kwargs.copy() - update_kwargs( - kwargs, - color=GRID_COLOR, - linestyle=GRID_LINESTYLE_MAJOR, - linewidth=GRID_LINEWIDH, - # fill=GRID_FILL, - ) - ax.add_line(Line2D([-1, 1], [0, 0], **kwargs)) - ax.add_line(Line2D([0, 0], [-1, 1], **kwargs)) - ax.add_patch(Circle((0, 0), 1, fill=False, **kwargs)) - # minor gridlines - kwargs = kwargs_copy - update_kwargs( - kwargs, - color=GRID_COLOR, - linestyle=GRID_LINESTYLE, - linewidth=GRID_LINEWIDH_MINOR, - ) - for r in (1 / 3, 2 / 3): - ax.add_patch(Circle((0, 0), r, fill=False, **kwargs)) - for a in (3, 6): - x = math.cos(math.pi / a) - y = math.sin(math.pi / a) - ax.add_line(Line2D([-x, x], [-y, y], **kwargs)) - ax.add_line(Line2D([-x, x], [y, -y], **kwargs)) - - def semicircle( - self, - frequency: float | None = None, - *, - polar_reference: tuple[float, float] | None = None, - phasor_reference: tuple[float, float] | None = None, - lifetime: Sequence[float] | None = None, - labels: Sequence[str] | None = None, - show_circle: bool = True, - use_lines: bool = False, - **kwargs, - ) -> None: - """Draw universal semicircle. - - Parameters - ---------- - frequency : float, optional - Laser pulse or modulation frequency in MHz. - polar_reference : (float, float), optional, default: (0, 1) - Polar coordinates of zero lifetime. - phasor_reference : (float, float), optional, default: (1, 0) - Phasor coordinates of zero lifetime. - Alternative to `polar_reference`. - lifetime : sequence of float, optional - Single component lifetimes at which to draw ticks and labels. - Only applies when `frequency` is specified. - labels : sequence of str, optional - Tick labels. By default, the values of `lifetime`. - Only applies when `frequency` and `lifetime` are specified. - show_circle : bool, optional, default: True - Draw universal semicircle. - use_lines : bool, optional, default: False - Draw universal semicircle using lines instead of arc. - **kwargs - Additional parameters passed to - :py:class:`matplotlib.lines.Line2D` or - :py:class:`matplotlib.patches.Arc` and - :py:meth:`matplotlib.axes.Axes.plot`. - - """ - if frequency is not None: - self._frequency = float(frequency) - - update_kwargs( - kwargs, - color=GRID_COLOR, - linestyle=GRID_LINESTYLE_MAJOR, - linewidth=GRID_LINEWIDH, - ) - if phasor_reference is not None: - polar_reference = phasor_to_polar_scalar(*phasor_reference) - if polar_reference is None: - polar_reference = (0.0, 1.0) - if phasor_reference is None: - phasor_reference = phasor_from_polar_scalar(*polar_reference) - ax = self._ax - - if show_circle: - if use_lines: - self._lines = [ - ax.add_line( - Line2D( - *phasor_transform( - *phasor_semicircle(), *polar_reference - ), - **kwargs, - ) - ) - ] - else: - ax.add_patch( - Arc( - (phasor_reference[0] / 2, phasor_reference[1] / 2), - polar_reference[1], - polar_reference[1], - theta1=math.degrees(polar_reference[0]), - theta2=math.degrees(polar_reference[0]) + 180.0, - fill=False, - **kwargs, - ) - ) - - if frequency is not None and polar_reference == (0.0, 1.0): - # draw ticks and labels - lifetime, labels = _semicircle_ticks(frequency, lifetime, labels) - self._semicircle_ticks = SemicircleTicks(labels=labels) - self._lines = ax.plot( - *phasor_transform( - *phasor_from_lifetime(frequency, lifetime), - *polar_reference, - ), - path_effects=[self._semicircle_ticks], - **kwargs, - ) - self._reset_limits() - - def _on_format_coord(self, x: float, y: float, /) -> str: - """Callback function to update coordinates displayed in toolbar.""" - phi, mod = phasor_to_polar_scalar(x, y) - ret = [ - f'[{x:4.2f}, {y:4.2f}]', - f'[{math.degrees(phi):.0f}°, {mod * 100:.0f}%]', - ] - if x > 0.0 and y > 0.0 and self._frequency > 0.0: - tp, tm = phasor_to_apparent_lifetime(x, y, self._frequency) - ret.append(f'[{tp:.2f}, {tm:.2f} ns]') - return ' '.join(reversed(ret)) - - -class PhasorPlotFret(PhasorPlot): - """FRET phasor plot. - - Plot Förster Resonance Energy Transfer efficiency trajectories - of donor and acceptor channels in phasor space. - - Parameters - ---------- - frequency : array_like - Laser pulse or modulation frequency in MHz. - donor_lifetime : array_like - Lifetime of donor without FRET in ns. - acceptor_lifetime : array_like - Lifetime of acceptor in ns. - fret_efficiency : array_like, optional, default 0 - FRET efficiency in range [0..1]. - donor_freting : array_like, optional, default 1 - Fraction of donors participating in FRET. Range [0..1]. - donor_bleedthrough : array_like, optional, default 0 - Weight of donor fluorescence in acceptor channel - relative to fluorescence of fully sensitized acceptor. - A weight of 1 means the fluorescence from donor and fully sensitized - acceptor are equal. - The background in the donor channel does not bleed through. - acceptor_bleedthrough : array_like, optional, default 0 - Weight of fluorescence from directly excited acceptor - relative to fluorescence of fully sensitized acceptor. - A weight of 1 means the fluorescence from directly excited acceptor - and fully sensitized acceptor are equal. - acceptor_background : array_like, optional, default 0 - Weight of background fluorescence in acceptor channel - relative to fluorescence of fully sensitized acceptor. - A weight of 1 means the fluorescence of background and fully - sensitized acceptor are equal. - donor_background : array_like, optional, default 0 - Weight of background fluorescence in donor channel - relative to fluorescence of donor without FRET. - A weight of 1 means the fluorescence of background and donor - without FRET are equal. - background_real : array_like, optional, default 0 - Real component of background fluorescence phasor coordinate - at `frequency`. - background_imag : array_like, optional, default 0 - Imaginary component of background fluorescence phasor coordinate - at `frequency`. - ax : matplotlib axes, optional - Matplotlib axes used for plotting. - By default, a new subplot axes is created. - Cannot be used with `interactive` mode. - interactive : bool, optional, default: False - Use matplotlib slider widgets to interactively control parameters. - **kwargs - Additional parameters passed to :py:class:`phasorpy.plot.PhasorPlot`. - - See Also - -------- - phasorpy.phasor.phasor_from_fret_donor - phasorpy.phasor.phasor_from_fret_acceptor - :ref:`sphx_glr_tutorials_phasorpy_fret.py` - - """ - - _fret_efficiencies: NDArray[Any] - - _frequency_slider: Slider - _donor_lifetime_slider: Slider - _acceptor_lifetime_slider: Slider - _fret_efficiency_slider: Slider - _donor_freting_slider: Slider - _donor_bleedthrough_slider: Slider - _acceptor_bleedthrough_slider: Slider - _acceptor_background_slider: Slider - _donor_background_slider: Slider - _background_real_slider: Slider - _background_imag_slider: Slider - - _donor_line: Line2D - _donor_only_line: Line2D - _donor_fret_line: Line2D - _donor_trajectory_line: Line2D - _donor_semicircle_line: Line2D - _donor_donor_line: Line2D - _donor_background_line: Line2D - _acceptor_line: Line2D - _acceptor_only_line: Line2D - _acceptor_trajectory_line: Line2D - _acceptor_semicircle_line: Line2D - _acceptor_background_line: Line2D - _background_line: Line2D - - _donor_semicircle_ticks: SemicircleTicks | None - - def __init__( - self, - *, - frequency: float = 60.0, - donor_lifetime: float = 4.2, - acceptor_lifetime: float = 3.0, - fret_efficiency: float = 0.5, - donor_freting: float = 1.0, - donor_bleedthrough: float = 0.0, - acceptor_bleedthrough: float = 0.0, - acceptor_background: float = 0.0, - donor_background: float = 0.0, - background_real: float = 0.0, - background_imag: float = 0.0, - ax: Axes | None = None, - interactive: bool = False, - **kwargs, - ) -> None: - update_kwargs( - kwargs, - title='FRET phasor plot', - xlim=[-0.2, 1.1], - ylim=[-0.1, 0.8], - ) - kwargs['allquadrants'] = False - kwargs['grid'] = False - - if ax is not None: - interactive = False - else: - fig = pyplot.figure() - ax = fig.add_subplot() - if interactive: - w, h = fig.get_size_inches() - fig.set_size_inches(w, h * 1.66) - fig.subplots_adjust(bottom=0.45) - fcm = fig.canvas.manager - if fcm is not None: - fcm.set_window_title(kwargs['title']) - - super().__init__(ax=ax, **kwargs) - - self._fret_efficiencies = numpy.linspace(0.0, 1.0, 101) - - donor_real, donor_imag = phasor_from_lifetime( - frequency, donor_lifetime - ) - donor_fret_real, donor_fret_imag = phasor_from_lifetime( - frequency, donor_lifetime * (1.0 - fret_efficiency) - ) - acceptor_real, acceptor_imag = phasor_from_lifetime( - frequency, acceptor_lifetime - ) - donor_trajectory_real, donor_trajectory_imag = phasor_from_fret_donor( - frequency, - donor_lifetime, - fret_efficiency=self._fret_efficiencies, - donor_freting=donor_freting, - donor_background=donor_background, - background_real=background_real, - background_imag=background_imag, - ) - acceptor_trajectory_real, acceptor_trajectory_imag = ( - phasor_from_fret_acceptor( - frequency, - donor_lifetime, - acceptor_lifetime, - fret_efficiency=self._fret_efficiencies, - donor_freting=donor_freting, - donor_bleedthrough=donor_bleedthrough, - acceptor_bleedthrough=acceptor_bleedthrough, - acceptor_background=acceptor_background, - background_real=background_real, - background_imag=background_imag, - ) - ) - - # add plots - self.semicircle(frequency=frequency) - self._donor_semicircle_line = self._lines[0] - self._donor_semicircle_ticks = self._semicircle_ticks - - self.semicircle( - phasor_reference=(float(acceptor_real), float(acceptor_imag)), - use_lines=True, - ) - self._acceptor_semicircle_line = self._lines[0] - - if donor_freting < 1.0 and donor_background == 0.0: - self.line( - [donor_real, donor_fret_real], - [donor_imag, donor_fret_imag], - ) - else: - self.line([0.0, 0.0], [0.0, 0.0]) - self._donor_donor_line = self._lines[0] - - if acceptor_background > 0.0: - self.line( - [float(acceptor_real), float(background_real)], - [float(acceptor_imag), float(background_imag)], - ) - else: - self.line([0.0, 0.0], [0.0, 0.0]) - self._acceptor_background_line = self._lines[0] - - if donor_background > 0.0: - self.line( - [float(donor_real), float(background_real)], - [float(donor_imag), float(background_imag)], - ) - else: - self.line([0.0, 0.0], [0.0, 0.0]) - self._donor_background_line = self._lines[0] - - self.plot( - donor_trajectory_real, - donor_trajectory_imag, - fmt='-', - color='tab:green', - ) - self._donor_trajectory_line = self._lines[0] - - self.plot( - acceptor_trajectory_real, - acceptor_trajectory_imag, - fmt='-', - color='tab:red', - ) - self._acceptor_trajectory_line = self._lines[0] - - self.plot( - donor_real, - donor_imag, - fmt='.', - color='tab:green', - ) - self._donor_only_line = self._lines[0] - - self.plot( - donor_real, - donor_imag, - fmt='.', - color='tab:green', - ) - self._donor_fret_line = self._lines[0] - - self.plot( - acceptor_real, - acceptor_imag, - fmt='.', - color='tab:red', - ) - self._acceptor_only_line = self._lines[0] - - self.plot( - donor_trajectory_real[int(fret_efficiency * 100.0)], - donor_trajectory_imag[int(fret_efficiency * 100.0)], - fmt='o', - color='tab:green', - label='Donor', - ) - self._donor_line = self._lines[0] - - self.plot( - acceptor_trajectory_real[int(fret_efficiency * 100.0)], - acceptor_trajectory_imag[int(fret_efficiency * 100.0)], - fmt='o', - color='tab:red', - label='Acceptor', - ) - self._acceptor_line = self._lines[0] - - self.plot( - background_real, - background_imag, - fmt='o', - color='black', - label='Background', - ) - self._background_line = self._lines[0] - - if not interactive: - return - - # add sliders - axes = [] - for i in range(11): - axes.append(fig.add_axes((0.33, 0.05 + i * 0.03, 0.45, 0.01))) - - self._frequency_slider = Slider( - ax=axes[10], - label='Frequency ', - valfmt=' %.0f MHz', - valmin=10, - valmax=200, - valstep=1, - valinit=frequency, - ) - self._frequency_slider.on_changed(self._on_semicircle_changed) - - self._donor_lifetime_slider = Slider( - ax=axes[9], - label='Donor lifetime ', - valfmt=' %.1f ns', - valmin=0.1, - valmax=16.0, - valstep=0.1, - valinit=donor_lifetime, - # facecolor='tab:green', - handle_style={'edgecolor': 'tab:green'}, - ) - self._donor_lifetime_slider.on_changed(self._on_changed) - - self._acceptor_lifetime_slider = Slider( - ax=axes[8], - label='Acceptor lifetime ', - valfmt=' %.1f ns', - valmin=0.1, - valmax=16.0, - valstep=0.1, - valinit=acceptor_lifetime, - # facecolor='tab:red', - handle_style={'edgecolor': 'tab:red'}, - ) - self._acceptor_lifetime_slider.on_changed(self._on_semicircle_changed) - - self._fret_efficiency_slider = Slider( - ax=axes[7], - label='FRET efficiency ', - valfmt=' %.2f', - valmin=0.0, - valmax=1.0, - valstep=0.01, - valinit=fret_efficiency, - ) - self._fret_efficiency_slider.on_changed(self._on_changed) - - self._donor_freting_slider = Slider( - ax=axes[6], - label='Donors FRETing ', - valfmt=' %.2f', - valmin=0.0, - valmax=1.0, - valstep=0.01, - valinit=donor_freting, - # facecolor='tab:green', - handle_style={'edgecolor': 'tab:green'}, - ) - self._donor_freting_slider.on_changed(self._on_changed) - - self._donor_bleedthrough_slider = Slider( - ax=axes[5], - label='Donor bleedthrough ', - valfmt=' %.2f', - valmin=0.0, - valmax=5.0, - valstep=0.01, - valinit=donor_bleedthrough, - # facecolor='tab:red', - handle_style={'edgecolor': 'tab:red'}, - ) - self._donor_bleedthrough_slider.on_changed(self._on_changed) - - self._acceptor_bleedthrough_slider = Slider( - ax=axes[4], - label='Acceptor bleedthrough ', - valfmt=' %.2f', - valmin=0.0, - valmax=5.0, - valstep=0.01, - valinit=acceptor_bleedthrough, - # facecolor='tab:red', - handle_style={'edgecolor': 'tab:red'}, - ) - self._acceptor_bleedthrough_slider.on_changed(self._on_changed) - - self._acceptor_background_slider = Slider( - ax=axes[3], - label='Acceptor background ', - valfmt=' %.2f', - valmin=0.0, - valmax=5.0, - valstep=0.01, - valinit=acceptor_background, - # facecolor='tab:red', - handle_style={'edgecolor': 'tab:red'}, - ) - self._acceptor_background_slider.on_changed(self._on_changed) - - self._donor_background_slider = Slider( - ax=axes[2], - label='Donor background ', - valfmt=' %.2f', - valmin=0.0, - valmax=5.0, - valstep=0.01, - valinit=donor_background, - # facecolor='tab:green', - handle_style={'edgecolor': 'tab:green'}, - ) - self._donor_background_slider.on_changed(self._on_changed) - - self._background_real_slider = Slider( - ax=axes[1], - label='Background real ', - valfmt=' %.2f', - valmin=0.0, - valmax=1.0, - valstep=0.01, - valinit=background_real, - ) - self._background_real_slider.on_changed(self._on_changed) - - self._background_imag_slider = Slider( - ax=axes[0], - label='Background imag ', - valfmt=' %.2f', - valmin=0.0, - valmax=0.6, - valstep=0.01, - valinit=background_imag, - ) - self._background_imag_slider.on_changed(self._on_changed) - - def _on_semicircle_changed(self, value: Any) -> None: - """Callback function to update semicircles.""" - self._frequency = frequency = self._frequency_slider.val - acceptor_lifetime = self._acceptor_lifetime_slider.val - if self._donor_semicircle_ticks is not None: - lifetime, labels = _semicircle_ticks(frequency) - self._donor_semicircle_ticks.labels = labels - self._donor_semicircle_line.set_data( - *phasor_transform(*phasor_from_lifetime(frequency, lifetime)) - ) - self._acceptor_semicircle_line.set_data( - *phasor_transform( - *phasor_semicircle(), - *phasor_to_polar( - *phasor_from_lifetime(frequency, acceptor_lifetime) - ), - ) - ) - self._on_changed(value) - - def _on_changed(self, value: Any) -> None: - """Callback function to update plot with current slider values.""" - frequency = self._frequency_slider.val - donor_lifetime = self._donor_lifetime_slider.val - acceptor_lifetime = self._acceptor_lifetime_slider.val - fret_efficiency = self._fret_efficiency_slider.val - donor_freting = self._donor_freting_slider.val - donor_bleedthrough = self._donor_bleedthrough_slider.val - acceptor_bleedthrough = self._acceptor_bleedthrough_slider.val - acceptor_background = self._acceptor_background_slider.val - donor_background = self._donor_background_slider.val - background_real = self._background_real_slider.val - background_imag = self._background_imag_slider.val - e = int(self._fret_efficiency_slider.val * 100) - - donor_real, donor_imag = phasor_from_lifetime( - frequency, donor_lifetime - ) - donor_fret_real, donor_fret_imag = phasor_from_lifetime( - frequency, donor_lifetime * (1.0 - fret_efficiency) - ) - acceptor_real, acceptor_imag = phasor_from_lifetime( - frequency, acceptor_lifetime - ) - donor_trajectory_real, donor_trajectory_imag = phasor_from_fret_donor( - frequency, - donor_lifetime, - fret_efficiency=self._fret_efficiencies, - donor_freting=donor_freting, - donor_background=donor_background, - background_real=background_real, - background_imag=background_imag, - ) - acceptor_trajectory_real, acceptor_trajectory_imag = ( - phasor_from_fret_acceptor( - frequency, - donor_lifetime, - acceptor_lifetime, - fret_efficiency=self._fret_efficiencies, - donor_freting=donor_freting, - donor_bleedthrough=donor_bleedthrough, - acceptor_bleedthrough=acceptor_bleedthrough, - acceptor_background=acceptor_background, - background_real=background_real, - background_imag=background_imag, - ) - ) - - if donor_background > 0.0: - self._donor_background_line.set_data( - [float(donor_real), float(background_real)], - [float(donor_imag), float(background_imag)], - ) - else: - self._donor_background_line.set_data([0.0, 0.0], [0.0, 0.0]) - - if donor_freting < 1.0 and donor_background == 0.0: - self._donor_donor_line.set_data( - [donor_real, donor_fret_real], - [donor_imag, donor_fret_imag], - ) - else: - self._donor_donor_line.set_data([0.0, 0.0], [0.0, 0.0]) - - if acceptor_background > 0.0: - self._acceptor_background_line.set_data( - [float(acceptor_real), float(background_real)], - [float(acceptor_imag), float(background_imag)], - ) - else: - self._acceptor_background_line.set_data([0.0, 0.0], [0.0, 0.0]) - - self._background_line.set_data([background_real], [background_imag]) - - self._donor_only_line.set_data([donor_real], [donor_imag]) - self._donor_fret_line.set_data([donor_fret_real], [donor_fret_imag]) - self._donor_trajectory_line.set_data( - donor_trajectory_real, donor_trajectory_imag - ) - self._donor_line.set_data( - [donor_trajectory_real[e]], [donor_trajectory_imag[e]] - ) - - self._acceptor_only_line.set_data([acceptor_real], [acceptor_imag]) - self._acceptor_trajectory_line.set_data( - acceptor_trajectory_real, acceptor_trajectory_imag - ) - self._acceptor_line.set_data( - [acceptor_trajectory_real[e]], [acceptor_trajectory_imag[e]] - ) - - -class SemicircleTicks(AbstractPathEffect): - """Draw ticks on universal semicircle. - - Parameters - ---------- - size : float, optional - Length of tick in dots. - The default is ``rcParams['xtick.major.size']``. - labels : sequence of str, optional - Tick labels for each vertex in path. - **kwargs - Extra keywords passed to matplotlib's - :py:meth:`matplotlib.patheffects.AbstractPathEffect._update_gc`. - - """ - - _size: float # tick length - _labels: tuple[str, ...] # tick labels - _gc: dict[str, Any] # keywords passed to _update_gc - - def __init__( - self, - size: float | None = None, - labels: Sequence[str] | None = None, - **kwargs, - ) -> None: - super().__init__((0.0, 0.0)) - - if size is None: - self._size = pyplot.rcParams['xtick.major.size'] - else: - self._size = size - if labels is None or not labels: - self._labels = () - else: - self._labels = tuple(labels) - self._gc = kwargs - - @property - def labels(self) -> tuple[str, ...]: - """Tick labels.""" - return self._labels - - @labels.setter - def labels(self, value: Sequence[str] | None, /) -> None: - if value is None or not value: - self._labels = () - else: - self._labels = tuple(value) - - def draw_path(self, renderer, gc, tpath, affine, rgbFace=None) -> None: - """Draw path with updated gc.""" - gc0 = renderer.new_gc() - gc0.copy_properties(gc) - - # TODO: this uses private methods of the base class - gc0 = self._update_gc(gc0, self._gc) # type: ignore - trans = affine + self._offset_transform(renderer) # type: ignore - - font = FontProperties() - # approximate half size of 'x' - fontsize = renderer.points_to_pixels(font.get_size_in_points()) / 4 - size = renderer.points_to_pixels(self._size) - origin = affine.transform([[0.5, 0.0]]) - - transpath = affine.transform_path(tpath) - polys = transpath.to_polygons(closed_only=False) - - for p in polys: - # coordinates of tick ends - t = p - origin - t /= numpy.hypot(t[:, 0], t[:, 1])[:, numpy.newaxis] - d = t.copy() - t *= size - t += p - - xyt = numpy.empty((2 * p.shape[0], 2)) - xyt[0::2] = p - xyt[1::2] = t - - renderer.draw_path( - gc0, - Path(xyt, numpy.tile([Path.MOVETO, Path.LINETO], p.shape[0])), - affine.inverted() + trans, - rgbFace, - ) - if not self._labels: - continue - # coordinates of labels - t = d * size * 2.5 - t += p - - if renderer.flipy(): - h = renderer.get_canvas_width_height()[1] - else: - h = 0.0 - - for s, (x, y), (dx, _) in zip(self._labels, t, d): - # TODO: get rendered text size from matplotlib.text.Text? - # this did not work: - # Text(d[i,0], h - d[i,1], label, ha='center', va='center') - x = x + fontsize * len(s.split()[0]) * (dx - 1.0) - y = h - y + fontsize - renderer.draw_text(gc0, x, y, s, font, 0.0) - - gc0.restore() - - -def plot_phasor( - real: ArrayLike, - imag: ArrayLike, - /, - *, - style: Literal['plot', 'hist2d', 'contour'] | None = None, - allquadrants: bool | None = None, - frequency: float | None = None, - show: bool = True, - **kwargs: Any, -) -> None: - """Plot phasor coordinates. - - A simplified interface to the :py:class:`PhasorPlot` class. - - Parameters - ---------- - real : array_like - Real component of phasor coordinates. - imag : array_like - Imaginary component of phasor coordinates. - Must be of same shape as `real`. - style : {'plot', 'hist2d', 'contour'}, optional - Method used to plot phasor coordinates. - By default, if the number of coordinates are less than 65536 - and the arrays are less than three-dimensional, `'plot'` style is used, - else `'hist2d'`. - allquadrants : bool, optional - Show all quadrants of phasor space. - By default, only the first quadrant is shown. - frequency : float, optional - Frequency of phasor plot. - If provided, the universal semicircle is labeled with reference - lifetimes. - show : bool, optional, default: True - Display figure. - **kwargs - Additional parguments passed to :py:class:`PhasorPlot`, - :py:meth:`PhasorPlot.plot`, :py:meth:`PhasorPlot.hist2d`, or - :py:meth:`PhasorPlot.contour` depending on `style`. - - See Also - -------- - phasorpy.plot.PhasorPlot - :ref:`sphx_glr_tutorials_phasorpy_phasorplot.py` - - """ - init_kwargs = parse_kwargs( - kwargs, - 'ax', - 'title', - 'xlabel', - 'ylabel', - 'xlim', - 'ylim', - 'xticks', - 'yticks', - 'grid', - ) - - real = numpy.asanyarray(real) - imag = numpy.asanyarray(imag) - plot = PhasorPlot( - frequency=frequency, allquadrants=allquadrants, **init_kwargs - ) - if style is None: - style = 'plot' if real.size < 65536 and real.ndim < 3 else 'hist2d' - if style == 'plot': - plot.plot(real, imag, **kwargs) - elif style == 'hist2d': - plot.hist2d(real, imag, **kwargs) - elif style == 'contour': - plot.contour(real, imag, **kwargs) - else: - raise ValueError(f'invalid {style=}') - if show: - plot.show() - - -def plot_phasor_image( - mean: ArrayLike | None, - real: ArrayLike, - imag: ArrayLike, - *, - harmonics: int | None = None, - percentile: float | None = None, - title: str | None = None, - show: bool = True, - **kwargs: Any, -) -> None: - """Plot phasor coordinates as images. - - Preview phasor coordinates from time-resolved or hyperspectral - image stacks as returned by :py:func:`phasorpy.phasor.phasor_from_signal`. - - The last two axes are assumed to be the image axes. - Harmonics, if any, are in the first axes of `real` and `imag`. - Other axes are averaged for display. - - Parameters - ---------- - mean : array_like - Image average. Must be two or more dimensional, or None. - real : array_like - Image of real component of phasor coordinates. - The last dimensions must match shape of `mean`. - imag : array_like - Image of imaginary component of phasor coordinates. - Must be same shape as `real`. - harmonics : int, optional - Number of harmonics to display. - If `mean` is None, a nonzero value indicates the presence of harmonics - in the first axes of `mean` and `real`. Else, the presence of harmonics - is determined from the shapes of `mean` and `real`. - By default, up to 4 harmonics are displayed. - percentile : float, optional - The (q, 100-q) percentiles of image data are covered by colormaps. - By default, the complete value range of `mean` is covered, - for `real` and `imag` the range [-1..1]. - title : str, optional - Figure title. - show : bool, optional, default: True - Display figure. - **kwargs - Additional arguments passed to :func:`matplotlib.pyplot.imshow`. - - Raises - ------ - ValueError - The shapes of `mean`, `real`, and `image` do not match. - Percentile is out of range. - - """ - update_kwargs(kwargs, interpolation='nearest') - cmap = kwargs.pop('cmap', None) - shape = None - - if mean is not None: - mean = numpy.asarray(mean) - if mean.ndim < 2: - raise ValueError(f'not an image {mean.ndim=} < 2') - shape = mean.shape - mean = numpy.mean(mean.reshape(-1, *mean.shape[-2:]), axis=0) - - real = numpy.asarray(real) - imag = numpy.asarray(imag) - if real.shape != imag.shape: - raise ValueError(f'{real.shape=} != {imag.shape=}') - if real.ndim < 2: - raise ValueError(f'not an image {real.ndim=} < 2') - - if (shape is not None and real.shape[1:] == shape) or ( - shape is None and harmonics - ): - # first image dimension contains harmonics - if real.ndim < 3: - raise ValueError(f'not a multi-harmonic image {real.shape=}') - nh = real.shape[0] # number harmonics - elif shape is None or shape == real.shape: - # single harmonic - nh = 1 - else: - raise ValueError(f'shape mismatch {real.shape[1:]=} != {shape}') - - # average extra image dimensions, but not harmonics - real = numpy.mean(real.reshape(nh, -1, *real.shape[-2:]), axis=1) - imag = numpy.mean(imag.reshape(nh, -1, *imag.shape[-2:]), axis=1) - - # for MyPy - assert isinstance(mean, numpy.ndarray) or mean is None - assert isinstance(real, numpy.ndarray) - assert isinstance(imag, numpy.ndarray) - - # limit number of displayed harmonics - nh = min(4 if harmonics is None else harmonics, nh) - - # create figure with size depending on image aspect and number of harmonics - fig = pyplot.figure(layout='constrained') - w, h = fig.get_size_inches() - aspect = min(1.0, max(0.5, real.shape[-2] / real.shape[-1])) - fig.set_size_inches(w, h * 0.4 * aspect * nh + h * 0.25 * aspect) - gs = GridSpec(nh, 2 if mean is None else 3, figure=fig) - if title: - fig.suptitle(title) - - if mean is not None: - _imshow( - fig.add_subplot(gs[0, 0]), - mean, - percentile=percentile, - vmin=None, - vmax=None, - cmap=cmap, - axis=True, - title='mean', - **kwargs, - ) - - if percentile is None: - vmin = -1.0 - vmax = 1.0 - if cmap is None: - cmap = 'coolwarm_r' - else: - vmin = None - vmax = None - - for h in range(nh): - axs = [] - ax = fig.add_subplot(gs[h, -2]) - axs.append(ax) - _imshow( - ax, - real[h], - percentile=percentile, - vmin=vmin, - vmax=vmax, - cmap=cmap, - axis=mean is None and h == 0, - colorbar=percentile is not None, - title=None if h else 'G, real', - **kwargs, - ) - - ax = fig.add_subplot(gs[h, -1]) - axs.append(ax) - pos = _imshow( - ax, - imag[h], - percentile=percentile, - vmin=vmin, - vmax=vmax, - cmap=cmap, - axis=False, - colorbar=percentile is not None, - title=None if h else 'S, imag', - **kwargs, - ) - if percentile is None and h == 0: - fig.colorbar(pos, ax=axs, shrink=0.4, location='bottom') - - if show: - pyplot.show() - - -def plot_signal_image( - signal: ArrayLike, - /, - *, - axis: int | None = None, - percentile: float | Sequence[float] | None = None, - title: str | None = None, - show: bool = True, - **kwargs: Any, -) -> None: - """Plot average image and signal along axis. - - Preview time-resolved or hyperspectral image stacks to be anayzed with - :py:func:`phasorpy.phasor.phasor_from_signal`. - - The last two axes, excluding `axis`, are assumed to be the image axes. - Other axes are averaged for image display. - - Parameters - ---------- - signal : array_like - Image stack. Must be three or more dimensional. - axis : int, optional, default: -1 - Axis over which phasor coordinates would be computed. - The default is the last axis (-1). - percentile : float or [float, float], optional - The [q, 100-q] percentiles of image data are covered by colormaps. - By default, the complete value range of `mean` is covered, - for `real` and `imag` the range [-1..1]. - title : str, optional - Figure title. - show : bool, optional, default: True - Display figure. - **kwargs - Additional arguments passed to :func:`matplotlib.pyplot.imshow`. - - Raises - ------ - ValueError - Signal is not an image stack. - Percentile is out of range. - - """ - # TODO: add option to separate channels? - # TODO: add option to plot non-images? - update_kwargs(kwargs, interpolation='nearest') - signal = numpy.asarray(signal) - if signal.ndim < 3: - raise ValueError(f'not an image stack {signal.ndim=} < 3') - - if axis is None: - axis = -1 - axis %= signal.ndim - - # for MyPy - assert isinstance(signal, numpy.ndarray) - - fig = pyplot.figure(layout='constrained') - if title: - fig.suptitle(title) - w, h = fig.get_size_inches() - fig.set_size_inches(w, h * 0.7) - gs = GridSpec(1, 2, figure=fig, width_ratios=(1, 1)) - - # histogram - axes = list(range(signal.ndim)) - del axes[axis] - ax = fig.add_subplot(gs[0, 1]) - ax.set_title(f'mean, axis {axis}') - ax.plot(signal.mean(axis=tuple(axes))) - - # image - axes = list(sorted(axes[:-2] + [axis])) - ax = fig.add_subplot(gs[0, 0]) - _imshow( - ax, - signal.mean(axis=tuple(axes)), - percentile=percentile, - shrink=0.5, - title='mean', - ) - - if show: - pyplot.show() - - -def plot_polar_frequency( - frequency: ArrayLike, - phase: ArrayLike, - modulation: ArrayLike, - *, - ax: Axes | None = None, - title: str | None = None, - show: bool = True, - **kwargs, -) -> None: - """Plot phase and modulation verus frequency. - - Parameters - ---------- - frequency : array_like, shape (n, ) - Laser pulse or modulation frequency in MHz. - phase : array_like - Angular component of polar coordinates in radians. - modulation : array_like - Radial component of polar coordinates. - ax : matplotlib axes, optional - Matplotlib axes used for plotting. - By default, a new subplot axes is created. - title : str, optional - Figure title. The default is "Multi-frequency plot". - show : bool, optional, default: True - Display figure. - **kwargs - Additional arguments passed to :py:func:`matplotlib.pyplot.plot`. - - """ - # TODO: make this customizable: labels, colors, ... - if ax is None: - ax = pyplot.subplots()[1] - if title is None: - title = 'Multi-frequency plot' - if title: - ax.set_title(title) - ax.set_xscale('log', base=10) - ax.set_xlabel('Frequency (MHz)') - - phase = numpy.asarray(phase) - if phase.ndim < 2: - phase = phase.reshape(-1, 1) - modulation = numpy.asarray(modulation) - if modulation.ndim < 2: - modulation = modulation.reshape(-1, 1) - - ax.set_ylabel('Phase (°)', color='tab:blue') - ax.set_yticks([0.0, 30.0, 60.0, 90.0]) - for phi in phase.T: - ax.plot(frequency, numpy.rad2deg(phi), color='tab:blue', **kwargs) - ax = ax.twinx() # type: ignore - - ax.set_ylabel('Modulation (%)', color='tab:red') - ax.set_yticks([0.0, 25.0, 50.0, 75.0, 100.0]) - for mod in modulation.T: - ax.plot(frequency, mod * 100, color='tab:red', **kwargs) - if show: - pyplot.show() - -def _imshow( - ax: Axes, - image: NDArray[Any], - /, - *, - percentile: float | Sequence[float] | None = None, - vmin: float | None = None, - vmax: float | None = None, - colorbar: bool = True, - shrink: float | None = None, - axis: bool = True, - title: str | None = None, - **kwargs, -) -> AxesImage: - """Plot image array. - - Convenience wrapper around :py:func:`matplotlib.pyplot.imshow`. - - """ - update_kwargs(kwargs, interpolation='none') - if percentile is not None: - if isinstance(percentile, Sequence): - percentile = percentile[0], percentile[1] - else: - # percentile = max(0.0, min(50, percentile)) - percentile = percentile, 100.0 - percentile - if ( - percentile[0] >= percentile[1] - or percentile[0] < 0 - or percentile[1] > 100 - ): - raise ValueError(f'{percentile=} out of range') - vmin, vmax = numpy.percentile(image, percentile) - pos = ax.imshow(image, vmin=vmin, vmax=vmax, **kwargs) - if colorbar: - if percentile is not None and vmin is not None and vmax is not None: - ticks = vmin, vmax - else: - ticks = None - fig = ax.get_figure() - if fig is not None: - if shrink is None: - shrink = 0.8 - fig.colorbar(pos, shrink=shrink, location='bottom', ticks=ticks) - if title: - ax.set_title(title) - if not axis: - ax.set_axis_off() - # ax.set_anchor('C') - return pos - - -def _semicircle_ticks( - frequency: float, - lifetime: Sequence[float] | None = None, - labels: Sequence[str] | None = None, -) -> tuple[tuple[float, ...], tuple[str, ...]]: - """Return semicircle tick lifetimes and labels at frequency.""" - if lifetime is None: - lifetime = [0.0] + [ - 2**t - for t in range(-8, 32) - if phasor_from_lifetime(frequency, 2**t)[1] >= 0.18 - ] - unit = 'ns' - else: - unit = '' - if labels is None: - labels = [f'{tau:g}' for tau in lifetime] - try: - labels[2] = f'{labels[2]} {unit}' - except IndexError: - pass - return tuple(lifetime), tuple(labels) From 2e4be7288b069d5cd0f3bd7e4fb7086e712bb14c Mon Sep 17 00:00:00 2001 From: bruno-pannunzio Date: Mon, 22 Apr 2024 18:26:00 -0300 Subject: [PATCH 11/21] Discard test files --- prueba_tutorial_components.py | 90 ----------------------------------- 1 file changed, 90 deletions(-) delete mode 100644 prueba_tutorial_components.py diff --git a/prueba_tutorial_components.py b/prueba_tutorial_components.py deleted file mode 100644 index 140b19ea..00000000 --- a/prueba_tutorial_components.py +++ /dev/null @@ -1,90 +0,0 @@ -#%% -import numpy -import matplotlib.pyplot as plt -from phasorpy.components import two_fractions_from_phasor -from phasorpy.plot import PhasorPlot -from phasorpy.phasor import phasor_from_lifetime - -frequency = 80.0 -components_lifetimes = [[8.0, 1.0],[4.0, 0.5]] -real, imag = phasor_from_lifetime( - frequency, components_lifetimes[0], [0.25, 0.75] - ) -components_real, components_imag = phasor_from_lifetime(frequency, components_lifetimes[0]) -plot = PhasorPlot(frequency=frequency, title = 'Phasor lying on the line between components') -plot.plot(components_real, components_imag, fmt= 'o-') -plot.plot(real, imag) -plot.show() -fraction_from_first_component, fraction_from_second_component = two_fractions_from_phasor(real, imag, components_real, components_imag) -print ('Fraction from first component: ', fraction_from_first_component) -print ('Fraction from second component: ', fraction_from_second_component) -# %% -real1, imag1 = numpy.random.multivariate_normal( - (0.6, 0.35), [[8e-3, 1e-3], [1e-3, 1e-3]], (100, 100) -).T -real2, imag2 = numpy.random.multivariate_normal( - (0.4, 0.3), [[8e-3, 1e-3], [1e-3, 1e-3]], (100, 100) -).T -real = numpy.stack((real1, real2), axis=0) -imag = numpy.stack((imag1, imag2), axis=0) -components_real2, components_imag2 = phasor_from_lifetime(frequency, components_lifetimes[1]) -components_real3 = numpy.stack((components_real, components_real2), axis=0) -components_imag3 = numpy.stack((components_imag, components_imag2), axis=0) -plot = PhasorPlot(frequency=frequency, title = 'Phasor lying on the line between components') -plot.plot(components_real3, components_imag3, fmt= 'o-') -plot.plot(real[0], imag[0], c='blue') -plot.plot(real[1], imag[1], c='orange') -plot.show() -fraction_from_first_component, fraction_from_second_component = two_fractions_from_phasor(real, imag, components_real3, components_imag3) -plt.figure() -plt.hist(fraction_from_first_component[0].flatten(), range=(0,1), bins=100) -plt.title('Histogram of fractions of first component ch1') -plt.xlabel('Fraction of first component') -plt.ylabel('Counts') -plt.show() - -plt.figure() -plt.hist(fraction_from_first_component[1].flatten(), range=(0,1), bins=100) -plt.title('Histogram of fractions of first component ch2') -plt.xlabel('Fraction of first component') -plt.ylabel('Counts') -plt.show() - -plt.figure() -plt.hist(fraction_from_second_component[0].flatten(), range=(0,1), bins=100) -plt.title('Histogram of fractions of second component ch1') -plt.xlabel('Fraction of second component') -plt.ylabel('Counts') -plt.show() -plt.figure() -plt.hist(fraction_from_second_component[1].flatten(), range=(0,1), bins=100) -plt.title('Histogram of fractions of second component ch2') -plt.xlabel('Fraction of second component') -plt.ylabel('Counts') -plt.show() - -#%% -real, imag = numpy.random.multivariate_normal( - (0.6, 0.35), [[8e-3, 1e-3], [1e-3, 1e-3]], (100, 100) -).T -plot = PhasorPlot(frequency=frequency, title = 'Point lying on the line between components') -plot.hist2d(real, imag) -plot.plot(*phasor_from_lifetime(frequency, components_lifetimes), fmt= 'o-') -plot.show() -fraction_from_first_component, fraction_from_second_component = two_fractions_from_phasor(real, imag, components_real, components_imag) - -plt.figure() -plt.hist(fraction_from_first_component.flatten(), range=(0,1), bins=100) -plt.title('Histogram of fractions of first component') -plt.xlabel('Fraction of first component') -plt.ylabel('Counts') -plt.show() - -plt.figure() -plt.hist(fraction_from_second_component.flatten(), range=(0,1), bins=100) -plt.title('Histogram of fractions of second component') -plt.xlabel('Fraction of second component') -plt.ylabel('Counts') -plt.show() - -# %% From 917bdcfa36d89ff29b52eeccf62f57e68a99f8f0 Mon Sep 17 00:00:00 2001 From: bruno-pannunzio Date: Mon, 22 Apr 2024 18:32:07 -0300 Subject: [PATCH 12/21] Revert "Discard `plot` changes" This reverts commit 4f472d18f6b67be1a2cccc855baf2dfbd02947ce. --- src/phasorpy/plot.py | 1896 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1896 insertions(+) create mode 100644 src/phasorpy/plot.py diff --git a/src/phasorpy/plot.py b/src/phasorpy/plot.py new file mode 100644 index 00000000..e55814fa --- /dev/null +++ b/src/phasorpy/plot.py @@ -0,0 +1,1896 @@ +"""Plot phasor coordinates and related data. + +The ``phasorpy.plot`` module provides functions and classes to visualize +phasor coordinates and related data using the matplotlib library. + +""" + +from __future__ import annotations + +__all__ = [ + 'PhasorPlot', + 'PhasorPlotFret', + 'plot_phasor', + 'plot_phasor_image', + 'plot_signal_image', + 'plot_polar_frequency', + 'two_components_histogram', +] + +import math +import os +from collections.abc import Sequence +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from ._typing import Any, ArrayLike, NDArray, Literal, BinaryIO + + from matplotlib.axes import Axes + from matplotlib.image import AxesImage + from matplotlib.figure import Figure + +import numpy +from matplotlib import pyplot +from matplotlib.font_manager import FontProperties +from matplotlib.gridspec import GridSpec +from matplotlib.lines import Line2D +from matplotlib.patches import Arc, Circle, Polygon +from matplotlib.path import Path +from matplotlib.patheffects import AbstractPathEffect +from matplotlib.widgets import Slider + +from ._utils import ( + circle_circle_intersection, + circle_line_intersection, + parse_kwargs, + phasor_from_polar_scalar, + phasor_to_polar_scalar, + project_phasor_to_line, + sort_coordinates, + update_kwargs, +) +from .phasor import ( + phasor_from_fret_acceptor, + phasor_from_fret_donor, + phasor_from_lifetime, + phasor_semicircle, + phasor_to_apparent_lifetime, + phasor_to_polar, + phasor_transform, +) + +GRID_COLOR = '0.5' +GRID_LINESTYLE = ':' +GRID_LINESTYLE_MAJOR = '-' +GRID_LINEWIDH = 1.0 +GRID_LINEWIDH_MINOR = 0.5 +GRID_FILL = False + + +class PhasorPlot: + """Phasor plot. + + Create publication quality visualizations of phasor coordinates. + + Parameters + ---------- + allquadrants : bool, optional + Show all quandrants of phasor space. + By default, only the first quadrant with universal semicircle is shown. + ax : matplotlib axes, optional + Matplotlib axes used for plotting. + By default, a new subplot axes is created. + frequency : float, optional + Laser pulse or modulation frequency in MHz. + grid : bool, optional, default: True + Display polar grid or semicircle. + **kwargs + Additional properties to set on `ax`. + + See Also + -------- + phasorpy.plot.plot_phasor + :ref:`sphx_glr_tutorials_phasorpy_phasorplot.py` + + """ + + _ax: Axes + """Matplotlib axes.""" + + _limits: tuple[tuple[float, float], tuple[float, float]] + """Axes limits (xmin, xmax), (ymin, ymax).""" + + _full: bool + """Show all quadrants of phasor space.""" + + _lines: list[Line2D] + """Last lines created.""" + + _semicircle_ticks: SemicircleTicks | None + """Last SemicircleTicks instance created.""" + + _frequency: float + """Laser pulse or modulation frequency in MHz.""" + + def __init__( + self, + /, + allquadrants: bool | None = None, + ax: Axes | None = None, + *, + frequency: float | None = None, + grid: bool = True, + **kwargs, + ) -> None: + # initialize empty phasor plot + self._ax = pyplot.subplots()[1] if ax is None else ax + self._ax.format_coord = self._on_format_coord # type: ignore + + self._lines = [] + self._semicircle_ticks = None + + self._full = bool(allquadrants) + if self._full: + xlim = (-1.05, 1.05) + ylim = (-1.05, 1.05) + xticks: tuple[float, ...] = (-1.0, -0.5, 0.0, 0.5, 1.0) + yticks: tuple[float, ...] = (-1.0, -0.5, 0.0, 0.5, 1.0) + if grid: + self.polar_grid() + else: + xlim = (-0.05, 1.05) + ylim = (-0.05, 0.7) + xticks = (0.0, 0.2, 0.4, 0.6, 0.8, 1.0) + yticks = (0.0, 0.2, 0.4, 0.6) + if grid: + self.semicircle(frequency=frequency) + + title = 'Phasor plot' + if frequency is not None: + self._frequency = float(frequency) + title += f' ({frequency:g} MHz)' + else: + self._frequency = 0.0 + + update_kwargs( + kwargs, + title=title, + xlabel='G, real', + ylabel='S, imag', + aspect='equal', + xlim=xlim, + ylim=ylim, + xticks=xticks, + yticks=yticks, + ) + self._limits = (kwargs['xlim'], kwargs['ylim']) + self._ax.set(**kwargs) + + @property + def ax(self) -> Axes: + """Matplotlib :py:class:`matplotlib.axes.Axes`.""" + return self._ax + + @property + def fig(self) -> Figure | None: + """Matplotlib :py:class:`matplotlib.figure.Figure`.""" + return self._ax.get_figure() + + def show(self) -> None: + """Display all open figures. Call :py:func:`matplotlib.pyplot.show`.""" + # self.fig.show() + pyplot.show() + + def save( + self, + file: str | os.PathLike[Any] | BinaryIO | None, + /, + **kwargs: Any, + ) -> None: + """Save current figure to file. + + Parameters + ---------- + file : str, path-like, or binary file-like + Path or Python file-like object to write the current figure to. + **kwargs + Additional keyword arguments passed to + :py:func:`matplotlib:pyplot.savefig`. + + """ + pyplot.savefig(file, **kwargs) + + def plot( + self, + real: ArrayLike, + imag: ArrayLike, + /, + fmt='o', + *, + label: str | Sequence[str] | None = None, + **kwargs: Any, + ) -> None: + """Plot imag versus real coordinates as markers and/or lines. + + Parameters + ---------- + real : array_like + Real component of phasor coordinates. + Must be one or two dimensional. + imag : array_like + Imaginary component of phasor coordinates. + Must be of same shape as `real`. + fmt : str, optional, default: 'o' + Matplotlib style format string. + label : str or sequence of str, optional + Plot label. + May be a sequence if phasor coordinates are two dimensional arrays. + **kwargs + Additional parameters passed to + :py:meth:`matplotlib.axes.Axes.plot`. + + """ + ax = self._ax + if label is not None and ( + isinstance(label, str) or not isinstance(label, Sequence) + ): + label = (label,) + for ( + i, + (re, im), + ) in enumerate( + zip(numpy.array(real, ndmin=2), numpy.array(imag, ndmin=2)) + ): + lbl = None + if label is not None: + try: + lbl = label[i] + except IndexError: + pass + self._lines = ax.plot(re, im, fmt, label=lbl, **kwargs) + if label is not None: + ax.legend() + self._reset_limits() + + def _histogram2d( + self, + real: ArrayLike, + imag: ArrayLike, + /, + **kwargs: Any, + ) -> tuple[NDArray[Any], NDArray[Any], NDArray[Any]]: + """Return 2D histogram of imag versus real coordinates.""" + update_kwargs(kwargs, range=self._limits) + (xmin, xmax), (ymin, ymax) = kwargs['range'] + assert xmax > xmin and ymax > ymin + bins = kwargs.get('bins', 128) + if isinstance(bins, int): + assert bins > 0 + aspect = (xmax - xmin) / (ymax - ymin) + if aspect > 1: + bins = (bins, max(int(bins / aspect), 1)) + else: + bins = (max(int(bins * aspect), 1), bins) + kwargs['bins'] = bins + return numpy.histogram2d( + numpy.asanyarray(real).reshape(-1), + numpy.asanyarray(imag).reshape(-1), + **kwargs, + ) + + def _reset_limits(self) -> None: + """Reset axes limits.""" + try: + self._ax.set(xlim=self._limits[0], ylim=self._limits[1]) + except AttributeError: + pass + + def hist2d( + self, + real: ArrayLike, + imag: ArrayLike, + /, + **kwargs: Any, + ) -> None: + """Plot 2D histogram of imag versus real coordinates. + + Parameters + ---------- + real : array_like + Real component of phasor coordinates. + imag : array_like + Imaginary component of phasor coordinates. + Must be of same shape as `real`. + **kwargs + Additional parameters passed to :py:meth:`numpy.histogram2d` + and :py:meth:`matplotlib.axes.Axes.pcolormesh`. + + """ + kwargs_hist2d = parse_kwargs( + kwargs, 'bins', 'range', 'density', 'weights' + ) + h, xedges, yedges = self._histogram2d(real, imag, **kwargs_hist2d) + + update_kwargs(kwargs, cmap='Blues', norm='log') + cmin = kwargs.pop('cmin', 1) + cmax = kwargs.pop('cmax', None) + if cmin is not None: + h[h < cmin] = None + if cmax is not None: + h[h > cmax] = None + self._ax.pcolormesh(xedges, yedges, h.T, **kwargs) + self._reset_limits() + + def contour( + self, + real: ArrayLike, + imag: ArrayLike, + /, + **kwargs: Any, + ) -> None: + """Plot contours of imag versus real coordinates (not implemented). + + Parameters + ---------- + real : array_like + Real component of phasor coordinates. + imag : array_like + Imaginary component of phasor coordinates. + Must be of same shape as `real`. + **kwargs + Additional parameters passed to :py:func:`numpy.histogram2d` + and :py:meth:`matplotlib.axes.Axes.contour`. + + """ + update_kwargs(kwargs, cmap='Blues', norm='log') + kwargs_hist2d = parse_kwargs( + kwargs, 'bins', 'range', 'density', 'weights' + ) + h, xedges, yedges = self._histogram2d(real, imag, **kwargs_hist2d) + xedges = xedges[:-1] + ((xedges[1] - xedges[0]) / 2.0) + yedges = yedges[:-1] + ((yedges[1] - yedges[0]) / 2.0) + self._ax.contour(xedges, yedges, h.T, **kwargs) + self._reset_limits() + + def imshow( + self, + image: ArrayLike, + /, + **kwargs: Any, + ) -> None: + """Plot an image, for example, a 2D histogram (not implemented). + + Parameters + ---------- + image : array_like + Image to display. + **kwargs + Additional parameters passed to + :py:meth:`matplotlib.axes.Axes.imshow`. + + """ + raise NotImplementedError + + def components( + self, + real: ArrayLike, + imag: ArrayLike, + /, + fraction: ArrayLike | None = None, + **kwargs: Any, + ) -> None: + """Plot linear combinations of phasor coordinates or ranges thereof. + + Parameters + ---------- + real : (N,) array_like + Real component of phasor coordinates. + imag : (N,) array_like + Imaginary component of phasor coordinates. + fraction : (N,) array_like, optional + Weight associated with each component. + If None (default), outline the polygon area of possible linear + combinations of components. + Else, draw lines from the component coordinates to the weighted + average. + **kwargs + Additional parameters passed to + :py:class:`matplotlib.patches.Polygon` or + :py:class:`matplotlib.lines.Line2D`. + + """ + real = numpy.asanyarray(real) + imag = numpy.asanyarray(imag) + if real.ndim != 1 or real.shape != imag.shape: + raise ValueError(f'invalid {real.shape=} or {imag.shape=}') + if fraction is None: + update_kwargs( + kwargs, + edgecolor=GRID_COLOR, + linestyle=GRID_LINESTYLE, + linewidth=GRID_LINEWIDH, + fill=GRID_FILL, + ) + self._ax.add_patch( + Polygon(numpy.vstack(sort_coordinates(real, imag)).T, **kwargs) + ) + return + + update_kwargs( + kwargs, + color=GRID_COLOR, + linestyle=GRID_LINESTYLE, + linewidth=GRID_LINEWIDH, + ) + center_re, center_im = numpy.average( + numpy.vstack((real, imag)), axis=-1, weights=fraction + ) + for re, im in zip(real, imag): + self._ax.add_line( + Line2D([center_re, re], [center_im, im], **kwargs) + ) + # TODO: add fraction labels? + + def line( + self, + real: ArrayLike, + imag: ArrayLike, + /, + **kwargs: Any, + ) -> None: + """Draw grid line. + + Parameters + ---------- + real : array_like, shape (n, ) + Real components of line start and end coordinates. + imag : array_like, shape (n, ) + Imaginary components of line start and end coordinates. + **kwargs + Additional parameters passed to + :py:class:`matplotlib.lines.Line2D`. + + """ + update_kwargs( + kwargs, + color=GRID_COLOR, + linestyle=GRID_LINESTYLE, + linewidth=GRID_LINEWIDH, + ) + self._lines = [self._ax.add_line(Line2D(real, imag, **kwargs))] + + def circle( + self, + real: float, + imag: float, + /, + radius: float, + **kwargs: Any, + ) -> None: + """Draw grid circle of radius around center. + + Parameters + ---------- + real : float + Real component of circle center coordinate. + imag : float + Imaginary component of circle center coordinate. + radius : float + Circle radius. + **kwargs + Additional parameters passed to + :py:class:`matplotlib.patches.Circle`. + + """ + update_kwargs( + kwargs, + color=GRID_COLOR, + linestyle=GRID_LINESTYLE, + linewidth=GRID_LINEWIDH, + fill=GRID_FILL, + ) + self._ax.add_patch(Circle((real, imag), radius, **kwargs)) + + def cursor( + self, + real: float, + imag: float, + /, + real_limit: float | None = None, + imag_limit: float | None = None, + radius: float | None = None, + **kwargs: Any, + ) -> None: + """Plot phase and modulation grid lines and arcs at phasor coordinates. + + Parameters + ---------- + real : float + Real component of phasor coordinate. + imag : float + Imaginary component of phasor coordinate. + real_limit : float, optional + Real component of limiting phasor coordinate. + imag_limit : float, optional + Imaginary component of limiting phasor coordinate. + radius : float, optional + Radius of circle limiting phase and modulation grid lines and arcs. + **kwargs + Additional parameters passed to + :py:class:`matplotlib.lines.Line2D`, + :py:class:`matplotlib.patches.Circle`, and + :py:class:`matplotlib.patches.Arc`. + + See Also + -------- + phasorpy.plot.PhasorPlot.polar_cursor + + """ + if real_limit is not None and imag_limit is not None: + return self.polar_cursor( + *phasor_to_polar_scalar(real, imag), + *phasor_to_polar_scalar(real_limit, imag_limit), + radius=radius, + **kwargs, + ) + return self.polar_cursor( + *phasor_to_polar_scalar(real, imag), + radius=radius, + # _circle_only=True, + **kwargs, + ) + + def polar_cursor( + self, + phase: float | None = None, + modulation: float | None = None, + phase_limit: float | None = None, + modulation_limit: float | None = None, + radius: float | None = None, + **kwargs: Any, + ) -> None: + """Plot phase and modulation grid lines and arcs. + + Parameters + ---------- + phase : float, optional + Angular component of polar coordinate in radians. + modulation : float, optional + Radial component of polar coordinate. + phase_limit : float, optional + Angular component of limiting polar coordinate (in radians). + Modulation grid arcs are drawn between `phase` and `phase_limit`. + modulation_limit : float, optional + Radial component of limiting polar coordinate. + Phase grid lines are drawn from `modulation` to `modulation_limit`. + radius : float, optional + Radius of circle limiting phase and modulation grid lines and arcs. + **kwargs + Additional parameters passed to + :py:class:`matplotlib.lines.Line2D`, + :py:class:`matplotlib.patches.Circle`, and + :py:class:`matplotlib.patches.Arc`. + + See Also + -------- + phasorpy.plot.PhasorPlot.cursor + + """ + update_kwargs( + kwargs, + color=GRID_COLOR, + linestyle=GRID_LINESTYLE, + linewidth=GRID_LINEWIDH, + fill=GRID_FILL, + ) + _circle_only = kwargs.pop('_circle_only', False) + ax = self._ax + if radius is not None and phase is not None and modulation is not None: + x = modulation * math.cos(phase) + y = modulation * math.sin(phase) + ax.add_patch(Circle((x, y), radius, **kwargs)) + if _circle_only: + return + del kwargs['fill'] + p0, p1 = circle_line_intersection(x, y, radius, 0, 0, x, y) + ax.add_line(Line2D((p0[0], p1[0]), (p0[1], p1[1]), **kwargs)) + p0, p1 = circle_circle_intersection(0, 0, modulation, x, y, radius) + ax.add_patch( + Arc( + (0, 0), + modulation * 2, + modulation * 2, + theta1=math.degrees(math.atan2(p0[1], p0[0])), + theta2=math.degrees(math.atan2(p1[1], p1[0])), + fill=False, + **kwargs, + ) + ) + return + + del kwargs['fill'] + for phi in (phase, phase_limit): + if phi is not None: + if modulation is not None and modulation_limit is not None: + x0 = modulation * math.cos(phi) + y0 = modulation * math.sin(phi) + x1 = modulation_limit * math.cos(phi) + y1 = modulation_limit * math.sin(phi) + else: + x0 = 0 + y0 = 0 + x1 = math.cos(phi) + y1 = math.sin(phi) + ax.add_line(Line2D((x0, x1), (y0, y1), **kwargs)) + for mod in (modulation, modulation_limit): + if mod is not None: + if phase is not None and phase_limit is not None: + theta1 = math.degrees(min(phase, phase_limit)) + theta2 = math.degrees(max(phase, phase_limit)) + else: + theta1 = 0.0 + theta2 = 360.0 if self._full else 90.0 + ax.add_patch( + Arc( + (0, 0), + mod * 2, + mod * 2, + theta1=theta1, + theta2=theta2, + fill=False, # filling arc objects is not supported + **kwargs, + ) + ) + + def polar_grid(self, **kwargs) -> None: + """Draw polar coordinate system. + + Parameters + ---------- + **kwargs + Parameters passed to + :py:class:`matplotlib.patches.Circle` and + :py:class:`matplotlib.lines.Line2D`. + + """ + ax = self._ax + # major gridlines + kwargs_copy = kwargs.copy() + update_kwargs( + kwargs, + color=GRID_COLOR, + linestyle=GRID_LINESTYLE_MAJOR, + linewidth=GRID_LINEWIDH, + # fill=GRID_FILL, + ) + ax.add_line(Line2D([-1, 1], [0, 0], **kwargs)) + ax.add_line(Line2D([0, 0], [-1, 1], **kwargs)) + ax.add_patch(Circle((0, 0), 1, fill=False, **kwargs)) + # minor gridlines + kwargs = kwargs_copy + update_kwargs( + kwargs, + color=GRID_COLOR, + linestyle=GRID_LINESTYLE, + linewidth=GRID_LINEWIDH_MINOR, + ) + for r in (1 / 3, 2 / 3): + ax.add_patch(Circle((0, 0), r, fill=False, **kwargs)) + for a in (3, 6): + x = math.cos(math.pi / a) + y = math.sin(math.pi / a) + ax.add_line(Line2D([-x, x], [-y, y], **kwargs)) + ax.add_line(Line2D([-x, x], [y, -y], **kwargs)) + + def semicircle( + self, + frequency: float | None = None, + *, + polar_reference: tuple[float, float] | None = None, + phasor_reference: tuple[float, float] | None = None, + lifetime: Sequence[float] | None = None, + labels: Sequence[str] | None = None, + show_circle: bool = True, + use_lines: bool = False, + **kwargs, + ) -> None: + """Draw universal semicircle. + + Parameters + ---------- + frequency : float, optional + Laser pulse or modulation frequency in MHz. + polar_reference : (float, float), optional, default: (0, 1) + Polar coordinates of zero lifetime. + phasor_reference : (float, float), optional, default: (1, 0) + Phasor coordinates of zero lifetime. + Alternative to `polar_reference`. + lifetime : sequence of float, optional + Single component lifetimes at which to draw ticks and labels. + Only applies when `frequency` is specified. + labels : sequence of str, optional + Tick labels. By default, the values of `lifetime`. + Only applies when `frequency` and `lifetime` are specified. + show_circle : bool, optional, default: True + Draw universal semicircle. + use_lines : bool, optional, default: False + Draw universal semicircle using lines instead of arc. + **kwargs + Additional parameters passed to + :py:class:`matplotlib.lines.Line2D` or + :py:class:`matplotlib.patches.Arc` and + :py:meth:`matplotlib.axes.Axes.plot`. + + """ + if frequency is not None: + self._frequency = float(frequency) + + update_kwargs( + kwargs, + color=GRID_COLOR, + linestyle=GRID_LINESTYLE_MAJOR, + linewidth=GRID_LINEWIDH, + ) + if phasor_reference is not None: + polar_reference = phasor_to_polar_scalar(*phasor_reference) + if polar_reference is None: + polar_reference = (0.0, 1.0) + if phasor_reference is None: + phasor_reference = phasor_from_polar_scalar(*polar_reference) + ax = self._ax + + if show_circle: + if use_lines: + self._lines = [ + ax.add_line( + Line2D( + *phasor_transform( + *phasor_semicircle(), *polar_reference + ), + **kwargs, + ) + ) + ] + else: + ax.add_patch( + Arc( + (phasor_reference[0] / 2, phasor_reference[1] / 2), + polar_reference[1], + polar_reference[1], + theta1=math.degrees(polar_reference[0]), + theta2=math.degrees(polar_reference[0]) + 180.0, + fill=False, + **kwargs, + ) + ) + + if frequency is not None and polar_reference == (0.0, 1.0): + # draw ticks and labels + lifetime, labels = _semicircle_ticks(frequency, lifetime, labels) + self._semicircle_ticks = SemicircleTicks(labels=labels) + self._lines = ax.plot( + *phasor_transform( + *phasor_from_lifetime(frequency, lifetime), + *polar_reference, + ), + path_effects=[self._semicircle_ticks], + **kwargs, + ) + self._reset_limits() + + def _on_format_coord(self, x: float, y: float, /) -> str: + """Callback function to update coordinates displayed in toolbar.""" + phi, mod = phasor_to_polar_scalar(x, y) + ret = [ + f'[{x:4.2f}, {y:4.2f}]', + f'[{math.degrees(phi):.0f}°, {mod * 100:.0f}%]', + ] + if x > 0.0 and y > 0.0 and self._frequency > 0.0: + tp, tm = phasor_to_apparent_lifetime(x, y, self._frequency) + ret.append(f'[{tp:.2f}, {tm:.2f} ns]') + return ' '.join(reversed(ret)) + + +class PhasorPlotFret(PhasorPlot): + """FRET phasor plot. + + Plot Förster Resonance Energy Transfer efficiency trajectories + of donor and acceptor channels in phasor space. + + Parameters + ---------- + frequency : array_like + Laser pulse or modulation frequency in MHz. + donor_lifetime : array_like + Lifetime of donor without FRET in ns. + acceptor_lifetime : array_like + Lifetime of acceptor in ns. + fret_efficiency : array_like, optional, default 0 + FRET efficiency in range [0..1]. + donor_freting : array_like, optional, default 1 + Fraction of donors participating in FRET. Range [0..1]. + donor_bleedthrough : array_like, optional, default 0 + Weight of donor fluorescence in acceptor channel + relative to fluorescence of fully sensitized acceptor. + A weight of 1 means the fluorescence from donor and fully sensitized + acceptor are equal. + The background in the donor channel does not bleed through. + acceptor_bleedthrough : array_like, optional, default 0 + Weight of fluorescence from directly excited acceptor + relative to fluorescence of fully sensitized acceptor. + A weight of 1 means the fluorescence from directly excited acceptor + and fully sensitized acceptor are equal. + acceptor_background : array_like, optional, default 0 + Weight of background fluorescence in acceptor channel + relative to fluorescence of fully sensitized acceptor. + A weight of 1 means the fluorescence of background and fully + sensitized acceptor are equal. + donor_background : array_like, optional, default 0 + Weight of background fluorescence in donor channel + relative to fluorescence of donor without FRET. + A weight of 1 means the fluorescence of background and donor + without FRET are equal. + background_real : array_like, optional, default 0 + Real component of background fluorescence phasor coordinate + at `frequency`. + background_imag : array_like, optional, default 0 + Imaginary component of background fluorescence phasor coordinate + at `frequency`. + ax : matplotlib axes, optional + Matplotlib axes used for plotting. + By default, a new subplot axes is created. + Cannot be used with `interactive` mode. + interactive : bool, optional, default: False + Use matplotlib slider widgets to interactively control parameters. + **kwargs + Additional parameters passed to :py:class:`phasorpy.plot.PhasorPlot`. + + See Also + -------- + phasorpy.phasor.phasor_from_fret_donor + phasorpy.phasor.phasor_from_fret_acceptor + :ref:`sphx_glr_tutorials_phasorpy_fret.py` + + """ + + _fret_efficiencies: NDArray[Any] + + _frequency_slider: Slider + _donor_lifetime_slider: Slider + _acceptor_lifetime_slider: Slider + _fret_efficiency_slider: Slider + _donor_freting_slider: Slider + _donor_bleedthrough_slider: Slider + _acceptor_bleedthrough_slider: Slider + _acceptor_background_slider: Slider + _donor_background_slider: Slider + _background_real_slider: Slider + _background_imag_slider: Slider + + _donor_line: Line2D + _donor_only_line: Line2D + _donor_fret_line: Line2D + _donor_trajectory_line: Line2D + _donor_semicircle_line: Line2D + _donor_donor_line: Line2D + _donor_background_line: Line2D + _acceptor_line: Line2D + _acceptor_only_line: Line2D + _acceptor_trajectory_line: Line2D + _acceptor_semicircle_line: Line2D + _acceptor_background_line: Line2D + _background_line: Line2D + + _donor_semicircle_ticks: SemicircleTicks | None + + def __init__( + self, + *, + frequency: float = 60.0, + donor_lifetime: float = 4.2, + acceptor_lifetime: float = 3.0, + fret_efficiency: float = 0.5, + donor_freting: float = 1.0, + donor_bleedthrough: float = 0.0, + acceptor_bleedthrough: float = 0.0, + acceptor_background: float = 0.0, + donor_background: float = 0.0, + background_real: float = 0.0, + background_imag: float = 0.0, + ax: Axes | None = None, + interactive: bool = False, + **kwargs, + ) -> None: + update_kwargs( + kwargs, + title='FRET phasor plot', + xlim=[-0.2, 1.1], + ylim=[-0.1, 0.8], + ) + kwargs['allquadrants'] = False + kwargs['grid'] = False + + if ax is not None: + interactive = False + else: + fig = pyplot.figure() + ax = fig.add_subplot() + if interactive: + w, h = fig.get_size_inches() + fig.set_size_inches(w, h * 1.66) + fig.subplots_adjust(bottom=0.45) + fcm = fig.canvas.manager + if fcm is not None: + fcm.set_window_title(kwargs['title']) + + super().__init__(ax=ax, **kwargs) + + self._fret_efficiencies = numpy.linspace(0.0, 1.0, 101) + + donor_real, donor_imag = phasor_from_lifetime( + frequency, donor_lifetime + ) + donor_fret_real, donor_fret_imag = phasor_from_lifetime( + frequency, donor_lifetime * (1.0 - fret_efficiency) + ) + acceptor_real, acceptor_imag = phasor_from_lifetime( + frequency, acceptor_lifetime + ) + donor_trajectory_real, donor_trajectory_imag = phasor_from_fret_donor( + frequency, + donor_lifetime, + fret_efficiency=self._fret_efficiencies, + donor_freting=donor_freting, + donor_background=donor_background, + background_real=background_real, + background_imag=background_imag, + ) + acceptor_trajectory_real, acceptor_trajectory_imag = ( + phasor_from_fret_acceptor( + frequency, + donor_lifetime, + acceptor_lifetime, + fret_efficiency=self._fret_efficiencies, + donor_freting=donor_freting, + donor_bleedthrough=donor_bleedthrough, + acceptor_bleedthrough=acceptor_bleedthrough, + acceptor_background=acceptor_background, + background_real=background_real, + background_imag=background_imag, + ) + ) + + # add plots + self.semicircle(frequency=frequency) + self._donor_semicircle_line = self._lines[0] + self._donor_semicircle_ticks = self._semicircle_ticks + + self.semicircle( + phasor_reference=(float(acceptor_real), float(acceptor_imag)), + use_lines=True, + ) + self._acceptor_semicircle_line = self._lines[0] + + if donor_freting < 1.0 and donor_background == 0.0: + self.line( + [donor_real, donor_fret_real], + [donor_imag, donor_fret_imag], + ) + else: + self.line([0.0, 0.0], [0.0, 0.0]) + self._donor_donor_line = self._lines[0] + + if acceptor_background > 0.0: + self.line( + [float(acceptor_real), float(background_real)], + [float(acceptor_imag), float(background_imag)], + ) + else: + self.line([0.0, 0.0], [0.0, 0.0]) + self._acceptor_background_line = self._lines[0] + + if donor_background > 0.0: + self.line( + [float(donor_real), float(background_real)], + [float(donor_imag), float(background_imag)], + ) + else: + self.line([0.0, 0.0], [0.0, 0.0]) + self._donor_background_line = self._lines[0] + + self.plot( + donor_trajectory_real, + donor_trajectory_imag, + fmt='-', + color='tab:green', + ) + self._donor_trajectory_line = self._lines[0] + + self.plot( + acceptor_trajectory_real, + acceptor_trajectory_imag, + fmt='-', + color='tab:red', + ) + self._acceptor_trajectory_line = self._lines[0] + + self.plot( + donor_real, + donor_imag, + fmt='.', + color='tab:green', + ) + self._donor_only_line = self._lines[0] + + self.plot( + donor_real, + donor_imag, + fmt='.', + color='tab:green', + ) + self._donor_fret_line = self._lines[0] + + self.plot( + acceptor_real, + acceptor_imag, + fmt='.', + color='tab:red', + ) + self._acceptor_only_line = self._lines[0] + + self.plot( + donor_trajectory_real[int(fret_efficiency * 100.0)], + donor_trajectory_imag[int(fret_efficiency * 100.0)], + fmt='o', + color='tab:green', + label='Donor', + ) + self._donor_line = self._lines[0] + + self.plot( + acceptor_trajectory_real[int(fret_efficiency * 100.0)], + acceptor_trajectory_imag[int(fret_efficiency * 100.0)], + fmt='o', + color='tab:red', + label='Acceptor', + ) + self._acceptor_line = self._lines[0] + + self.plot( + background_real, + background_imag, + fmt='o', + color='black', + label='Background', + ) + self._background_line = self._lines[0] + + if not interactive: + return + + # add sliders + axes = [] + for i in range(11): + axes.append(fig.add_axes((0.33, 0.05 + i * 0.03, 0.45, 0.01))) + + self._frequency_slider = Slider( + ax=axes[10], + label='Frequency ', + valfmt=' %.0f MHz', + valmin=10, + valmax=200, + valstep=1, + valinit=frequency, + ) + self._frequency_slider.on_changed(self._on_semicircle_changed) + + self._donor_lifetime_slider = Slider( + ax=axes[9], + label='Donor lifetime ', + valfmt=' %.1f ns', + valmin=0.1, + valmax=16.0, + valstep=0.1, + valinit=donor_lifetime, + # facecolor='tab:green', + handle_style={'edgecolor': 'tab:green'}, + ) + self._donor_lifetime_slider.on_changed(self._on_changed) + + self._acceptor_lifetime_slider = Slider( + ax=axes[8], + label='Acceptor lifetime ', + valfmt=' %.1f ns', + valmin=0.1, + valmax=16.0, + valstep=0.1, + valinit=acceptor_lifetime, + # facecolor='tab:red', + handle_style={'edgecolor': 'tab:red'}, + ) + self._acceptor_lifetime_slider.on_changed(self._on_semicircle_changed) + + self._fret_efficiency_slider = Slider( + ax=axes[7], + label='FRET efficiency ', + valfmt=' %.2f', + valmin=0.0, + valmax=1.0, + valstep=0.01, + valinit=fret_efficiency, + ) + self._fret_efficiency_slider.on_changed(self._on_changed) + + self._donor_freting_slider = Slider( + ax=axes[6], + label='Donors FRETing ', + valfmt=' %.2f', + valmin=0.0, + valmax=1.0, + valstep=0.01, + valinit=donor_freting, + # facecolor='tab:green', + handle_style={'edgecolor': 'tab:green'}, + ) + self._donor_freting_slider.on_changed(self._on_changed) + + self._donor_bleedthrough_slider = Slider( + ax=axes[5], + label='Donor bleedthrough ', + valfmt=' %.2f', + valmin=0.0, + valmax=5.0, + valstep=0.01, + valinit=donor_bleedthrough, + # facecolor='tab:red', + handle_style={'edgecolor': 'tab:red'}, + ) + self._donor_bleedthrough_slider.on_changed(self._on_changed) + + self._acceptor_bleedthrough_slider = Slider( + ax=axes[4], + label='Acceptor bleedthrough ', + valfmt=' %.2f', + valmin=0.0, + valmax=5.0, + valstep=0.01, + valinit=acceptor_bleedthrough, + # facecolor='tab:red', + handle_style={'edgecolor': 'tab:red'}, + ) + self._acceptor_bleedthrough_slider.on_changed(self._on_changed) + + self._acceptor_background_slider = Slider( + ax=axes[3], + label='Acceptor background ', + valfmt=' %.2f', + valmin=0.0, + valmax=5.0, + valstep=0.01, + valinit=acceptor_background, + # facecolor='tab:red', + handle_style={'edgecolor': 'tab:red'}, + ) + self._acceptor_background_slider.on_changed(self._on_changed) + + self._donor_background_slider = Slider( + ax=axes[2], + label='Donor background ', + valfmt=' %.2f', + valmin=0.0, + valmax=5.0, + valstep=0.01, + valinit=donor_background, + # facecolor='tab:green', + handle_style={'edgecolor': 'tab:green'}, + ) + self._donor_background_slider.on_changed(self._on_changed) + + self._background_real_slider = Slider( + ax=axes[1], + label='Background real ', + valfmt=' %.2f', + valmin=0.0, + valmax=1.0, + valstep=0.01, + valinit=background_real, + ) + self._background_real_slider.on_changed(self._on_changed) + + self._background_imag_slider = Slider( + ax=axes[0], + label='Background imag ', + valfmt=' %.2f', + valmin=0.0, + valmax=0.6, + valstep=0.01, + valinit=background_imag, + ) + self._background_imag_slider.on_changed(self._on_changed) + + def _on_semicircle_changed(self, value: Any) -> None: + """Callback function to update semicircles.""" + self._frequency = frequency = self._frequency_slider.val + acceptor_lifetime = self._acceptor_lifetime_slider.val + if self._donor_semicircle_ticks is not None: + lifetime, labels = _semicircle_ticks(frequency) + self._donor_semicircle_ticks.labels = labels + self._donor_semicircle_line.set_data( + *phasor_transform(*phasor_from_lifetime(frequency, lifetime)) + ) + self._acceptor_semicircle_line.set_data( + *phasor_transform( + *phasor_semicircle(), + *phasor_to_polar( + *phasor_from_lifetime(frequency, acceptor_lifetime) + ), + ) + ) + self._on_changed(value) + + def _on_changed(self, value: Any) -> None: + """Callback function to update plot with current slider values.""" + frequency = self._frequency_slider.val + donor_lifetime = self._donor_lifetime_slider.val + acceptor_lifetime = self._acceptor_lifetime_slider.val + fret_efficiency = self._fret_efficiency_slider.val + donor_freting = self._donor_freting_slider.val + donor_bleedthrough = self._donor_bleedthrough_slider.val + acceptor_bleedthrough = self._acceptor_bleedthrough_slider.val + acceptor_background = self._acceptor_background_slider.val + donor_background = self._donor_background_slider.val + background_real = self._background_real_slider.val + background_imag = self._background_imag_slider.val + e = int(self._fret_efficiency_slider.val * 100) + + donor_real, donor_imag = phasor_from_lifetime( + frequency, donor_lifetime + ) + donor_fret_real, donor_fret_imag = phasor_from_lifetime( + frequency, donor_lifetime * (1.0 - fret_efficiency) + ) + acceptor_real, acceptor_imag = phasor_from_lifetime( + frequency, acceptor_lifetime + ) + donor_trajectory_real, donor_trajectory_imag = phasor_from_fret_donor( + frequency, + donor_lifetime, + fret_efficiency=self._fret_efficiencies, + donor_freting=donor_freting, + donor_background=donor_background, + background_real=background_real, + background_imag=background_imag, + ) + acceptor_trajectory_real, acceptor_trajectory_imag = ( + phasor_from_fret_acceptor( + frequency, + donor_lifetime, + acceptor_lifetime, + fret_efficiency=self._fret_efficiencies, + donor_freting=donor_freting, + donor_bleedthrough=donor_bleedthrough, + acceptor_bleedthrough=acceptor_bleedthrough, + acceptor_background=acceptor_background, + background_real=background_real, + background_imag=background_imag, + ) + ) + + if donor_background > 0.0: + self._donor_background_line.set_data( + [float(donor_real), float(background_real)], + [float(donor_imag), float(background_imag)], + ) + else: + self._donor_background_line.set_data([0.0, 0.0], [0.0, 0.0]) + + if donor_freting < 1.0 and donor_background == 0.0: + self._donor_donor_line.set_data( + [donor_real, donor_fret_real], + [donor_imag, donor_fret_imag], + ) + else: + self._donor_donor_line.set_data([0.0, 0.0], [0.0, 0.0]) + + if acceptor_background > 0.0: + self._acceptor_background_line.set_data( + [float(acceptor_real), float(background_real)], + [float(acceptor_imag), float(background_imag)], + ) + else: + self._acceptor_background_line.set_data([0.0, 0.0], [0.0, 0.0]) + + self._background_line.set_data([background_real], [background_imag]) + + self._donor_only_line.set_data([donor_real], [donor_imag]) + self._donor_fret_line.set_data([donor_fret_real], [donor_fret_imag]) + self._donor_trajectory_line.set_data( + donor_trajectory_real, donor_trajectory_imag + ) + self._donor_line.set_data( + [donor_trajectory_real[e]], [donor_trajectory_imag[e]] + ) + + self._acceptor_only_line.set_data([acceptor_real], [acceptor_imag]) + self._acceptor_trajectory_line.set_data( + acceptor_trajectory_real, acceptor_trajectory_imag + ) + self._acceptor_line.set_data( + [acceptor_trajectory_real[e]], [acceptor_trajectory_imag[e]] + ) + + +class SemicircleTicks(AbstractPathEffect): + """Draw ticks on universal semicircle. + + Parameters + ---------- + size : float, optional + Length of tick in dots. + The default is ``rcParams['xtick.major.size']``. + labels : sequence of str, optional + Tick labels for each vertex in path. + **kwargs + Extra keywords passed to matplotlib's + :py:meth:`matplotlib.patheffects.AbstractPathEffect._update_gc`. + + """ + + _size: float # tick length + _labels: tuple[str, ...] # tick labels + _gc: dict[str, Any] # keywords passed to _update_gc + + def __init__( + self, + size: float | None = None, + labels: Sequence[str] | None = None, + **kwargs, + ) -> None: + super().__init__((0.0, 0.0)) + + if size is None: + self._size = pyplot.rcParams['xtick.major.size'] + else: + self._size = size + if labels is None or not labels: + self._labels = () + else: + self._labels = tuple(labels) + self._gc = kwargs + + @property + def labels(self) -> tuple[str, ...]: + """Tick labels.""" + return self._labels + + @labels.setter + def labels(self, value: Sequence[str] | None, /) -> None: + if value is None or not value: + self._labels = () + else: + self._labels = tuple(value) + + def draw_path(self, renderer, gc, tpath, affine, rgbFace=None) -> None: + """Draw path with updated gc.""" + gc0 = renderer.new_gc() + gc0.copy_properties(gc) + + # TODO: this uses private methods of the base class + gc0 = self._update_gc(gc0, self._gc) # type: ignore + trans = affine + self._offset_transform(renderer) # type: ignore + + font = FontProperties() + # approximate half size of 'x' + fontsize = renderer.points_to_pixels(font.get_size_in_points()) / 4 + size = renderer.points_to_pixels(self._size) + origin = affine.transform([[0.5, 0.0]]) + + transpath = affine.transform_path(tpath) + polys = transpath.to_polygons(closed_only=False) + + for p in polys: + # coordinates of tick ends + t = p - origin + t /= numpy.hypot(t[:, 0], t[:, 1])[:, numpy.newaxis] + d = t.copy() + t *= size + t += p + + xyt = numpy.empty((2 * p.shape[0], 2)) + xyt[0::2] = p + xyt[1::2] = t + + renderer.draw_path( + gc0, + Path(xyt, numpy.tile([Path.MOVETO, Path.LINETO], p.shape[0])), + affine.inverted() + trans, + rgbFace, + ) + if not self._labels: + continue + # coordinates of labels + t = d * size * 2.5 + t += p + + if renderer.flipy(): + h = renderer.get_canvas_width_height()[1] + else: + h = 0.0 + + for s, (x, y), (dx, _) in zip(self._labels, t, d): + # TODO: get rendered text size from matplotlib.text.Text? + # this did not work: + # Text(d[i,0], h - d[i,1], label, ha='center', va='center') + x = x + fontsize * len(s.split()[0]) * (dx - 1.0) + y = h - y + fontsize + renderer.draw_text(gc0, x, y, s, font, 0.0) + + gc0.restore() + + +def plot_phasor( + real: ArrayLike, + imag: ArrayLike, + /, + *, + style: Literal['plot', 'hist2d', 'contour'] | None = None, + allquadrants: bool | None = None, + frequency: float | None = None, + show: bool = True, + **kwargs: Any, +) -> None: + """Plot phasor coordinates. + + A simplified interface to the :py:class:`PhasorPlot` class. + + Parameters + ---------- + real : array_like + Real component of phasor coordinates. + imag : array_like + Imaginary component of phasor coordinates. + Must be of same shape as `real`. + style : {'plot', 'hist2d', 'contour'}, optional + Method used to plot phasor coordinates. + By default, if the number of coordinates are less than 65536 + and the arrays are less than three-dimensional, `'plot'` style is used, + else `'hist2d'`. + allquadrants : bool, optional + Show all quadrants of phasor space. + By default, only the first quadrant is shown. + frequency : float, optional + Frequency of phasor plot. + If provided, the universal semicircle is labeled with reference + lifetimes. + show : bool, optional, default: True + Display figure. + **kwargs + Additional parguments passed to :py:class:`PhasorPlot`, + :py:meth:`PhasorPlot.plot`, :py:meth:`PhasorPlot.hist2d`, or + :py:meth:`PhasorPlot.contour` depending on `style`. + + See Also + -------- + phasorpy.plot.PhasorPlot + :ref:`sphx_glr_tutorials_phasorpy_phasorplot.py` + + """ + init_kwargs = parse_kwargs( + kwargs, + 'ax', + 'title', + 'xlabel', + 'ylabel', + 'xlim', + 'ylim', + 'xticks', + 'yticks', + 'grid', + ) + + real = numpy.asanyarray(real) + imag = numpy.asanyarray(imag) + plot = PhasorPlot( + frequency=frequency, allquadrants=allquadrants, **init_kwargs + ) + if style is None: + style = 'plot' if real.size < 65536 and real.ndim < 3 else 'hist2d' + if style == 'plot': + plot.plot(real, imag, **kwargs) + elif style == 'hist2d': + plot.hist2d(real, imag, **kwargs) + elif style == 'contour': + plot.contour(real, imag, **kwargs) + else: + raise ValueError(f'invalid {style=}') + if show: + plot.show() + + +def plot_phasor_image( + mean: ArrayLike | None, + real: ArrayLike, + imag: ArrayLike, + *, + harmonics: int | None = None, + percentile: float | None = None, + title: str | None = None, + show: bool = True, + **kwargs: Any, +) -> None: + """Plot phasor coordinates as images. + + Preview phasor coordinates from time-resolved or hyperspectral + image stacks as returned by :py:func:`phasorpy.phasor.phasor_from_signal`. + + The last two axes are assumed to be the image axes. + Harmonics, if any, are in the first axes of `real` and `imag`. + Other axes are averaged for display. + + Parameters + ---------- + mean : array_like + Image average. Must be two or more dimensional, or None. + real : array_like + Image of real component of phasor coordinates. + The last dimensions must match shape of `mean`. + imag : array_like + Image of imaginary component of phasor coordinates. + Must be same shape as `real`. + harmonics : int, optional + Number of harmonics to display. + If `mean` is None, a nonzero value indicates the presence of harmonics + in the first axes of `mean` and `real`. Else, the presence of harmonics + is determined from the shapes of `mean` and `real`. + By default, up to 4 harmonics are displayed. + percentile : float, optional + The (q, 100-q) percentiles of image data are covered by colormaps. + By default, the complete value range of `mean` is covered, + for `real` and `imag` the range [-1..1]. + title : str, optional + Figure title. + show : bool, optional, default: True + Display figure. + **kwargs + Additional arguments passed to :func:`matplotlib.pyplot.imshow`. + + Raises + ------ + ValueError + The shapes of `mean`, `real`, and `image` do not match. + Percentile is out of range. + + """ + update_kwargs(kwargs, interpolation='nearest') + cmap = kwargs.pop('cmap', None) + shape = None + + if mean is not None: + mean = numpy.asarray(mean) + if mean.ndim < 2: + raise ValueError(f'not an image {mean.ndim=} < 2') + shape = mean.shape + mean = numpy.mean(mean.reshape(-1, *mean.shape[-2:]), axis=0) + + real = numpy.asarray(real) + imag = numpy.asarray(imag) + if real.shape != imag.shape: + raise ValueError(f'{real.shape=} != {imag.shape=}') + if real.ndim < 2: + raise ValueError(f'not an image {real.ndim=} < 2') + + if (shape is not None and real.shape[1:] == shape) or ( + shape is None and harmonics + ): + # first image dimension contains harmonics + if real.ndim < 3: + raise ValueError(f'not a multi-harmonic image {real.shape=}') + nh = real.shape[0] # number harmonics + elif shape is None or shape == real.shape: + # single harmonic + nh = 1 + else: + raise ValueError(f'shape mismatch {real.shape[1:]=} != {shape}') + + # average extra image dimensions, but not harmonics + real = numpy.mean(real.reshape(nh, -1, *real.shape[-2:]), axis=1) + imag = numpy.mean(imag.reshape(nh, -1, *imag.shape[-2:]), axis=1) + + # for MyPy + assert isinstance(mean, numpy.ndarray) or mean is None + assert isinstance(real, numpy.ndarray) + assert isinstance(imag, numpy.ndarray) + + # limit number of displayed harmonics + nh = min(4 if harmonics is None else harmonics, nh) + + # create figure with size depending on image aspect and number of harmonics + fig = pyplot.figure(layout='constrained') + w, h = fig.get_size_inches() + aspect = min(1.0, max(0.5, real.shape[-2] / real.shape[-1])) + fig.set_size_inches(w, h * 0.4 * aspect * nh + h * 0.25 * aspect) + gs = GridSpec(nh, 2 if mean is None else 3, figure=fig) + if title: + fig.suptitle(title) + + if mean is not None: + _imshow( + fig.add_subplot(gs[0, 0]), + mean, + percentile=percentile, + vmin=None, + vmax=None, + cmap=cmap, + axis=True, + title='mean', + **kwargs, + ) + + if percentile is None: + vmin = -1.0 + vmax = 1.0 + if cmap is None: + cmap = 'coolwarm_r' + else: + vmin = None + vmax = None + + for h in range(nh): + axs = [] + ax = fig.add_subplot(gs[h, -2]) + axs.append(ax) + _imshow( + ax, + real[h], + percentile=percentile, + vmin=vmin, + vmax=vmax, + cmap=cmap, + axis=mean is None and h == 0, + colorbar=percentile is not None, + title=None if h else 'G, real', + **kwargs, + ) + + ax = fig.add_subplot(gs[h, -1]) + axs.append(ax) + pos = _imshow( + ax, + imag[h], + percentile=percentile, + vmin=vmin, + vmax=vmax, + cmap=cmap, + axis=False, + colorbar=percentile is not None, + title=None if h else 'S, imag', + **kwargs, + ) + if percentile is None and h == 0: + fig.colorbar(pos, ax=axs, shrink=0.4, location='bottom') + + if show: + pyplot.show() + + +def plot_signal_image( + signal: ArrayLike, + /, + *, + axis: int | None = None, + percentile: float | Sequence[float] | None = None, + title: str | None = None, + show: bool = True, + **kwargs: Any, +) -> None: + """Plot average image and signal along axis. + + Preview time-resolved or hyperspectral image stacks to be anayzed with + :py:func:`phasorpy.phasor.phasor_from_signal`. + + The last two axes, excluding `axis`, are assumed to be the image axes. + Other axes are averaged for image display. + + Parameters + ---------- + signal : array_like + Image stack. Must be three or more dimensional. + axis : int, optional, default: -1 + Axis over which phasor coordinates would be computed. + The default is the last axis (-1). + percentile : float or [float, float], optional + The [q, 100-q] percentiles of image data are covered by colormaps. + By default, the complete value range of `mean` is covered, + for `real` and `imag` the range [-1..1]. + title : str, optional + Figure title. + show : bool, optional, default: True + Display figure. + **kwargs + Additional arguments passed to :func:`matplotlib.pyplot.imshow`. + + Raises + ------ + ValueError + Signal is not an image stack. + Percentile is out of range. + + """ + # TODO: add option to separate channels? + # TODO: add option to plot non-images? + update_kwargs(kwargs, interpolation='nearest') + signal = numpy.asarray(signal) + if signal.ndim < 3: + raise ValueError(f'not an image stack {signal.ndim=} < 3') + + if axis is None: + axis = -1 + axis %= signal.ndim + + # for MyPy + assert isinstance(signal, numpy.ndarray) + + fig = pyplot.figure(layout='constrained') + if title: + fig.suptitle(title) + w, h = fig.get_size_inches() + fig.set_size_inches(w, h * 0.7) + gs = GridSpec(1, 2, figure=fig, width_ratios=(1, 1)) + + # histogram + axes = list(range(signal.ndim)) + del axes[axis] + ax = fig.add_subplot(gs[0, 1]) + ax.set_title(f'mean, axis {axis}') + ax.plot(signal.mean(axis=tuple(axes))) + + # image + axes = list(sorted(axes[:-2] + [axis])) + ax = fig.add_subplot(gs[0, 0]) + _imshow( + ax, + signal.mean(axis=tuple(axes)), + percentile=percentile, + shrink=0.5, + title='mean', + ) + + if show: + pyplot.show() + + +def plot_polar_frequency( + frequency: ArrayLike, + phase: ArrayLike, + modulation: ArrayLike, + *, + ax: Axes | None = None, + title: str | None = None, + show: bool = True, + **kwargs, +) -> None: + """Plot phase and modulation verus frequency. + + Parameters + ---------- + frequency : array_like, shape (n, ) + Laser pulse or modulation frequency in MHz. + phase : array_like + Angular component of polar coordinates in radians. + modulation : array_like + Radial component of polar coordinates. + ax : matplotlib axes, optional + Matplotlib axes used for plotting. + By default, a new subplot axes is created. + title : str, optional + Figure title. The default is "Multi-frequency plot". + show : bool, optional, default: True + Display figure. + **kwargs + Additional arguments passed to :py:func:`matplotlib.pyplot.plot`. + + """ + # TODO: make this customizable: labels, colors, ... + if ax is None: + ax = pyplot.subplots()[1] + if title is None: + title = 'Multi-frequency plot' + if title: + ax.set_title(title) + ax.set_xscale('log', base=10) + ax.set_xlabel('Frequency (MHz)') + + phase = numpy.asarray(phase) + if phase.ndim < 2: + phase = phase.reshape(-1, 1) + modulation = numpy.asarray(modulation) + if modulation.ndim < 2: + modulation = modulation.reshape(-1, 1) + + ax.set_ylabel('Phase (°)', color='tab:blue') + ax.set_yticks([0.0, 30.0, 60.0, 90.0]) + for phi in phase.T: + ax.plot(frequency, numpy.rad2deg(phi), color='tab:blue', **kwargs) + ax = ax.twinx() # type: ignore + + ax.set_ylabel('Modulation (%)', color='tab:red') + ax.set_yticks([0.0, 25.0, 50.0, 75.0, 100.0]) + for mod in modulation.T: + ax.plot(frequency, mod * 100, color='tab:red', **kwargs) + if show: + pyplot.show() + +def _imshow( + ax: Axes, + image: NDArray[Any], + /, + *, + percentile: float | Sequence[float] | None = None, + vmin: float | None = None, + vmax: float | None = None, + colorbar: bool = True, + shrink: float | None = None, + axis: bool = True, + title: str | None = None, + **kwargs, +) -> AxesImage: + """Plot image array. + + Convenience wrapper around :py:func:`matplotlib.pyplot.imshow`. + + """ + update_kwargs(kwargs, interpolation='none') + if percentile is not None: + if isinstance(percentile, Sequence): + percentile = percentile[0], percentile[1] + else: + # percentile = max(0.0, min(50, percentile)) + percentile = percentile, 100.0 - percentile + if ( + percentile[0] >= percentile[1] + or percentile[0] < 0 + or percentile[1] > 100 + ): + raise ValueError(f'{percentile=} out of range') + vmin, vmax = numpy.percentile(image, percentile) + pos = ax.imshow(image, vmin=vmin, vmax=vmax, **kwargs) + if colorbar: + if percentile is not None and vmin is not None and vmax is not None: + ticks = vmin, vmax + else: + ticks = None + fig = ax.get_figure() + if fig is not None: + if shrink is None: + shrink = 0.8 + fig.colorbar(pos, shrink=shrink, location='bottom', ticks=ticks) + if title: + ax.set_title(title) + if not axis: + ax.set_axis_off() + # ax.set_anchor('C') + return pos + + +def _semicircle_ticks( + frequency: float, + lifetime: Sequence[float] | None = None, + labels: Sequence[str] | None = None, +) -> tuple[tuple[float, ...], tuple[str, ...]]: + """Return semicircle tick lifetimes and labels at frequency.""" + if lifetime is None: + lifetime = [0.0] + [ + 2**t + for t in range(-8, 32) + if phasor_from_lifetime(frequency, 2**t)[1] >= 0.18 + ] + unit = 'ns' + else: + unit = '' + if labels is None: + labels = [f'{tau:g}' for tau in lifetime] + try: + labels[2] = f'{labels[2]} {unit}' + except IndexError: + pass + return tuple(lifetime), tuple(labels) From 788f43e94c416ec802ebe9765a484bb60ff06fde Mon Sep 17 00:00:00 2001 From: bruno-pannunzio Date: Mon, 22 Apr 2024 18:54:59 -0300 Subject: [PATCH 13/21] Discard `plot` changes --- src/phasorpy/plot.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/phasorpy/plot.py b/src/phasorpy/plot.py index e55814fa..27aa2d0a 100644 --- a/src/phasorpy/plot.py +++ b/src/phasorpy/plot.py @@ -14,7 +14,6 @@ 'plot_phasor_image', 'plot_signal_image', 'plot_polar_frequency', - 'two_components_histogram', ] import math @@ -45,7 +44,6 @@ parse_kwargs, phasor_from_polar_scalar, phasor_to_polar_scalar, - project_phasor_to_line, sort_coordinates, update_kwargs, ) @@ -1820,6 +1818,7 @@ def plot_polar_frequency( if show: pyplot.show() + def _imshow( ax: Axes, image: NDArray[Any], From 8e6e6fe5af5303f5d289e0433c1b6dc52869a110 Mon Sep 17 00:00:00 2001 From: bruno-pannunzio Date: Mon, 22 Apr 2024 19:02:53 -0300 Subject: [PATCH 14/21] Update `components` module description --- src/phasorpy/components.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/phasorpy/components.py b/src/phasorpy/components.py index 26121a93..c3576ef0 100644 --- a/src/phasorpy/components.py +++ b/src/phasorpy/components.py @@ -2,7 +2,22 @@ The ``phasorpy.components`` module provides functions to: + - calculate fractions of two components of known location by projecting to + line between components: + - :py:func:`two_fractions_from_phasor` + + - calculate phasor coordinates of second component if only one is + known (not implemented) + + - calculate fractions of three or four known components by using higher + harmonic information (not implemented) + + - calculate fractions of two or three components of known location by + resolving graphically with histogram (not implemented) + + - blindly resolve fractions of n components by using harmonic + information (not implemented) """ from __future__ import annotations From 342cd3cf0090f3c1cabbccd27f52a70957a23e14 Mon Sep 17 00:00:00 2001 From: bruno-pannunzio Date: Mon, 22 Apr 2024 19:06:48 -0300 Subject: [PATCH 15/21] Apply code standards --- src/phasorpy/plot.py | 54 +++++++++++++++++++++------------------- tests/test__utils.py | 33 ++++++++++++++---------- tests/test_components.py | 46 ++++++++++++++++++++++++---------- 3 files changed, 81 insertions(+), 52 deletions(-) diff --git a/src/phasorpy/plot.py b/src/phasorpy/plot.py index 27aa2d0a..1716784d 100644 --- a/src/phasorpy/plot.py +++ b/src/phasorpy/plot.py @@ -943,19 +943,20 @@ def __init__( background_real=background_real, background_imag=background_imag, ) - acceptor_trajectory_real, acceptor_trajectory_imag = ( - phasor_from_fret_acceptor( - frequency, - donor_lifetime, - acceptor_lifetime, - fret_efficiency=self._fret_efficiencies, - donor_freting=donor_freting, - donor_bleedthrough=donor_bleedthrough, - acceptor_bleedthrough=acceptor_bleedthrough, - acceptor_background=acceptor_background, - background_real=background_real, - background_imag=background_imag, - ) + ( + acceptor_trajectory_real, + acceptor_trajectory_imag, + ) = phasor_from_fret_acceptor( + frequency, + donor_lifetime, + acceptor_lifetime, + fret_efficiency=self._fret_efficiencies, + donor_freting=donor_freting, + donor_bleedthrough=donor_bleedthrough, + acceptor_bleedthrough=acceptor_bleedthrough, + acceptor_background=acceptor_background, + background_real=background_real, + background_imag=background_imag, ) # add plots @@ -1259,19 +1260,20 @@ def _on_changed(self, value: Any) -> None: background_real=background_real, background_imag=background_imag, ) - acceptor_trajectory_real, acceptor_trajectory_imag = ( - phasor_from_fret_acceptor( - frequency, - donor_lifetime, - acceptor_lifetime, - fret_efficiency=self._fret_efficiencies, - donor_freting=donor_freting, - donor_bleedthrough=donor_bleedthrough, - acceptor_bleedthrough=acceptor_bleedthrough, - acceptor_background=acceptor_background, - background_real=background_real, - background_imag=background_imag, - ) + ( + acceptor_trajectory_real, + acceptor_trajectory_imag, + ) = phasor_from_fret_acceptor( + frequency, + donor_lifetime, + acceptor_lifetime, + fret_efficiency=self._fret_efficiencies, + donor_freting=donor_freting, + donor_bleedthrough=donor_bleedthrough, + acceptor_bleedthrough=acceptor_bleedthrough, + acceptor_background=acceptor_background, + background_real=background_real, + background_imag=background_imag, ) if donor_background > 0.0: diff --git a/tests/test__utils.py b/tests/test__utils.py index 27d9ce16..2d85d503 100644 --- a/tests/test__utils.py +++ b/tests/test__utils.py @@ -2,8 +2,8 @@ import math -import pytest import numpy +import pytest from numpy.testing import assert_allclose from phasorpy._utils import ( @@ -13,10 +13,10 @@ parse_kwargs, phasor_from_polar_scalar, phasor_to_polar_scalar, + project_phasor_to_line, scale_matrix, sort_coordinates, update_kwargs, - project_phasor_to_line, ) @@ -139,24 +139,31 @@ def test_circle_circle_intersection(): def test_project_phasor_to_line(): """Test project_phasor_to_line function.""" assert_allclose( - project_phasor_to_line([0.7, 0.5, 0.3],[0.3, 0.4, 0.3],[0.2, 0.9],[0.4, 0.3]), - (numpy.array([0.704, 0.494, 0.312]), numpy.array([0.328, 0.358, 0.384])) + project_phasor_to_line( + [0.7, 0.5, 0.3], [0.3, 0.4, 0.3], [0.2, 0.9], [0.4, 0.3] + ), + ( + numpy.array([0.704, 0.494, 0.312]), + numpy.array([0.328, 0.358, 0.384]), + ), ) assert_allclose( - project_phasor_to_line([0.1, 1.0],[0.5, 0.5],[0.2, 0.9],[0.4, 0.3]), - (numpy.array([0.2, 0.9]), numpy.array([0.4, 0.3])) + project_phasor_to_line([0.1, 1.0], [0.5, 0.5], [0.2, 0.9], [0.4, 0.3]), + (numpy.array([0.2, 0.9]), numpy.array([0.4, 0.3])), ) assert_allclose( - project_phasor_to_line([0.1, 1.0],[0.5, 0.5],[0.2, 0.9],[0.4, 0.3], clip=False), - (numpy.array([0.088, 0.97 ]), numpy.array([0.416, 0.29 ])) + project_phasor_to_line( + [0.1, 1.0], [0.5, 0.5], [0.2, 0.9], [0.4, 0.3], clip=False + ), + (numpy.array([0.088, 0.97]), numpy.array([0.416, 0.29])), ) with pytest.raises(ValueError): - project_phasor_to_line([0],[0],[0.1, 0.2],[0.1, 0.2]) + project_phasor_to_line([0], [0], [0.1, 0.2], [0.1, 0.2]) with pytest.raises(ValueError): - project_phasor_to_line([0],[0],[0.3],[0.1, 0.2]) + project_phasor_to_line([0], [0], [0.3], [0.1, 0.2]) with pytest.raises(ValueError): - project_phasor_to_line([0],[0],[0.1, 0.2],[0.3]) + project_phasor_to_line([0], [0], [0.1, 0.2], [0.3]) with pytest.raises(ValueError): - project_phasor_to_line([0],[0],[0.1],[0.3]) + project_phasor_to_line([0], [0], [0.1], [0.3]) with pytest.raises(ValueError): - project_phasor_to_line([0],[0],[0.1, 0.1, 0,1],[0.1, 0,2]) \ No newline at end of file + project_phasor_to_line([0], [0], [0.1, 0.1, 0, 1], [0.1, 0, 2]) diff --git a/tests/test_components.py b/tests/test_components.py index e0b5d95d..13fec3f6 100644 --- a/tests/test_components.py +++ b/tests/test_components.py @@ -2,24 +2,44 @@ import numpy import pytest -from numpy.testing import ( - assert_allclose, -) -from phasorpy.components import ( - two_fractions_from_phasor, -) +from numpy.testing import assert_allclose + +from phasorpy.components import two_fractions_from_phasor + def test_two_fractions_from_phasor(): """Test two_fractions_from_phasor function.""" - assert_allclose(two_fractions_from_phasor([0.2, 0.5, 0.7],[0.2, 0.4, 0.3],[0.0582399, 0.79830002],[0.23419652, 0.40126936]), (numpy.array([0.82766281, 0.38389704, 0.15577992]), numpy.array([0.17233719, 0.61610296, 0.84422008]))) - assert_allclose(two_fractions_from_phasor([0.0, 0.5, 0.9],[0.4, 0.4, 0.6],[0.0582399, 0.79830002],[0.23419652, 0.40126936]), (numpy.array([1., 0.38389704, 0.]), numpy.array([0., 0.61610296, 1.]))) + assert_allclose( + two_fractions_from_phasor( + [0.2, 0.5, 0.7], + [0.2, 0.4, 0.3], + [0.0582399, 0.79830002], + [0.23419652, 0.40126936], + ), + ( + numpy.array([0.82766281, 0.38389704, 0.15577992]), + numpy.array([0.17233719, 0.61610296, 0.84422008]), + ), + ) + assert_allclose( + two_fractions_from_phasor( + [0.0, 0.5, 0.9], + [0.4, 0.4, 0.6], + [0.0582399, 0.79830002], + [0.23419652, 0.40126936], + ), + ( + numpy.array([1.0, 0.38389704, 0.0]), + numpy.array([0.0, 0.61610296, 1.0]), + ), + ) with pytest.raises(ValueError): - two_fractions_from_phasor([0],[0],[0.1, 0.2],[0.1, 0.2]) + two_fractions_from_phasor([0], [0], [0.1, 0.2], [0.1, 0.2]) with pytest.raises(ValueError): - two_fractions_from_phasor([0],[0],[0.3],[0.1, 0.2]) + two_fractions_from_phasor([0], [0], [0.3], [0.1, 0.2]) with pytest.raises(ValueError): - two_fractions_from_phasor([0],[0],[0.1, 0.2],[0.3]) + two_fractions_from_phasor([0], [0], [0.1, 0.2], [0.3]) with pytest.raises(ValueError): - two_fractions_from_phasor([0],[0],[0.1],[0.3]) + two_fractions_from_phasor([0], [0], [0.1], [0.3]) with pytest.raises(ValueError): - two_fractions_from_phasor([0],[0],[0.1, 0.1, 0,1],[0.1, 0,2]) \ No newline at end of file + two_fractions_from_phasor([0], [0], [0.1, 0.1, 0, 1], [0.1, 0, 2]) From a2a46cf90964c8f61e9163d08eb47038f9d8c23e Mon Sep 17 00:00:00 2001 From: bruno-pannunzio Date: Tue, 23 Apr 2024 15:44:01 -0300 Subject: [PATCH 16/21] Minor fixes mainly to documentation of tutorial and components functions --- docs/api/components.rst | 2 +- docs/api/index.rst | 2 +- src/phasorpy/components.py | 25 ++++++------ tests/test__utils.py | 9 ++--- tests/test_components.py | 9 ++--- tutorials/phasorpy_components.py | 66 ++++++++++++++------------------ 6 files changed, 52 insertions(+), 61 deletions(-) diff --git a/docs/api/components.rst b/docs/api/components.rst index 128bfc44..3e8d43a0 100644 --- a/docs/api/components.rst +++ b/docs/api/components.rst @@ -1,5 +1,5 @@ phasorpy.components ---------------- +------------------- .. automodule:: phasorpy.components :members: diff --git a/docs/api/index.rst b/docs/api/index.rst index c0148417..1ee1b890 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -14,9 +14,9 @@ PhasorPy library version |version|. phasorpy phasor + components plot io - components color datasets utils diff --git a/src/phasorpy/components.py b/src/phasorpy/components.py index c3576ef0..dcfd72c9 100644 --- a/src/phasorpy/components.py +++ b/src/phasorpy/components.py @@ -2,22 +2,23 @@ The ``phasorpy.components`` module provides functions to: - - calculate fractions of two components of known location by projecting to - line between components: +- calculate fractions of two components of known location by projecting to + line between components: - - :py:func:`two_fractions_from_phasor` + - :py:func:`two_fractions_from_phasor` - - calculate phasor coordinates of second component if only one is - known (not implemented) - - - calculate fractions of three or four known components by using higher - harmonic information (not implemented) +- calculate phasor coordinates of second component if only one is + known (not implemented) - - calculate fractions of two or three components of known location by - resolving graphically with histogram (not implemented) +- calculate fractions of three or four known components by using higher + harmonic information (not implemented) + +- calculate fractions of two or three components of known location by + resolving graphically with histogram (not implemented) + +- blindly resolve fractions of n components by using harmonic + information (not implemented) - - blindly resolve fractions of n components by using harmonic - information (not implemented) """ from __future__ import annotations diff --git a/tests/test__utils.py b/tests/test__utils.py index 2d85d503..2b88edee 100644 --- a/tests/test__utils.py +++ b/tests/test__utils.py @@ -2,7 +2,6 @@ import math -import numpy import pytest from numpy.testing import assert_allclose @@ -143,19 +142,19 @@ def test_project_phasor_to_line(): [0.7, 0.5, 0.3], [0.3, 0.4, 0.3], [0.2, 0.9], [0.4, 0.3] ), ( - numpy.array([0.704, 0.494, 0.312]), - numpy.array([0.328, 0.358, 0.384]), + [0.704, 0.494, 0.312], + [0.328, 0.358, 0.384], ), ) assert_allclose( project_phasor_to_line([0.1, 1.0], [0.5, 0.5], [0.2, 0.9], [0.4, 0.3]), - (numpy.array([0.2, 0.9]), numpy.array([0.4, 0.3])), + ([0.2, 0.9], [0.4, 0.3]), ) assert_allclose( project_phasor_to_line( [0.1, 1.0], [0.5, 0.5], [0.2, 0.9], [0.4, 0.3], clip=False ), - (numpy.array([0.088, 0.97]), numpy.array([0.416, 0.29])), + ([0.088, 0.97], [0.416, 0.29]), ) with pytest.raises(ValueError): project_phasor_to_line([0], [0], [0.1, 0.2], [0.1, 0.2]) diff --git a/tests/test_components.py b/tests/test_components.py index 13fec3f6..2f32e1c5 100644 --- a/tests/test_components.py +++ b/tests/test_components.py @@ -1,6 +1,5 @@ """Tests for the phasorpy.components module.""" -import numpy import pytest from numpy.testing import assert_allclose @@ -17,8 +16,8 @@ def test_two_fractions_from_phasor(): [0.23419652, 0.40126936], ), ( - numpy.array([0.82766281, 0.38389704, 0.15577992]), - numpy.array([0.17233719, 0.61610296, 0.84422008]), + [0.82766281, 0.38389704, 0.15577992], + [0.17233719, 0.61610296, 0.84422008], ), ) assert_allclose( @@ -29,8 +28,8 @@ def test_two_fractions_from_phasor(): [0.23419652, 0.40126936], ), ( - numpy.array([1.0, 0.38389704, 0.0]), - numpy.array([0.0, 0.61610296, 1.0]), + [1.0, 0.38389704, 0.0], + [0.0, 0.61610296, 1.0], ), ) with pytest.raises(ValueError): diff --git a/tutorials/phasorpy_components.py b/tutorials/phasorpy_components.py index b97dedf1..f6154dce 100644 --- a/tutorials/phasorpy_components.py +++ b/tutorials/phasorpy_components.py @@ -1,12 +1,8 @@ """ Component analysis -=========== +================== -An introduction to component analysis in the phasor space. The -:py:func:`phasorpy.phasor.phasor_from_lifetime` function is used to calculate -phasor coordinates as a function of frequency, single or multiple lifetime -components, and the pre-exponential amplitudes or fractional intensities of the -components. +An introduction to component analysis in the phasor space. """ @@ -22,10 +18,12 @@ # %% # Fractions of combination of two components -# ------------------ +# ------------------------------------------ # -# A phasor that lies in the line between two components with 0.25 contribution -# of the first components and 0.75 contribution of the second component: +# The phasor coordinate of a combination of two lifetime components lie on +# the line between the two components. For example, a combination with 25% +# contribution of a component with lifetime 8.0 ns and 75% contribution of +# a second component with lifetime 1.0 ns at 80 MHz: frequency = 80.0 components_lifetimes = [8.0, 1.0] @@ -35,32 +33,29 @@ components_real, components_imag = phasor_from_lifetime( frequency, components_lifetimes ) -plot = PhasorPlot( - frequency=frequency, title='Phasor lying on the line between components' -) +plot = PhasorPlot(frequency=frequency, title='Combination of two components') plot.plot(components_real, components_imag, fmt='o-') plot.plot(real, imag) plot.show() # %% - -# If we know the location of both components, we can compute the contribution -# of both components to the phasor point that lies in the line between the two -# components: +# If the location of both components is known, their contributions +# to the phasor point that lies on the line between the components +# can be calculated: ( fraction_of_first_component, fraction_of_second_component, ) = two_fractions_from_phasor(real, imag, components_real, components_imag) -print('Fraction of first component: ', fraction_of_first_component) -print('Fraction of second component: ', fraction_of_second_component) +print(f'Fraction of first component: {fraction_of_first_component:.3f}') +print(f'Fraction of second component: {fraction_of_second_component:.3f}') # %% # Contribution of two known components in multiple phasors -# ------------------ +# -------------------------------------------------------- # -# Phasors can have different contributions of two components with known phasor -# coordinates: +# Phasors can have different contributions of two components with known +# phasor coordinates: real, imag = numpy.random.multivariate_normal( (0.6, 0.35), [[8e-3, 1e-3], [1e-3, 1e-3]], (100, 100) @@ -74,27 +69,24 @@ plot.show() # %% -# If we know the phasor coordinates of two components that contribute to -# multiple phasors, we can compute the contribution of both components for each -# phasor and plot the distributions: +# If the phasor coordinates of two components contributing to multiple +# phasors are known, their fractional contributions to each phasor coordinate +# can be calculated and plotted as histograms: ( fraction_from_first_component, fraction_from_second_component, ) = two_fractions_from_phasor(real, imag, components_real, components_imag) - -plt.figure() -plt.hist(fraction_from_first_component.flatten(), range=(0, 1), bins=100) -plt.title('Histogram of fractions of first component') -plt.xlabel('Fraction of first component') -plt.ylabel('Counts') -plt.show() - -plt.figure() -plt.hist(fraction_from_second_component.flatten(), range=(0, 1), bins=100) -plt.title('Histogram of fractions of second component') -plt.xlabel('Fraction of second component') -plt.ylabel('Counts') +fig, ax = plt.subplots(2, 1, figsize=(8, 8)) +ax[0].hist(fraction_from_first_component.flatten(), range=(0, 1), bins=100) +ax[0].set_title('Histogram of fractions of first component') +ax[0].set_xlabel('Fraction of first component') +ax[0].set_ylabel('Counts') +ax[1].hist(fraction_from_second_component.flatten(), range=(0, 1), bins=100) +ax[1].set_title('Histogram of fractions of second component') +ax[1].set_xlabel('Fraction of second component') +ax[1].set_ylabel('Counts') +plt.tight_layout() plt.show() # %% From 4eaa807f0f5809bc8c82c1b161ffca6520cda921 Mon Sep 17 00:00:00 2001 From: bruno-pannunzio Date: Tue, 23 Apr 2024 19:52:37 -0300 Subject: [PATCH 17/21] Minor fixes to `phasorpy_components` tutorial --- tutorials/phasorpy_components.py | 35 +++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/tutorials/phasorpy_components.py b/tutorials/phasorpy_components.py index f6154dce..d6c8e717 100644 --- a/tutorials/phasorpy_components.py +++ b/tutorials/phasorpy_components.py @@ -27,8 +27,9 @@ frequency = 80.0 components_lifetimes = [8.0, 1.0] +component_fractions = [0.25, 0.75] real, imag = phasor_from_lifetime( - frequency, components_lifetimes, [0.25, 0.75] + frequency, components_lifetimes, component_fractions ) components_real, components_imag = phasor_from_lifetime( frequency, components_lifetimes @@ -47,7 +48,7 @@ fraction_of_first_component, fraction_of_second_component, ) = two_fractions_from_phasor(real, imag, components_real, components_imag) -print(f'Fraction of first component: {fraction_of_first_component:.3f}') +print(f'Fraction of first component: {fraction_of_first_component:.3f}') print(f'Fraction of second component: {fraction_of_second_component:.3f}') # %% @@ -62,7 +63,7 @@ ).T plot = PhasorPlot( frequency=frequency, - title='Phasor with contibution of two known components', + title='Phasor with contribution of two known components', ) plot.hist2d(real, imag, cmap='plasma') plot.plot(*phasor_from_lifetime(frequency, components_lifetimes), fmt='o-') @@ -77,15 +78,25 @@ fraction_from_first_component, fraction_from_second_component, ) = two_fractions_from_phasor(real, imag, components_real, components_imag) -fig, ax = plt.subplots(2, 1, figsize=(8, 8)) -ax[0].hist(fraction_from_first_component.flatten(), range=(0, 1), bins=100) -ax[0].set_title('Histogram of fractions of first component') -ax[0].set_xlabel('Fraction of first component') -ax[0].set_ylabel('Counts') -ax[1].hist(fraction_from_second_component.flatten(), range=(0, 1), bins=100) -ax[1].set_title('Histogram of fractions of second component') -ax[1].set_xlabel('Fraction of second component') -ax[1].set_ylabel('Counts') +fig, ax = plt.subplots() +ax.hist( + fraction_from_first_component.flatten(), + range=(0, 1), + bins=100, + alpha=0.75, + label='First', +) +ax.hist( + fraction_from_second_component.flatten(), + range=(0, 1), + bins=100, + alpha=0.75, + label='Second', +) +ax.set_title('Histograms of fractions of first and second component') +ax.set_xlabel('Fraction') +ax.set_ylabel('Counts') +ax.legend() plt.tight_layout() plt.show() From c115a414c07a55eb8bd37bde0c8b15860bb9420e Mon Sep 17 00:00:00 2001 From: bruno-pannunzio Date: Thu, 25 Apr 2024 11:52:53 -0300 Subject: [PATCH 18/21] Modified check for equal components in `two_fractions_from_phasor`. Added test that fails for `two_fractions_from_phasor` with mutlitple channels. --- src/phasorpy/_utils.py | 18 +++++++++++------- src/phasorpy/components.py | 24 +++++++++++++++--------- tests/test__utils.py | 2 +- tests/test_components.py | 19 ++++++++++++++++++- 4 files changed, 45 insertions(+), 18 deletions(-) diff --git a/src/phasorpy/_utils.py b/src/phasorpy/_utils.py index c70c938f..5d3e097b 100644 --- a/src/phasorpy/_utils.py +++ b/src/phasorpy/_utils.py @@ -276,7 +276,7 @@ def project_phasor_to_line( """Return projected phasor coordinates to the line that joins two phasors. By default, the points are clipped to the line segment between components - and the axis into which project the phasor can also be selected. + and the projection is done into the last axis. >>> project_phasor_to_line( ... [0.6, 0.5, 0.4], [0.4, 0.3, 0.2], [0.2, 0.9], [0.4, 0.3] @@ -288,18 +288,22 @@ def project_phasor_to_line( imag = numpy.copy(imag) real_components = numpy.asarray(real_components) imag_components = numpy.asarray(imag_components) - if real_components.size != 2: - raise ValueError(f'{real_components.size=} must have two coordinates') - if imag_components.size != 2: - raise ValueError(f'{imag_components.size=} must have two coordinates') - if numpy.all(real_components == imag_components): - raise ValueError('components must have different coordinates') + if real_components.shape != (2,): + raise ValueError(f'{real_components.shape=} != (2,)') + if imag_components.shape != (2,): + raise ValueError(f'{imag_components.shape=} != (2,)') first_component_phasor = numpy.array( [real_components[0], imag_components[0]] ) second_component_phasor = numpy.array( [real_components[1], imag_components[1]] ) + total_distance_between_components = math.sqrt( + (second_component_phasor[0] - first_component_phasor[0]) ** 2 + + (second_component_phasor[1] - first_component_phasor[1]) ** 2 + ) + if math.isclose(total_distance_between_components, 0, abs_tol = 1e-6): + raise ValueError('components must have different coordinates') line_vector = second_component_phasor - first_component_phasor line_length = numpy.linalg.norm(line_vector) line_direction = line_vector / line_length diff --git a/src/phasorpy/components.py b/src/phasorpy/components.py index dcfd72c9..bd4827e5 100644 --- a/src/phasorpy/components.py +++ b/src/phasorpy/components.py @@ -64,6 +64,12 @@ def two_fractions_from_phasor( fraction_of_second_component : ndarray Fractions of the second component. + Notes + ----- + For the moment, calculation of fraction of components from different + channels or frequencies is not supported. Only one pair of components can + be analyzed and will be broadcasted to all channels/frequencies. + Raises ------ ValueError @@ -81,15 +87,10 @@ def two_fractions_from_phasor( """ real_components = numpy.asarray(real_components) imag_components = numpy.asarray(imag_components) - if real_components.size != 2: - raise ValueError(f'{real_components.size=} must have two coordinates') - if imag_components.size != 2: - raise ValueError(f'{imag_components.size=} must have two coordinates') - if numpy.all(real_components == imag_components): - raise ValueError('components must have different coordinates') - projected_real, projected_imag = project_phasor_to_line( - real, imag, real_components, imag_components - ) + if real_components.shape != (2,): + raise ValueError(f'{real_components.shape=} != (2,)') + if imag_components.shape != (2,): + raise ValueError(f'{imag_components.shape=} != (2,)') first_component_phasor = numpy.array( [real_components[0], imag_components[0]] ) @@ -100,6 +101,11 @@ def two_fractions_from_phasor( (second_component_phasor[0] - first_component_phasor[0]) ** 2 + (second_component_phasor[1] - first_component_phasor[1]) ** 2 ) + if math.isclose(total_distance_between_components, 0, abs_tol = 1e-6): + raise ValueError('components must have different coordinates') + projected_real, projected_imag = project_phasor_to_line( + real, imag, real_components, imag_components + ) distances_to_first_component = numpy.sqrt( (numpy.array(projected_real) - first_component_phasor[0]) ** 2 + (numpy.array(projected_imag) - first_component_phasor[1]) ** 2 diff --git a/tests/test__utils.py b/tests/test__utils.py index 2b88edee..5f6a9160 100644 --- a/tests/test__utils.py +++ b/tests/test__utils.py @@ -157,7 +157,7 @@ def test_project_phasor_to_line(): ([0.088, 0.97], [0.416, 0.29]), ) with pytest.raises(ValueError): - project_phasor_to_line([0], [0], [0.1, 0.2], [0.1, 0.2]) + project_phasor_to_line([0], [0], [0.1, 0.1], [0.2, 0.2]) with pytest.raises(ValueError): project_phasor_to_line([0], [0], [0.3], [0.1, 0.2]) with pytest.raises(ValueError): diff --git a/tests/test_components.py b/tests/test_components.py index 2f32e1c5..b97704fe 100644 --- a/tests/test_components.py +++ b/tests/test_components.py @@ -33,7 +33,7 @@ def test_two_fractions_from_phasor(): ), ) with pytest.raises(ValueError): - two_fractions_from_phasor([0], [0], [0.1, 0.2], [0.1, 0.2]) + two_fractions_from_phasor([0], [0], [0.1, 0.1], [0.2, 0.2]) with pytest.raises(ValueError): two_fractions_from_phasor([0], [0], [0.3], [0.1, 0.2]) with pytest.raises(ValueError): @@ -42,3 +42,20 @@ def test_two_fractions_from_phasor(): two_fractions_from_phasor([0], [0], [0.1], [0.3]) with pytest.raises(ValueError): two_fractions_from_phasor([0], [0], [0.1, 0.1, 0, 1], [0.1, 0, 2]) + + +@pytest.mark.xfail +def test_two_fractions_from_phasor_channels(): + """Test two_fractions_from_phasor function for multiple channels.""" + assert_allclose( + two_fractions_from_phasor( + [[[0.1, 0.2, 0.3]]], + [[[0.1, 0.2, 0.3]]], + [[0.2, 0.2, 0.2], [0.9, 0.9, 0.9]], + [[0.4, 0.4, 0.4], [0.3, 0.3, 0.3]], + ), + ( + [[[1., 0.96, 0.84]]], + [[[0., 0.04, 0.16]]], + ), + ) From 0d08625d5d08a9c4ca3f16aea00ef8e89b8ac21c Mon Sep 17 00:00:00 2001 From: bruno-pannunzio Date: Thu, 25 Apr 2024 11:56:42 -0300 Subject: [PATCH 19/21] Applied code standards. --- src/phasorpy/_utils.py | 2 +- src/phasorpy/components.py | 4 ++-- tests/test_components.py | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/phasorpy/_utils.py b/src/phasorpy/_utils.py index 5d3e097b..779ab115 100644 --- a/src/phasorpy/_utils.py +++ b/src/phasorpy/_utils.py @@ -302,7 +302,7 @@ def project_phasor_to_line( (second_component_phasor[0] - first_component_phasor[0]) ** 2 + (second_component_phasor[1] - first_component_phasor[1]) ** 2 ) - if math.isclose(total_distance_between_components, 0, abs_tol = 1e-6): + if math.isclose(total_distance_between_components, 0, abs_tol=1e-6): raise ValueError('components must have different coordinates') line_vector = second_component_phasor - first_component_phasor line_length = numpy.linalg.norm(line_vector) diff --git a/src/phasorpy/components.py b/src/phasorpy/components.py index bd4827e5..49e7bf6a 100644 --- a/src/phasorpy/components.py +++ b/src/phasorpy/components.py @@ -68,7 +68,7 @@ def two_fractions_from_phasor( ----- For the moment, calculation of fraction of components from different channels or frequencies is not supported. Only one pair of components can - be analyzed and will be broadcasted to all channels/frequencies. + be analyzed and will be broadcasted to all channels/frequencies. Raises ------ @@ -101,7 +101,7 @@ def two_fractions_from_phasor( (second_component_phasor[0] - first_component_phasor[0]) ** 2 + (second_component_phasor[1] - first_component_phasor[1]) ** 2 ) - if math.isclose(total_distance_between_components, 0, abs_tol = 1e-6): + if math.isclose(total_distance_between_components, 0, abs_tol=1e-6): raise ValueError('components must have different coordinates') projected_real, projected_imag = project_phasor_to_line( real, imag, real_components, imag_components diff --git a/tests/test_components.py b/tests/test_components.py index b97704fe..a66ec751 100644 --- a/tests/test_components.py +++ b/tests/test_components.py @@ -55,7 +55,7 @@ def test_two_fractions_from_phasor_channels(): [[0.4, 0.4, 0.4], [0.3, 0.3, 0.3]], ), ( - [[[1., 0.96, 0.84]]], - [[[0., 0.04, 0.16]]], + [[[1.0, 0.96, 0.84]]], + [[[0.0, 0.04, 0.16]]], ), ) From 6cd8c54873e07884874ce0db8a5b9f4af4276147 Mon Sep 17 00:00:00 2001 From: bruno-pannunzio Date: Thu, 25 Apr 2024 12:34:56 -0300 Subject: [PATCH 20/21] Replace `math.sqrt` with `numpy.hypot` in `two_fractions_from_phasor` and `project_phasor_to_line` --- src/phasorpy/_utils.py | 6 +++--- src/phasorpy/components.py | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/phasorpy/_utils.py b/src/phasorpy/_utils.py index 779ab115..e82b77bb 100644 --- a/src/phasorpy/_utils.py +++ b/src/phasorpy/_utils.py @@ -298,9 +298,9 @@ def project_phasor_to_line( second_component_phasor = numpy.array( [real_components[1], imag_components[1]] ) - total_distance_between_components = math.sqrt( - (second_component_phasor[0] - first_component_phasor[0]) ** 2 - + (second_component_phasor[1] - first_component_phasor[1]) ** 2 + total_distance_between_components = numpy.hypot( + (second_component_phasor[0] - first_component_phasor[0]), + (second_component_phasor[1] - first_component_phasor[1]), ) if math.isclose(total_distance_between_components, 0, abs_tol=1e-6): raise ValueError('components must have different coordinates') diff --git a/src/phasorpy/components.py b/src/phasorpy/components.py index 49e7bf6a..063f62b5 100644 --- a/src/phasorpy/components.py +++ b/src/phasorpy/components.py @@ -97,18 +97,18 @@ def two_fractions_from_phasor( second_component_phasor = numpy.array( [real_components[1], imag_components[1]] ) - total_distance_between_components = math.sqrt( - (second_component_phasor[0] - first_component_phasor[0]) ** 2 - + (second_component_phasor[1] - first_component_phasor[1]) ** 2 + total_distance_between_components = numpy.hypot( + (second_component_phasor[0] - first_component_phasor[0]), + (second_component_phasor[1] - first_component_phasor[1]), ) if math.isclose(total_distance_between_components, 0, abs_tol=1e-6): raise ValueError('components must have different coordinates') projected_real, projected_imag = project_phasor_to_line( real, imag, real_components, imag_components ) - distances_to_first_component = numpy.sqrt( - (numpy.array(projected_real) - first_component_phasor[0]) ** 2 - + (numpy.array(projected_imag) - first_component_phasor[1]) ** 2 + distances_to_first_component = numpy.hypot( + (numpy.array(projected_real) - first_component_phasor[0]), + (numpy.array(projected_imag) - first_component_phasor[1]), ) fraction_of_second_component = ( distances_to_first_component / total_distance_between_components From c5c3a6c8dce8dcfc34fb88fa912650f703496c8d Mon Sep 17 00:00:00 2001 From: bruno-pannunzio Date: Thu, 25 Apr 2024 12:38:31 -0300 Subject: [PATCH 21/21] Update `numpy.hypot` with `math.hypot` --- src/phasorpy/_utils.py | 2 +- src/phasorpy/components.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/phasorpy/_utils.py b/src/phasorpy/_utils.py index e82b77bb..309c66e5 100644 --- a/src/phasorpy/_utils.py +++ b/src/phasorpy/_utils.py @@ -298,7 +298,7 @@ def project_phasor_to_line( second_component_phasor = numpy.array( [real_components[1], imag_components[1]] ) - total_distance_between_components = numpy.hypot( + total_distance_between_components = math.hypot( (second_component_phasor[0] - first_component_phasor[0]), (second_component_phasor[1] - first_component_phasor[1]), ) diff --git a/src/phasorpy/components.py b/src/phasorpy/components.py index 063f62b5..be52b443 100644 --- a/src/phasorpy/components.py +++ b/src/phasorpy/components.py @@ -97,7 +97,7 @@ def two_fractions_from_phasor( second_component_phasor = numpy.array( [real_components[1], imag_components[1]] ) - total_distance_between_components = numpy.hypot( + total_distance_between_components = math.hypot( (second_component_phasor[0] - first_component_phasor[0]), (second_component_phasor[1] - first_component_phasor[1]), )