Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add components module #59

Merged
merged 27 commits into from
Apr 25, 2024
Merged

Conversation

bruno-pannunzio
Copy link
Contributor

@bruno-pannunzio bruno-pannunzio commented Apr 22, 2024

Description

This PR proposes the addition of the components module. At the moment, a basic function for calculation of fraction of two known components is implemented, where the phasors are projected to the line between components and the distance to the component is inversely proportional to the fraction of that component.

This PR covers some aspects of issue #47

Release note

Summarize the changes in the code block below to be included in the
release notes:

Add `components` module with basic function for fraction calculation of two known components.

Checklist

  • The pull request title, summary, and description are concise.
  • Related issues are linked in the description.
  • New dependencies are explained.
  • The source code and documentation can be distributed under the MIT license.
  • The source code adheres to code standards.
  • New classes, functions, and features are thoroughly tested.
  • New, user-facing classes, functions, and features are documented.
  • New features are covered in tutorials.
  • No files other than source code, documentation, and project settings are added to the repository.

@bruno-pannunzio bruno-pannunzio requested a review from cgohlke April 22, 2024 22:14
@bruno-pannunzio
Copy link
Contributor Author

@cgohlke I started this PR so we can discuss the components module. I have been discussing with Leonel about what method is the best for calculating the fraction of two components and we arrived to the conclusion that both the more "mathematical" solution (solving system of equations) and the more "graphical" approach (moving cursor and creating histogram) should both be available. I started implementing the mathematical approach until the cursors module is ready.

Regarding this approach, I tried both with solving linear equations and the alternative by projecting points to a line between components and then calculating euclidean distance, which is inversely proportional to the fraction of the component. I tested and this last method was fastest than solving a system of equations, so I went for that approach, at least for now so we can start testing this implementations. The approach for solving equations is also necessary, particularly for calculating fractions of three and four components, so it will also hav to be implemented, but maybe if it is faster we can keep this implementation for two components and use the other for more components.

Let me know what do you think and we can continue this discussion since I have other issues I would like to ask you related to this PR.

docs/api/components.rst Outdated Show resolved Hide resolved
tutorials/phasorpy_components.py Outdated Show resolved Hide resolved
tutorials/phasorpy_components.py Outdated Show resolved Hide resolved
tutorials/phasorpy_components.py Outdated Show resolved Hide resolved
tutorials/phasorpy_components.py Outdated Show resolved Hide resolved
tutorials/phasorpy_components.py Outdated Show resolved Hide resolved
tutorials/phasorpy_components.py Outdated Show resolved Hide resolved
imag = numpy.copy(imag)
real_components = numpy.asarray(real_components)
imag_components = numpy.asarray(imag_components)
if real_components.size != 2:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should be real_components.shape == (2,)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This check was something I added before making some changes to the function and just realized I left it there. Still I don't know if I should remove it. The idea of this is to check that it has only two components. I don't know if broadcasting works correctly, although I added the axis argument for this purpose. I tested a couple of cases and arrays of different shapes and I think t works, but still.

If that is the case, I just realized that the other check:

    if numpy.all(real_components == imag_components):
        raise ValueError('components must have different coordinates')

should be modified to compare components in the same axis instead.
What do you think?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see the axis argument.
I wasn't sure what the all(real_components == imag_components) check was about. Instead test if total_distance_between_components is close to 0`?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like this approach. I will give it a try and update both functions. I only added the axis argument to project_phasor_to_line because I found it a little bit more challenging for two_fractions_from_phasor, but I will try to implement it for both.

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:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here.

tests/test__utils.py Outdated Show resolved Hide resolved
@cgohlke
Copy link
Member

cgohlke commented Apr 23, 2024

This looks good. I left some comments.

"graphical" approach (moving cursor and creating histogram)

Can you explain this.

this last method was fastest than solving a system of equations

If you have a large number of phasor coordinates and are OK with a limited precision, you could calculate a 2D lookup table of fractions with reduced resolution, and then index that table with the digitized phasor coordinates. The lookup table can be calculated using your two_fractions_from_phasor function...

@cgohlke cgohlke added the enhancement New feature or request label Apr 23, 2024
@bruno-pannunzio
Copy link
Contributor Author

@cgohlke thanks for all the comments, they helped a lot! I think I addressed all of them, and some of them I replied in the same thread as to make it easier to follow.

"graphical" approach (moving cursor and creating histogram)

This is a method that Leonel explained to me, that he believes is what SimFCS does. This method is also described in this paper:
DOI: 10.1021/acs.jpca.9b07880

Briefly, the idea is that by moving the cursor along a line in discrete steps and then creating a histogram along the line in each step helps to take into account the dispersion in all directions, instead of the projection to the line that only considers one direction. Maybe we can discuss this next Monday with Leonel.

I also like the idea of a 2D lookup table. Maybe it's another thing to discuss on monday if it makes sense to compromise a little of resolution for speed, at least to have that option I think would be great.

@cgohlke
Copy link
Member

cgohlke commented Apr 23, 2024

If I may suggest some minor cleanup of the tutorial, mainly separating variable definitions from calculations and plotting by empty lines. Also, how about combining the two subplots since the histograms do not overlap much?

diff --git a/tutorials/phasorpy_components.py b/tutorials/phasorpy_components.py
index f6154dc..e2070d9 100644
--- a/tutorials/phasorpy_components.py
+++ b/tutorials/phasorpy_components.py
@@ -27,12 +27,15 @@ from phasorpy.plot import PhasorPlot
 
 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
 )
+
 plot = PhasorPlot(frequency=frequency, title='Combination of two components')
 plot.plot(components_real, components_imag, fmt='o-')
 plot.plot(real, imag)
@@ -47,7 +50,8 @@ plot.show()
     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}')
 
 # %%
@@ -60,9 +64,10 @@ print(f'Fraction of second component: {fraction_of_second_component:.3f}')
 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='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 +82,26 @@ plot.show()
     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()
 

@bruno-pannunzio
Copy link
Contributor Author

Excelent! I like the modifications you did to the tutorial. I will update it with your suggestions.

@bruno-pannunzio
Copy link
Contributor Author

Hi @cgohlke, I am having some trouble trying to make both the project_phasor_to_line and two_fractions_from_phasor to be able to handle inputs with several dimensions. I am not exactly sure if this will be necessary also. For example if the real and imag are of shape (512, 512, 4) because they have different harmonics or channels (4), then real_components and imag_components should be also of shape (2, 4) and the projection should be performed over the last dimension. I am trying but couldn't find a solution.

Do you think this is necessary? If so, do you have any suggestion of how to approach this with the current implementation?

@cgohlke
Copy link
Member

cgohlke commented Apr 24, 2024

I haven't looked in that detail yet. If you think supporting those cases will complicate the implementation or documentation too much, how about documenting the limitation in the docstring and adding a test that should ideally pass but currently fails (marked pytest.xfail).

@cgohlke
Copy link
Member

cgohlke commented Apr 24, 2024

Do you think this is necessary?

Let's leave it out of this PR. Most methods/algorithms in this module will not naturally support multiple channels and/or frequencies (no?). If we decide to re-implement this algorithm as a numpy ufunc in Cython, those cases will be supported automatically.

@bruno-pannunzio
Copy link
Contributor Author

Totally agree, I will update documentation and tests and let you know when it is ready again for reviewing.

…dded test that fails for `two_fractions_from_phasor` with mutlitple channels.
src/phasorpy/components.py Outdated Show resolved Hide resolved
src/phasorpy/_utils.py Outdated Show resolved Hide resolved
@bruno-pannunzio bruno-pannunzio requested a review from cgohlke April 25, 2024 15:49
@bruno-pannunzio
Copy link
Contributor Author

@cgohlke I think this is ready for merge, but please let me know if something else can be improved

@cgohlke
Copy link
Member

cgohlke commented Apr 25, 2024

For some reason I get the following doctest error:

        Examples
        --------
        >>> two_fractions_from_phasor(
        ...     [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.shape != (2,):
>           raise ValueError(f'{real_components.shape=} != (2,)')
E           ValueError: real_components.shape=(2, 3) != (2,)

@bruno-pannunzio
Copy link
Contributor Author

For some reason I get the following doctest error:

        Examples
        --------
        >>> two_fractions_from_phasor(
        ...     [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.shape != (2,):
>           raise ValueError(f'{real_components.shape=} != (2,)')
E           ValueError: real_components.shape=(2, 3) != (2,)

That's strange, I can't seem to replicate that error. That comes from testing the example in the docstring? Could it be something related to the pytest.xfail test? I think is the only place I test with a real_components of shape (2,3)

@cgohlke
Copy link
Member

cgohlke commented Apr 25, 2024

Never mind. I misread the pytest output. It's the output of the known failure. All tests passed!

@bruno-pannunzio bruno-pannunzio merged commit 08593fe into phasorpy:main Apr 25, 2024
13 checks passed
@bruno-pannunzio bruno-pannunzio deleted the components branch April 25, 2024 20:10
@cgohlke cgohlke mentioned this pull request Apr 29, 2024
13 tasks
@cgohlke cgohlke changed the title Add components module Add components module Sep 25, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants