Skip to content

Commit bc540ee

Browse files
committed
motion.{AnomalousDiffusion,BrownianMotion}: Support nD case
1 parent 298633a commit bc540ee

File tree

3 files changed

+253
-168
lines changed

3 files changed

+253
-168
lines changed

pyproject.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,3 +127,8 @@ only-include = [
127127

128128
[tool.pytest.ini_options]
129129
markers = "slow: tests that take some time"
130+
131+
[tool.pyright]
132+
venvPath = "."
133+
venv = ".venv"
134+
reportIncompatibleMethodOverride = false

sdt/motion/msd.py

Lines changed: 99 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,16 @@
33
# SPDX-License-Identifier: BSD-3-Clause
44

55
"""Analyze mean square displacements (MSDs)"""
6-
from collections import namedtuple, OrderedDict
76
import math
7+
from collections import OrderedDict, namedtuple
8+
from typing import Literal, Tuple
89

9-
import pandas as pd
1010
import numpy as np
11+
import pandas as pd
1112
import scipy.optimize
1213

13-
from . import msd_base
1414
from .. import config
15+
from . import msd_base
1516

1617

1718
class Msd:
@@ -209,32 +210,40 @@ def fit(self, model="brownian", *args, **kwargs):
209210
class AnomalousDiffusion:
210211
r"""Fit anomalous diffusion parameters to MSD values
211212
212-
Fit a function :math:`msd(t_\text{lag}) = 4 D t_\text{lag}^\alpha +
213-
4 \epsilon^2`
213+
Fit a function :math:`msd(t_\text{lag}) = 2 n_\text{dim} D t_\text{lag}^\alpha +
214+
2 n_\text{dim} \epsilon^2`
214215
to the tlag-vs.-MSD graph, where :math:`D` is the diffusion coefficient,
215-
:math:`\epsilon` is the positional accuracy (uncertainty), and
216-
:math:`\alpha` the anomalous diffusion exponent.
216+
:math:`\epsilon` is the positional uncertainty, and :math:`\alpha` the anomalous
217+
diffusion exponent.
217218
"""
218219
_fit_parameters = ["D", "eps", "alpha"]
219220

220-
def __init__(self, msd_data, n_lag=np.inf, exposure_time=0.,
221-
initial=(0.5, 0.05, 1.)):
221+
def __init__(
222+
self,
223+
msd_data: msd_base.MsdData,
224+
n_lag: int | Literal[math.inf] = math.inf,
225+
exposure_time: float = 0.0,
226+
n_dim: int = 2,
227+
initial: Tuple[float, float, float] = (0.5, 0.05, 1.0),
228+
):
222229
r"""Parameters
223230
----------
224-
msd_data : msd_base.MsdData
231+
msd_data
225232
MSD data
226-
n_lag : int or inf, optional
233+
n_lag
227234
Maximum number of lag times to use for fitting. Defaults to
228235
`inf`, i.e. using all.
229-
exposure_time : float, optional
230-
Exposure time. Defaults to 0, i.e. no exposure time correction
231-
initial : tuple of float, optional
236+
exposure_time
237+
Exposure time. 0 disables exposure time correction.
238+
n_dim
239+
Number of spatial dimensions
240+
initial
232241
Initial guesses for the fitting for :math:`D`, :math:`\epsilon`,
233-
and :math:`\alpha`. Defaults to ``(0.5, 0.05, 1.)``.
242+
and :math:`\alpha`.
234243
"""
235244
def residual(x, lagt, target):
236245
d, eps, alpha = x
237-
r = self.theoretical(lagt, d, eps, alpha, exposure_time)
246+
r = self.theoretical(lagt, d, eps, alpha, exposure_time, n_dim)
238247
return r - target
239248

240249
initial = np.asarray(initial)
@@ -249,9 +258,11 @@ def residual(x, lagt, target):
249258
lt = lagt[fin]
250259
nl = min(n_lag, len(tgt))
251260
f = scipy.optimize.least_squares(
252-
residual, initial,
261+
residual,
262+
initial,
253263
bounds=([0, -np.inf, 0], [np.inf, np.inf, np.inf]),
254-
kwargs={"lagt": lt[:nl], "target": tgt[:nl]})
264+
kwargs={"lagt": lt[:nl], "target": tgt[:nl]},
265+
)
255266
r.append(f.x)
256267
r = np.array(r)
257268
self._results[particle] = np.mean(r, axis=0)
@@ -262,6 +273,7 @@ def residual(x, lagt, target):
262273

263274
self._msd_data = msd_data
264275
self.exposure_time = exposure_time
276+
self.n_dim = n_dim
265277

266278
@staticmethod
267279
def exposure_time_corr(t, alpha, exposure_time, n=100,
@@ -321,7 +333,15 @@ def exposure_time_corr(t, alpha, exposure_time, n=100,
321333
return (np.sum(s, axis=(1, 2)) / n**2)**(1/alpha)
322334

323335
@staticmethod
324-
def theoretical(t, d, eps, alpha=1, exposure_time=0, squeeze_result=True):
336+
def theoretical(
337+
t: np.ndarray | float,
338+
d: np.ndarray | float,
339+
eps: np.ndarray | float,
340+
alpha: np.ndarray | float = 1.0,
341+
exposure_time: float = 0.0,
342+
n_dim: int = 2,
343+
squeeze_result: bool = True,
344+
) -> np.ndarray | float:
325345
r"""Calculate theoretical MSDs for different lag times
326346
327347
Calculate :math:`msd(t_\text{lag}) = 4 D t_\text{app}^\alpha + 4
@@ -331,24 +351,25 @@ def theoretical(t, d, eps, alpha=1, exposure_time=0, squeeze_result=True):
331351
332352
Parameters
333353
----------
334-
t : array-like or scalar
354+
t
335355
Lag times
336-
d : float
356+
d
337357
Diffusion coefficient
338-
eps : float
339-
Positional accuracy.
340-
alpha : float, optional
341-
Anomalous diffusion exponent. Defaults to 1.
342-
exposure_time : float, optional
343-
Exposure time. Defaults to 0.
344-
squeeze_result : bool, optional
358+
eps
359+
Positional uncertainty.
360+
alpha
361+
Anomalous diffusion exponent.
362+
exposure_time
363+
Exposure time.
364+
n_dim
365+
Number of spatial dimensions
366+
squeeze_result
345367
If `True`, return the result as a scalar type or 1D array if
346-
possible. Otherwise, always return a 2D array. Defaults to `True`.
368+
possible. Otherwise, always return a 2D array.
347369
348370
Returns
349371
-------
350-
numpy.ndarray or scalar
351-
Calculated theoretical MSDs
372+
Calculated theoretical MSDs
352373
"""
353374
t = np.atleast_1d(t)
354375
d = np.atleast_1d(d)
@@ -357,17 +378,18 @@ def theoretical(t, d, eps, alpha=1, exposure_time=0, squeeze_result=True):
357378
if d.shape != eps.shape or d.shape != alpha.shape:
358379
raise ValueError("`d`, `eps`, and `alpha` should have same shape.")
359380
if t.ndim > 1 or d.ndim > 1 or eps.ndim > 1:
360-
raise ValueError("Number of dimensions of `t`, `d`, `eps`, and "
361-
"`alpha` need to be less than 2")
381+
raise ValueError(
382+
"Number of dimensions of `t`, `d`, `eps`, and `alpha` need to be"
383+
"less than 2"
384+
)
362385

363-
ic = 4 * eps**2
386+
ic = 2 * n_dim * eps**2
364387
ic[eps < 0] *= -1
365388
t_corr = np.empty((len(t), len(alpha)), dtype=float)
366389
for i, a in enumerate(alpha):
367-
t_corr[:, i] = AnomalousDiffusion.exposure_time_corr(
368-
t, a, exposure_time)
390+
t_corr[:, i] = AnomalousDiffusion.exposure_time_corr(t, a, exposure_time)
369391

370-
ret = 4 * d[None, :] * t_corr**alpha[None, :] + ic[None, :]
392+
ret = 2 * n_dim * d[None, :] * t_corr ** alpha[None, :] + ic[None, :]
371393

372394
if squeeze_result:
373395
if ret.size == 1:
@@ -520,7 +542,7 @@ def _plot_single(self, data_id, n_lag, name, ax, color):
520542
d_err, eps_err, alpha_err = self._err.get(data_id, (np.nan,) * 3)
521543

522544
x = np.linspace(0, n_lag, 100)
523-
y = self.theoretical(x, d, eps, alpha, self.exposure_time)
545+
y = self.theoretical(x, d, eps, alpha, self.exposure_time, self.n_dim)
524546

525547
legend = []
526548
if name:
@@ -544,15 +566,23 @@ class BrownianMotion(AnomalousDiffusion):
544566
"""
545567
_fit_parameters = ["D", "eps"]
546568

547-
def __init__(self, msd_data, n_lag=2, exposure_time=0):
569+
def __init__(
570+
self,
571+
msd_data: msd_base.MsdData,
572+
n_lag: int | Literal[math.inf] = 2,
573+
exposure_time: float = 0.0,
574+
n_dim: int = 2,
575+
):
548576
"""Parameters
549577
----------
550-
msd_data : msd_base.MsdData
578+
msd_data
551579
MSD data
552-
n_lag : int or inf, optional
553-
Maximum number of lag times to use for fitting. Defaults to 2.
554-
exposure_time : float, optional
555-
Exposure time. Defaults to 0, i.e. no exposure time correction
580+
n_lag
581+
Maximum number of lag times to use for fitting.
582+
exposure_time
583+
Exposure time. 0 disables exposure time correction.
584+
n_dim
585+
Number of spatial dimensions
556586
"""
557587
self._results = OrderedDict()
558588
self._err = OrderedDict()
@@ -578,8 +608,8 @@ def __init__(self, msd_data, n_lag=2, exposure_time=0):
578608
else:
579609
s, i = np.polyfit(lagt[:nl] - exposure_time / 3, m[:nl, :], 1)
580610

581-
d = s / 4
582-
eps = np.sqrt(i.astype(complex)) / 2
611+
d = s / (2 * n_dim)
612+
eps = np.sqrt(i.astype(complex) / (2 * n_dim))
583613
eps = np.where(i > 0, np.real(eps), -np.imag(eps))
584614

585615
self._results[particle] = [np.mean(d), np.mean(eps)]
@@ -590,9 +620,17 @@ def __init__(self, msd_data, n_lag=2, exposure_time=0):
590620

591621
self._msd_data = msd_data
592622
self.exposure_time = exposure_time
623+
self.n_dim = n_dim
593624

594625
@staticmethod
595-
def theoretical(t, d, eps, exposure_time=0):
626+
def theoretical( # pyright: ignore reportIncompatibleMethodOverride
627+
t: np.ndarray | float,
628+
d: np.ndarray | float,
629+
eps: np.ndarray | float,
630+
exposure_time: float = 0.0,
631+
n_dim: int = 2,
632+
squeeze_result: bool = False,
633+
) -> np.ndarray | float:
596634
r"""Calculate theoretical MSDs for different lag times
597635
598636
Calculate :math:`msd(t_\text{lag}) = 4 D t_\text{app} + 4
@@ -602,34 +640,34 @@ def theoretical(t, d, eps, exposure_time=0):
602640
603641
Parameters
604642
----------
605-
t : array-like or scalar
643+
t
606644
Lag times
607-
d : float
645+
d
608646
Diffusion coefficient
609-
eps : float
610-
Positional accuracy.
611-
alpha : float, optional
612-
Anomalous diffusion exponent. Defaults to 1.
613-
exposure_time : float, optional
614-
Exposure time. Defaults to 0.
615-
squeeze_result : bool, optional
647+
eps
648+
Positional uncertainty
649+
exposure_time
650+
Exposure time
651+
n_dim
652+
Number of spatial dimensions
653+
squeeze_result
616654
If `True`, return the result as a scalar type or 1D array if
617655
possible. Otherwise, always return a 2D array. Defaults to `True`.
618656
619657
Returns
620658
-------
621-
numpy.ndarray or scalar
622-
Calculated theoretical MSDs
659+
Calculated theoretical MSDs
623660
"""
624-
return AnomalousDiffusion.theoretical(t, d, eps, np.ones_like(d),
625-
exposure_time)
661+
return AnomalousDiffusion.theoretical(
662+
t, d, eps, np.ones_like(d), exposure_time, n_dim, squeeze_result
663+
)
626664

627665
def _plot_single(self, data_id, n_lag, name, ax, color):
628666
d, eps = self._results[data_id]
629667
d_err, eps_err = self._err.get(data_id, (np.nan,) * 2)
630668

631669
x = np.linspace(0, n_lag, 100)
632-
y = self.theoretical(x, d, eps, self.exposure_time)
670+
y = self.theoretical(x, d, eps, self.exposure_time, self.n_dim)
633671

634672
legend = []
635673
if name:

0 commit comments

Comments
 (0)