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

Higher level calibration function #37

Merged
merged 14 commits into from
Mar 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
239 changes: 196 additions & 43 deletions src/phasorpy/phasor.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
lifetime:

- :py:func:`phasor_calibrate`
- :py:func:`phasor_transform`
- :py:func:`polar_from_reference`
- :py:func:`polar_from_reference_phasor`

Expand All @@ -45,12 +46,12 @@
'phasor_semicircle',
# 'phasor_to_apparent_lifetime',
'phasor_to_polar',
'phasor_transform',
'polar_from_reference',
'polar_from_reference_phasor',
]

import math
import os
import warnings
from typing import TYPE_CHECKING

Expand Down Expand Up @@ -391,78 +392,228 @@ def phasor_semicircle(
def phasor_calibrate(
real: ArrayLike,
imag: ArrayLike,
phase0: ArrayLike = 0.0,
modulation0: ArrayLike = 1.0,
reference_real: ArrayLike,
reference_imag: ArrayLike,
/,
frequency: ArrayLike,
lifetime: ArrayLike,
*,
fraction: ArrayLike | None = None,
preexponential: bool = False,
unit_conversion: float = 1e-3,
method: Literal['mean', 'median'] = 'mean',
) -> tuple[NDArray[Any], NDArray[Any]]:
"""Return calibrated/referenced phasor coordinates.
"""
Return calibrated/referenced phasor coordinates.

Calibration of phasor coordinates in fluorescence lifetime analysis is
Calibration of phasor coordinates from time-resolved measurements is
necessary to account for the instrument response function (IRF) and delays
in the electronics.

This function can also be used to transform/rotate any phasor coordinate.
Parameters
----------
real : array_like
Real component of phasor coordinates to be calibrated.
imag : array_like
Imaginary component of phasor coordinates to be calibrated.
reference_real : array_like
Real component of phasor coordinates from reference of known lifetime.
cgohlke marked this conversation as resolved.
Show resolved Hide resolved
Must be measured with the same instrument setting as the phasor
coordinates to be calibrated.
reference_imag : array_like
Imaginary component of phasor coordinates from reference of known
lifetime.
Must be measured with the same instrument setting as the phasor
coordinates to be calibrated.
frequency : array_like
Laser pulse or modulation frequency in MHz.
A scalar or one-dimensional sequence.
lifetime : array_like
Lifetime components in ns. Must be scalar or one dimensional.
fraction : array_like, optional
Fractional intensities or pre-exponential amplitudes of the lifetime
components. Fractions are normalized to sum to 1.
Must be same size as `lifetime`.
preexponential : bool, optional
If true, `fraction` values are pre-exponential amplitudes,
else fractional intensities (default).
unit_conversion : float
Product of `frequency` and `lifetime` units' prefix factors.
The default is 1e-3 for MHz and ns, or Hz and ms.
Use 1.0 for Hz and s.
method : str, optional
Method used for calculating center of `reference_real` and
`reference_imag`:

- ``'mean'``: Arithmetic mean of phasor coordinates.
- ``'median'``: Spatial median of phasor coordinates.

Returns
-------
real : ndarray
Calibrated real component of phasor coordinates.
imag : ndarray
Calibrated imaginary component of phasor coordinates.

Raises
------
ValueError
The array shapes of `real` and `imag`, or `reference_real` and
`reference_imag` do not match.

Notes
-----
This function is a convenience wrapper for the following operations:

.. code-block:: python

phasor_transform(
real,
imag,
*polar_from_reference_phasor(
*phasor_center(
reference_real,
reference_imag,
skip_axes,
method,
),
*phasor_from_lifetime(
frequency,
lifetime,
fraction,
preexponential,
unit_conversion,
),
),
)

Examples
--------
>>> phasor_calibrate(
... [0.1, 0.2, 0.3], [0.4, 0.5, 0.6],
... [0.2, 0.3, 0.4], [0.5, 0.6, 0.7],
... frequency=80, lifetime=4
... ) # doctest: +NUMBER
(array([0.0658, 0.132, 0.198]), array([0.2657, 0.332, 0.399]))

"""
re = numpy.asarray(real)
im = numpy.asarray(imag)
if re.shape != im.shape:
raise ValueError(f'real.shape={re.shape} != imag.shape={im.shape}')
ref_re = numpy.asarray(reference_real)
ref_im = numpy.asarray(reference_imag)
if ref_re.shape != ref_im.shape:
raise ValueError(
f'reference_real.shape={ref_re.shape} '
f'!= reference_imag.shape{ref_im.shape}'
)
measured_re, measured_im = phasor_center(
reference_real, reference_imag, method=method
)
known_re, known_im = phasor_from_lifetime(
frequency,
lifetime,
fraction,
preexponential=preexponential,
unit_conversion=unit_conversion,
)
phi_zero, mod_zero = polar_from_reference_phasor(
measured_re, measured_im, known_re, known_im
)
return phasor_transform(re, im, phi_zero, mod_zero)


def phasor_transform(
real: ArrayLike,
imag: ArrayLike,
phase_zero: ArrayLike = 0.0,
modulation_zero: ArrayLike = 1.0,
/,
) -> tuple[NDArray[Any], NDArray[Any]]:
r"""Return rotated and scaled phasor coordinates.

This function rotates and uniformly scales phasor coordinates around the
origin.
It can be used, for example, to calibrate phasor coordinates.

Parameters
----------
real : array_like
Real component of phasor coordinates to calibrate.
Real component of phasor coordinates to transform.
imag : array_like
Imaginary component of phasor coordinates to calibrate.
phase0 : array_like, optional
Angular component of polar coordinates for calibration in radians.
Imaginary component of phasor coordinates to transform.
phase_zero : array_like, optional
Rotation angle in radians.
Defaults to 0.0.
modulation0 : array_like, optional
Radial component of polar coordinates for calibration.
modulation_zero : array_like, optional
Uniform scale factor.
Defaults to 1.0.

Returns
-------
real : ndarray
Calibrated real component of phasor coordinates.
Real component of rotated and scaled phasor coordinates.
imag : ndarray
Calibrated imaginary component of phasor coordinates.
Imaginary component of rotated and scaled phasor coordinates.

Raises
------
ValueError
The array shapes of `real` and `imag`, or `phase0` and `modulation0`
do not match.
The array shapes of `real` and `imag`, or `phase_zero` and
`modulation_zero` do not match.

Notes
-----
Rotation and scaling are performed in the polar system by adding
`phase_zero` (:math:`\phi_{0}`) to, and multiplying `modulation_zero`
(:math:`M_{0}`) with the phase (:math:`\phi`) and modulation (:math:`M`)
respectively, calculated from `real` and `imag`:

.. math::
\phi' &= \phi + \phi_{0}

M' &= M \cdot M_{0}

Examples
--------
Use scalar reference coordinates to calibrate phasor coordinates:
Use scalar reference coordinates to rotate and scale phasor coordinates:

>>> phasor_calibrate([1.0, 2.0, 3.0], [4.0, 5.0, 6.0], 0.5, 0.2)
(array([-0.208, -0.1284, -0.04876]), array([0.798, 1.069, 1.341]))
>>> phasor_transform(
... [0.1, 0.2, 0.3],
... [0.4, 0.5, 0.6],
... 0.1,
... 0.5
... ) # doctest: +NUMBER
(array([0.0298, 0.0745, 0.119]), array([0.204, 0.259, 0.3135]))

Use separate reference coordinates for each phasor coordinate:

>>> phasor_calibrate(
... [1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [0.5, 0.2, 0.3], [1.5, 2.0, 0.3]
... )
(array([-1.56, 1.934, 0.3279]), array([5.985, 10.6, 1.986]))
>>> phasor_transform(
... [0.1, 0.2, 0.3], [0.4, 0.5, 0.6], [0.2, 0.2, 0.3], [0.5, 0.2, 0.3]
... ) # doctest: +NUMBER
(array([0.00927, 0.0193, 0.0328]), array([0.206, 0.106, 0.1986]))

"""
phi0 = numpy.asarray(phase0)
mod0 = numpy.asarray(modulation0)
if phi0.shape != mod0.shape:
raise ValueError(f'{phi0.shape=} != {mod0.shape=}')
phi_zero = numpy.asarray(phase_zero)
mod_zero = numpy.asarray(modulation_zero)
if phi_zero.shape != mod_zero.shape:
raise ValueError(f'{phi_zero.shape=} != {mod_zero.shape=}')
re = numpy.array(real, copy=True, dtype=float)
im = numpy.array(imag, copy=True, dtype=float)
if re.shape != im.shape:
raise ValueError(f'{re.shape=} != {im.shape=}')
cos = numpy.cos(phi0)
cos *= mod0
sin = numpy.sin(phi0)
sin *= mod0
cos = numpy.cos(phi_zero)
cos *= mod_zero
sin = numpy.sin(phi_zero)
sin *= mod_zero
re_calibrated = re * cos
re_calibrated -= im * sin
im_calibrated = re
im_calibrated *= sin
im *= cos
im_calibrated += im
return re_calibrated, im_calibrated
return numpy.asarray(re_calibrated), numpy.asarray(im_calibrated)


def polar_from_reference_phasor(
Expand Down Expand Up @@ -491,9 +642,9 @@ def polar_from_reference_phasor(

Returns
-------
phase0 : ndarray
phase_zero : ndarray
Angular component of polar coordinates for calibration in radians.
modulation0 : ndarray
modulation_zero : ndarray
Radial component of polar coordinates for calibration.

Raises
Expand All @@ -518,10 +669,12 @@ def polar_from_reference_phasor(
raise ValueError(f'{known_real.shape=} != {known_imag.shape=}')
measured_phi, measured_mod = phasor_to_polar(measured_real, measured_imag)
known_phi, known_mod = phasor_to_polar(known_real, known_imag)
phase0 = known_phi - measured_phi
modulation0 = known_mod / measured_mod
phase0 = phase0.item() if numpy.isscalar(modulation0) else phase0
return phase0, modulation0
phase_zero = known_phi - measured_phi
modulation_zero = known_mod / measured_mod
phase_zero = (
phase_zero.item() if numpy.isscalar(modulation_zero) else phase_zero
)
return phase_zero, modulation_zero


def polar_from_reference(
Expand Down Expand Up @@ -550,9 +703,9 @@ def polar_from_reference(

Returns
-------
phase0 : ndarray
phase_zero : ndarray
Angular component of polar coordinates for calibration in radians.
modulation0 : ndarray
modulation_zero : ndarray
Radial component of polar coordinates for calibration.

Raises
Expand All @@ -577,9 +730,9 @@ def polar_from_reference(
known_modulation = numpy.asarray(known_modulation)
if known_phase.shape != known_modulation.shape:
raise ValueError(f'{known_phase.shape=} != {known_modulation.shape=}')
phase0 = measured_phase - known_phase
modulation0 = measured_modulation / known_modulation
return phase0, modulation0
phase_zero = measured_phase - known_phase
modulation_zero = measured_modulation / known_modulation
return phase_zero, modulation_zero


def phasor_to_polar(
Expand Down
4 changes: 2 additions & 2 deletions src/phasorpy/plot.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
sort_coordinates,
update_kwargs,
)
from .phasor import phasor_calibrate, phasor_from_lifetime
from .phasor import phasor_from_lifetime, phasor_transform

GRID_COLOR = '0.5'
GRID_LINESTYLE = ':'
Expand Down Expand Up @@ -669,7 +669,7 @@ def semicircle(
except IndexError:
pass
ax.plot(
*phasor_calibrate(
*phasor_transform(
*phasor_from_lifetime(frequency, lifetime),
*polar_reference,
),
Expand Down
Loading