qdiffusivity 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- qdiffusivity/__init__.py +51 -0
- qdiffusivity/binned.py +614 -0
- qdiffusivity/data/README.md +17 -0
- qdiffusivity/data/__init__.py +0 -0
- qdiffusivity/data/files.py +19 -0
- qdiffusivity/data/mda.txt +45 -0
- qdiffusivity/density.py +489 -0
- qdiffusivity/diffusivity.py +750 -0
- qdiffusivity/example_api.py +3 -0
- qdiffusivity/tests/__init__.py +0 -0
- qdiffusivity/tests/conftest.py +82 -0
- qdiffusivity/tests/test_binned.py +163 -0
- qdiffusivity/tests/test_density.py +123 -0
- qdiffusivity/tests/test_diffusivity.py +227 -0
- qdiffusivity/tests/test_qdiffusivity.py +8 -0
- qdiffusivity/tests/utils.py +77 -0
- qdiffusivity-0.1.0.dist-info/METADATA +123 -0
- qdiffusivity-0.1.0.dist-info/RECORD +22 -0
- qdiffusivity-0.1.0.dist-info/WHEEL +5 -0
- qdiffusivity-0.1.0.dist-info/licenses/AUTHORS.md +23 -0
- qdiffusivity-0.1.0.dist-info/licenses/LICENSE +21 -0
- qdiffusivity-0.1.0.dist-info/top_level.txt +1 -0
qdiffusivity/__init__.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""
|
|
2
|
+
qdiffusivity
|
|
3
|
+
Quantile-based estimation of local diffusivities in molecular dynamics
|
|
4
|
+
simulations of nanoconfined liquids
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
# Add imports here
|
|
8
|
+
from importlib.metadata import version
|
|
9
|
+
|
|
10
|
+
from .binned import (
|
|
11
|
+
LocalDiffusivityQBinned,
|
|
12
|
+
TransverseDensityQBinned,
|
|
13
|
+
cic_assign,
|
|
14
|
+
resolve_bins,
|
|
15
|
+
)
|
|
16
|
+
from .density import (
|
|
17
|
+
TransverseDensityQKDE,
|
|
18
|
+
epanechnikov_kernel,
|
|
19
|
+
kde_1d,
|
|
20
|
+
select_bandwidth,
|
|
21
|
+
sheather_jones_bw,
|
|
22
|
+
silverman_bw,
|
|
23
|
+
)
|
|
24
|
+
from .diffusivity import (
|
|
25
|
+
LocalDiffusivityQKDE,
|
|
26
|
+
build_cdf,
|
|
27
|
+
gaussian_kernel,
|
|
28
|
+
kde_estimate,
|
|
29
|
+
select_diff_bandwidth,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
__version__ = version("qdiffusivity")
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"TransverseDensityQBinned",
|
|
36
|
+
"TransverseDensityQKDE",
|
|
37
|
+
"LocalDiffusivityQBinned",
|
|
38
|
+
"LocalDiffusivityQKDE",
|
|
39
|
+
"build_cdf",
|
|
40
|
+
"cic_assign",
|
|
41
|
+
"epanechnikov_kernel",
|
|
42
|
+
"gaussian_kernel",
|
|
43
|
+
"kde_1d",
|
|
44
|
+
"kde_estimate",
|
|
45
|
+
"resolve_bins",
|
|
46
|
+
"select_bandwidth",
|
|
47
|
+
"select_diff_bandwidth",
|
|
48
|
+
"sheather_jones_bw",
|
|
49
|
+
"silverman_bw",
|
|
50
|
+
"__version__",
|
|
51
|
+
]
|
qdiffusivity/binned.py
ADDED
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
r"""CDF-binned transverse density and diffusivity profiles.
|
|
2
|
+
|
|
3
|
+
This module provides binned (histogram-style) counterparts to the KDE
|
|
4
|
+
classes in :mod:`qdiffusivity.density` and
|
|
5
|
+
:mod:`qdiffusivity.diffusivity`. Binning is always in *u-space* — the
|
|
6
|
+
CDF-uniformised coordinate :math:`u = F(z) \in [0, 1]` built from the
|
|
7
|
+
pooled equilibrium positions — so that bins are naturally finer where
|
|
8
|
+
the particle density is high (adsorption peaks) and coarser where it is
|
|
9
|
+
low, and every bin receives a comparable number of samples. This is
|
|
10
|
+
the same equal-population strategy used by the project's quantile
|
|
11
|
+
scripts.
|
|
12
|
+
|
|
13
|
+
Two assignment schemes are supported:
|
|
14
|
+
|
|
15
|
+
* **Cloud-in-cell (CIC)** — used for integer ``bins`` (N uniform
|
|
16
|
+
u-space bins). Each sample is linearly split between the two nearest
|
|
17
|
+
bin centres, avoiding the discontinuities that hard binning would
|
|
18
|
+
introduce at the (population-balanced) bin edges.
|
|
19
|
+
|
|
20
|
+
* **Hard assignment** — used when explicit bin edges (an array in
|
|
21
|
+
``[0, 1]``) are supplied. Each sample falls in exactly one bin
|
|
22
|
+
(``np.digitize``), matching the standard histogram behaviour.
|
|
23
|
+
|
|
24
|
+
The perpendicular diffusivity estimator supports the same
|
|
25
|
+
:math:`O(\Delta t)` Itô bias correction as the KDE diffusivity class,
|
|
26
|
+
gated on ``ito_correction=False`` by default.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import numpy as np
|
|
32
|
+
from MDAnalysis.analysis.base import AnalysisBase
|
|
33
|
+
from MDAnalysis.transformations import NoJump
|
|
34
|
+
|
|
35
|
+
from .diffusivity import build_cdf
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def resolve_bins(bins, n_default=30):
|
|
39
|
+
"""Resolve a ``bins`` spec into ``(n_bins, edges, use_cic)``.
|
|
40
|
+
|
|
41
|
+
Parameters
|
|
42
|
+
----------
|
|
43
|
+
bins : int, array_like, or "quantile"
|
|
44
|
+
``int`` — N uniform u-space bins in [0, 1] (CIC assignment).
|
|
45
|
+
``"quantile"`` — shortcut for the default ``n_default`` uniform
|
|
46
|
+
u-space bins (CIC).
|
|
47
|
+
``array_like`` — explicit u-space bin edges in ``[0, 1]`` (hard
|
|
48
|
+
assignment via ``np.digitize``). Must be sorted and span a
|
|
49
|
+
sub-interval of ``[0, 1]``.
|
|
50
|
+
n_default : int, optional
|
|
51
|
+
Number of bins when ``bins == "quantile"``. Default 30.
|
|
52
|
+
|
|
53
|
+
Returns
|
|
54
|
+
-------
|
|
55
|
+
n_bins : int
|
|
56
|
+
Number of bins.
|
|
57
|
+
edges : numpy.ndarray
|
|
58
|
+
``(n_bins + 1,)`` u-space bin edges in ``[0, 1]`` (uniform for
|
|
59
|
+
int / ``"quantile"``; as supplied for an array, padded with
|
|
60
|
+
0 and 1 if needed).
|
|
61
|
+
use_cic : bool
|
|
62
|
+
``True`` for CIC assignment (int / ``"quantile"``);
|
|
63
|
+
``False`` for hard assignment (explicit edges).
|
|
64
|
+
"""
|
|
65
|
+
if isinstance(bins, str):
|
|
66
|
+
if bins != "quantile":
|
|
67
|
+
raise ValueError(f"bins string must be 'quantile', got {bins!r}")
|
|
68
|
+
n = int(n_default)
|
|
69
|
+
edges = np.linspace(0.0, 1.0, n + 1)
|
|
70
|
+
return n, edges, True
|
|
71
|
+
|
|
72
|
+
if isinstance(bins, (int, np.integer)):
|
|
73
|
+
n = int(bins)
|
|
74
|
+
if n < 1:
|
|
75
|
+
raise ValueError(f"bins must be >= 1, got {n}")
|
|
76
|
+
edges = np.linspace(0.0, 1.0, n + 1)
|
|
77
|
+
return n, edges, True
|
|
78
|
+
|
|
79
|
+
arr = np.asarray(bins, dtype=float).ravel()
|
|
80
|
+
if arr.size < 2:
|
|
81
|
+
raise ValueError("explicit bin edges must have >= 2 values")
|
|
82
|
+
if np.any(np.diff(arr) <= 0):
|
|
83
|
+
raise ValueError("explicit bin edges must be strictly increasing")
|
|
84
|
+
if arr[0] < 0.0 or arr[-1] > 1.0:
|
|
85
|
+
raise ValueError("explicit bin edges must lie in [0, 1]")
|
|
86
|
+
# Pad with 0 / 1 if the user supplied interior edges only.
|
|
87
|
+
if arr[0] > 0.0:
|
|
88
|
+
arr = np.concatenate([[0.0], arr])
|
|
89
|
+
if arr[-1] < 1.0:
|
|
90
|
+
arr = np.concatenate([arr, [1.0]])
|
|
91
|
+
n = arr.size - 1
|
|
92
|
+
return n, arr, False
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def cic_assign(u_data, n_bins):
|
|
96
|
+
"""Cloud-in-cell assignment of u-space samples to uniform bins.
|
|
97
|
+
|
|
98
|
+
Each sample at fractional bin index ``q = u * n_bins - 0.5`` is split
|
|
99
|
+
between the two nearest bin centres: weight ``1 - frac`` to bin
|
|
100
|
+
``k0 = floor(q)`` and weight ``frac`` to bin ``k0 + 1``. Samples
|
|
101
|
+
outside ``[0, n_bins - 1]`` (in the outer half of the first or last
|
|
102
|
+
bin) are mirror-reflected back into the interior, so each boundary
|
|
103
|
+
bin receives its correct share rather than a half- or double-weight
|
|
104
|
+
artefact.
|
|
105
|
+
|
|
106
|
+
Parameters
|
|
107
|
+
----------
|
|
108
|
+
u_data : array_like
|
|
109
|
+
``(N,)`` u-space positions in ``[0, 1]``.
|
|
110
|
+
n_bins : int
|
|
111
|
+
Number of uniform u-space bins.
|
|
112
|
+
|
|
113
|
+
Returns
|
|
114
|
+
-------
|
|
115
|
+
k0, k1 : numpy.ndarray
|
|
116
|
+
``(N,)`` int arrays of the two bin indices each sample
|
|
117
|
+
contributes to (clipped to ``[0, n_bins - 1]``).
|
|
118
|
+
w0, w1 : numpy.ndarray
|
|
119
|
+
``(N,)`` float arrays of the CIC weights for ``k0`` and ``k1``
|
|
120
|
+
(``w0 + w1 == 1``).
|
|
121
|
+
"""
|
|
122
|
+
u = np.asarray(u_data, dtype=float).ravel()
|
|
123
|
+
q = u * n_bins - 0.5
|
|
124
|
+
# Mirror reflection at the outer boundaries.
|
|
125
|
+
q = np.where(q < 0.0, -q, q)
|
|
126
|
+
q = np.where(q > n_bins - 1.0, 2.0 * (n_bins - 1.0) - q, q)
|
|
127
|
+
k0 = np.floor(q).astype(int)
|
|
128
|
+
frac = q - k0
|
|
129
|
+
k0 = np.clip(k0, 0, n_bins - 1)
|
|
130
|
+
k1 = np.clip(k0 + 1, 0, n_bins - 1)
|
|
131
|
+
w0 = 1.0 - frac
|
|
132
|
+
w1 = frac
|
|
133
|
+
return k0, k1, w0, w1
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _bin_centers_from_edges(edges):
|
|
137
|
+
"""Return the midpoints of ``edges``."""
|
|
138
|
+
return 0.5 * (edges[:-1] + edges[1:])
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class TransverseDensityQBinned(AnalysisBase):
|
|
142
|
+
r"""CDF-binned transverse number-density profile.
|
|
143
|
+
|
|
144
|
+
Pools per-frame positions of ``atomgroup`` along ``dim`` (the
|
|
145
|
+
confined axis), builds a CDF-uniformised u-space, and assigns each
|
|
146
|
+
sample to u-space bins via cloud-in-cell (CIC) or hard assignment.
|
|
147
|
+
The bin population is converted to a number density via the
|
|
148
|
+
cross-sectional area and the number of analysis frames.
|
|
149
|
+
|
|
150
|
+
Parameters
|
|
151
|
+
----------
|
|
152
|
+
atomgroup : MDAnalysis.core.groups.AtomGroup
|
|
153
|
+
Atoms whose positions are sampled (or, with
|
|
154
|
+
``grouping="residues"``, the atoms whose residue centre-of-mass
|
|
155
|
+
positions are sampled).
|
|
156
|
+
dim : int, optional
|
|
157
|
+
Confined-axis index (0=x, 1=y, 2=z). Default 2 (z).
|
|
158
|
+
z_bot, z_top : float, optional
|
|
159
|
+
Boundaries of the confined region. If ``None`` they default to
|
|
160
|
+
``0`` and the box length along ``dim``.
|
|
161
|
+
bins : int, "quantile", or array_like, optional
|
|
162
|
+
``int`` — N uniform u-space bins (CIC assignment). Default 30.
|
|
163
|
+
``"quantile"`` — shortcut for 30 uniform u-space bins.
|
|
164
|
+
``array_like`` — explicit u-space edges in ``[0, 1]`` (hard
|
|
165
|
+
assignment).
|
|
166
|
+
grouping : {"atoms", "residues"}, optional
|
|
167
|
+
``"atoms"`` samples every atom's position each frame;
|
|
168
|
+
``"residues"`` samples the mass-weighted centre-of-mass of
|
|
169
|
+
each residue each frame. Default ``"atoms"``.
|
|
170
|
+
|
|
171
|
+
Attributes
|
|
172
|
+
----------
|
|
173
|
+
z_centers : numpy.ndarray
|
|
174
|
+
``(n_bins,)`` bin centres in z-space (via the inverse CDF).
|
|
175
|
+
u_centers : numpy.ndarray
|
|
176
|
+
``(n_bins,)`` bin centres in u-space.
|
|
177
|
+
density : numpy.ndarray
|
|
178
|
+
``(n_bins,)`` number density (particles per volume), computed
|
|
179
|
+
as ``(N_total / (n_frames_used * A)) * (bin_population /
|
|
180
|
+
bin_width_in_u) * rho(z_center)`` — i.e. the CDF bin population
|
|
181
|
+
rescaled by the local Jacobian :math:`\rho = du/dz`.
|
|
182
|
+
n_per_bin : numpy.ndarray
|
|
183
|
+
``(n_bins,)`` raw (weighted) population per bin.
|
|
184
|
+
n_eff : numpy.ndarray
|
|
185
|
+
``(n_bins,)`` effective sample size (sum of CIC weights, or
|
|
186
|
+
integer count for hard assignment).
|
|
187
|
+
bin_edges_u : numpy.ndarray
|
|
188
|
+
``(n_bins + 1,)`` u-space bin edges.
|
|
189
|
+
n_total : int
|
|
190
|
+
Total number of pooled samples.
|
|
191
|
+
n_frames_used : int
|
|
192
|
+
Number of analysis frames pooled.
|
|
193
|
+
n_per_frame : float
|
|
194
|
+
Mean number of samples per frame.
|
|
195
|
+
P, P_inv, rho : callables
|
|
196
|
+
CDF, inverse-CDF and equilibrium density closures.
|
|
197
|
+
|
|
198
|
+
Notes
|
|
199
|
+
-----
|
|
200
|
+
Confined-axis positions are wrapped into the primary box cell along
|
|
201
|
+
``dim`` before pooling. For ``grouping="residues"`` the
|
|
202
|
+
centre-of-mass is computed with the atoms' topology masses.
|
|
203
|
+
|
|
204
|
+
Examples
|
|
205
|
+
--------
|
|
206
|
+
::
|
|
207
|
+
|
|
208
|
+
import MDAnalysis as mda
|
|
209
|
+
from qdiffusivity import TransverseDensityQBinned
|
|
210
|
+
|
|
211
|
+
u = mda.Universe("topology.data", "trajectory.xtc")
|
|
212
|
+
ag = u.select_atoms("type 1 2")
|
|
213
|
+
binned = TransverseDensityQBinned(
|
|
214
|
+
ag, dim=2, z_bot=10.0, z_top=90.0, bins=30,
|
|
215
|
+
)
|
|
216
|
+
binned.run()
|
|
217
|
+
# binned.density is in particles/Å^3.
|
|
218
|
+
"""
|
|
219
|
+
|
|
220
|
+
def __init__(
|
|
221
|
+
self,
|
|
222
|
+
atomgroup,
|
|
223
|
+
*,
|
|
224
|
+
dim: int = 2,
|
|
225
|
+
z_bot=None,
|
|
226
|
+
z_top=None,
|
|
227
|
+
bins=30,
|
|
228
|
+
grouping: str = "atoms",
|
|
229
|
+
):
|
|
230
|
+
super().__init__(atomgroup.universe.trajectory)
|
|
231
|
+
self._ag = atomgroup
|
|
232
|
+
if dim not in (0, 1, 2):
|
|
233
|
+
raise ValueError(f"dim must be 0, 1 or 2, got {dim}")
|
|
234
|
+
self._dim = dim
|
|
235
|
+
self._z_bot_user = z_bot
|
|
236
|
+
self._z_top_user = z_top
|
|
237
|
+
self._n_bins, self._edges_u, self._use_cic = resolve_bins(bins)
|
|
238
|
+
if grouping not in ("atoms", "residues"):
|
|
239
|
+
raise ValueError(
|
|
240
|
+
f"grouping must be 'atoms' or 'residues', got {grouping!r}"
|
|
241
|
+
)
|
|
242
|
+
self._grouping = grouping
|
|
243
|
+
|
|
244
|
+
def _prepare(self):
|
|
245
|
+
if self._grouping == "residues":
|
|
246
|
+
residx = self._ag.resindices
|
|
247
|
+
self._res_unique, self._res_inv = np.unique(
|
|
248
|
+
residx, return_inverse=True
|
|
249
|
+
)
|
|
250
|
+
self._n_res = self._res_unique.size
|
|
251
|
+
self._masses = self._ag.masses.astype(np.float64)
|
|
252
|
+
self._res_mass = np.zeros(self._n_res, dtype=np.float64)
|
|
253
|
+
np.add.at(self._res_mass, self._res_inv, self._masses)
|
|
254
|
+
self._u_frames = []
|
|
255
|
+
|
|
256
|
+
def _single_frame(self):
|
|
257
|
+
L = float(self._ts.dimensions[self._dim])
|
|
258
|
+
if self._grouping == "residues":
|
|
259
|
+
z_atoms = self._ag.positions[:, self._dim]
|
|
260
|
+
masses = self._masses
|
|
261
|
+
mz = np.zeros(self._n_res, dtype=np.float64)
|
|
262
|
+
np.add.at(mz, self._res_inv, masses * z_atoms)
|
|
263
|
+
com_z = mz / self._res_mass
|
|
264
|
+
if L > 0:
|
|
265
|
+
com_z %= L
|
|
266
|
+
self._u_frames.append(com_z)
|
|
267
|
+
else:
|
|
268
|
+
z = self._ag.positions[:, self._dim].astype(np.float64).copy()
|
|
269
|
+
if L > 0:
|
|
270
|
+
z %= L
|
|
271
|
+
self._u_frames.append(z)
|
|
272
|
+
|
|
273
|
+
def _conclude(self):
|
|
274
|
+
z_frames = (
|
|
275
|
+
np.concatenate(self._u_frames)
|
|
276
|
+
if self._u_frames
|
|
277
|
+
else np.empty(0, dtype=np.float64)
|
|
278
|
+
)
|
|
279
|
+
self.n_total = int(z_frames.size)
|
|
280
|
+
self.n_frames_used = len(self._u_frames)
|
|
281
|
+
self.n_per_frame = (
|
|
282
|
+
self.n_total / self.n_frames_used if self.n_frames_used > 0 else 0.0
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
self.P, self.P_inv, self.rho, _, _, _ = build_cdf(z_frames)
|
|
286
|
+
self.u_centers = _bin_centers_from_edges(self._edges_u)
|
|
287
|
+
self.z_centers = self.P_inv(self.u_centers)
|
|
288
|
+
n_bins = self._n_bins
|
|
289
|
+
|
|
290
|
+
if z_frames.size == 0:
|
|
291
|
+
self.density = np.zeros(n_bins, dtype=np.float64)
|
|
292
|
+
self.n_per_bin = np.zeros(n_bins, dtype=np.float64)
|
|
293
|
+
self.n_eff = np.zeros(n_bins, dtype=np.float64)
|
|
294
|
+
return
|
|
295
|
+
|
|
296
|
+
u_samples = self.P(z_frames)
|
|
297
|
+
|
|
298
|
+
if self._use_cic:
|
|
299
|
+
k0, k1, w0, w1 = cic_assign(u_samples, n_bins)
|
|
300
|
+
pop = np.zeros(n_bins, dtype=np.float64)
|
|
301
|
+
np.add.at(pop, k0, w0)
|
|
302
|
+
np.add.at(pop, k1, w1)
|
|
303
|
+
self.n_eff = pop.copy()
|
|
304
|
+
else:
|
|
305
|
+
# Hard assignment via digitize. Samples exactly on the
|
|
306
|
+
# upper edge (u == 1) are folded into the last bin.
|
|
307
|
+
idx = np.clip(
|
|
308
|
+
np.digitize(u_samples, self._edges_u) - 1,
|
|
309
|
+
0,
|
|
310
|
+
n_bins - 1,
|
|
311
|
+
)
|
|
312
|
+
pop = np.bincount(idx, minlength=n_bins).astype(np.float64)
|
|
313
|
+
self.n_eff = pop.copy()
|
|
314
|
+
|
|
315
|
+
self.n_per_bin = pop
|
|
316
|
+
|
|
317
|
+
# Convert bin population to a number density.
|
|
318
|
+
# n(z) = (N_total / (n_frames * A)) * rho(z)
|
|
319
|
+
# because in u-space the population per bin is ~N_total * du,
|
|
320
|
+
# and du = rho(z) * dz, so the z-space number density is
|
|
321
|
+
# (N_total / (n_frames * A)) * (pop / N_total) / dz
|
|
322
|
+
# = pop / (n_frames * A * dz), with dz = du / rho(z).
|
|
323
|
+
A = 1.0
|
|
324
|
+
dims = self._ag.universe.dimensions
|
|
325
|
+
if dims is not None:
|
|
326
|
+
para = [i for i in (0, 1, 2) if i != self._dim]
|
|
327
|
+
A = float(dims[para[0]] * dims[para[1]])
|
|
328
|
+
rho_centers = self.rho(self.z_centers)
|
|
329
|
+
du = np.diff(self._edges_u)
|
|
330
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
331
|
+
dz = np.where(rho_centers > 0, du / rho_centers, 0.0)
|
|
332
|
+
self.density = np.where(
|
|
333
|
+
dz > 0,
|
|
334
|
+
pop / (self.n_frames_used * A * dz),
|
|
335
|
+
0.0,
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
class LocalDiffusivityQBinned(AnalysisBase):
|
|
340
|
+
r"""CDF-binned transverse diffusivity estimator.
|
|
341
|
+
|
|
342
|
+
Pools per-frame positions of ``atomgroup``, builds a CDF-uniformised
|
|
343
|
+
u-space, and evaluates a binned local estimator for the perpendicular
|
|
344
|
+
(:math:`D_\perp`) and parallel (:math:`D_\parallel`) diffusivities.
|
|
345
|
+
Samples are assigned to u-space bins via cloud-in-cell (CIC) for
|
|
346
|
+
integer ``bins`` or hard assignment for explicit edges.
|
|
347
|
+
|
|
348
|
+
The perpendicular estimator uses the z-space local estimator
|
|
349
|
+
:math:`(\Delta z)^2/(2\Delta t)`, binned in u-space. The parallel
|
|
350
|
+
estimator uses
|
|
351
|
+
:math:`(\Delta x^2+\Delta y^2)/(4\Delta t)`, binned by the
|
|
352
|
+
starting position in u-space.
|
|
353
|
+
|
|
354
|
+
Parameters
|
|
355
|
+
----------
|
|
356
|
+
atomgroup : MDAnalysis.core.groups.AtomGroup
|
|
357
|
+
Atoms whose positions are sampled.
|
|
358
|
+
dim : int, optional
|
|
359
|
+
Confined-axis index (0=x, 1=y, 2=z). Default 2 (z).
|
|
360
|
+
n_points : int, optional
|
|
361
|
+
Number of evaluation grid points ``M`` (uniform in u-space).
|
|
362
|
+
Default 200.
|
|
363
|
+
bins : int, "quantile", or array_like, optional
|
|
364
|
+
``int`` — N uniform u-space bins (CIC assignment). Default 30.
|
|
365
|
+
``"quantile"`` — shortcut for 30 uniform u-space bins.
|
|
366
|
+
``array_like`` — explicit u-space edges in ``[0, 1]`` (hard
|
|
367
|
+
assignment).
|
|
368
|
+
dt : float or None, optional
|
|
369
|
+
Time step between consecutive frames. If ``None`` (default) the
|
|
370
|
+
trajectory's ``dt`` (:attr:`ts.dt`) is used.
|
|
371
|
+
ito_correction : bool, optional
|
|
372
|
+
If ``True``, subtract the :math:`O(\Delta t)` Itô bias
|
|
373
|
+
:math:`\frac{\Delta t}{2}\Phi(z)^2` (with
|
|
374
|
+
:math:`\Phi = D\,\rho'/\rho` in the isothermal convention) from
|
|
375
|
+
the perpendicular estimator. Default ``False``.
|
|
376
|
+
|
|
377
|
+
Attributes
|
|
378
|
+
----------
|
|
379
|
+
z_centers : numpy.ndarray
|
|
380
|
+
``(n_bins,)`` bin centres in z-space (via the inverse CDF).
|
|
381
|
+
u_centers : numpy.ndarray
|
|
382
|
+
``(n_bins,)`` bin centres in u-space.
|
|
383
|
+
D_perp : numpy.ndarray
|
|
384
|
+
``(n_bins,)`` perpendicular diffusivity (length²/time), clipped
|
|
385
|
+
to be non-negative. When ``ito_correction=True`` the Itô bias
|
|
386
|
+
is subtracted before clipping.
|
|
387
|
+
D_perp_std : numpy.ndarray
|
|
388
|
+
``(n_bins,)`` standard error of :math:`D_\perp`.
|
|
389
|
+
n_eff_perp : numpy.ndarray
|
|
390
|
+
``(n_bins,)`` effective sample size for :math:`D_\perp`.
|
|
391
|
+
D_para : numpy.ndarray
|
|
392
|
+
``(n_bins,)`` parallel diffusivity (length²/time).
|
|
393
|
+
D_para_std : numpy.ndarray
|
|
394
|
+
``(n_bins,)`` standard error of :math:`D_\parallel`.
|
|
395
|
+
n_eff_para : numpy.ndarray
|
|
396
|
+
``(n_bins,)`` effective sample size for :math:`D_\parallel`.
|
|
397
|
+
ito_bias : numpy.ndarray or None
|
|
398
|
+
``(n_bins,)`` Itô bias subtracted from :math:`D_\perp` when
|
|
399
|
+
``ito_correction=True``; ``None`` otherwise.
|
|
400
|
+
bin_edges_u : numpy.ndarray
|
|
401
|
+
``(n_bins + 1,)`` u-space bin edges.
|
|
402
|
+
n_increments : int
|
|
403
|
+
Total number of trajectory increments pooled.
|
|
404
|
+
n_frames_used : int
|
|
405
|
+
Number of frames pooled.
|
|
406
|
+
dt : float
|
|
407
|
+
Time step used in the estimator.
|
|
408
|
+
P, P_inv, rho : callables
|
|
409
|
+
CDF, inverse-CDF and equilibrium density closures.
|
|
410
|
+
|
|
411
|
+
Notes
|
|
412
|
+
-----
|
|
413
|
+
Parallel (x/y) positions are unwrapped with MDAnalysis's ``NoJump``
|
|
414
|
+
transformation so that multi-frame displacements across periodic
|
|
415
|
+
boundaries are captured. The transformation is attached in place
|
|
416
|
+
(guarded so it is only attached once per Universe) and the full
|
|
417
|
+
trajectory is iterated from frame 0 (``NoJump`` is stateful).
|
|
418
|
+
|
|
419
|
+
Examples
|
|
420
|
+
--------
|
|
421
|
+
::
|
|
422
|
+
|
|
423
|
+
import MDAnalysis as mda
|
|
424
|
+
from qdiffusivity import LocalDiffusivityQBinned
|
|
425
|
+
|
|
426
|
+
u = mda.Universe("topology.data", "trajectory.xtc")
|
|
427
|
+
ag = u.select_atoms("type 1 2")
|
|
428
|
+
binned = LocalDiffusivityQBinned(
|
|
429
|
+
ag, dim=2, bins=30, ito_correction=True,
|
|
430
|
+
)
|
|
431
|
+
binned.run()
|
|
432
|
+
"""
|
|
433
|
+
|
|
434
|
+
def __init__(
|
|
435
|
+
self,
|
|
436
|
+
atomgroup,
|
|
437
|
+
*,
|
|
438
|
+
dim: int = 2,
|
|
439
|
+
bins=30,
|
|
440
|
+
dt=None,
|
|
441
|
+
ito_correction: bool = False,
|
|
442
|
+
):
|
|
443
|
+
super().__init__(atomgroup.universe.trajectory)
|
|
444
|
+
self._ag = atomgroup
|
|
445
|
+
if dim not in (0, 1, 2):
|
|
446
|
+
raise ValueError(f"dim must be 0, 1 or 2, got {dim}")
|
|
447
|
+
self._dim = dim
|
|
448
|
+
self._para_dims = tuple(i for i in (0, 1, 2) if i != dim)
|
|
449
|
+
self._n_bins, self._edges_u, self._use_cic = resolve_bins(bins)
|
|
450
|
+
self._dt = dt
|
|
451
|
+
self._ito_correction = bool(ito_correction)
|
|
452
|
+
|
|
453
|
+
def _prepare(self):
|
|
454
|
+
self._pos_frames = []
|
|
455
|
+
|
|
456
|
+
def _single_frame(self):
|
|
457
|
+
self._pos_frames.append(self._ag.positions.copy())
|
|
458
|
+
|
|
459
|
+
def _conclude(self):
|
|
460
|
+
pos = np.asarray(self._pos_frames, dtype=np.float64)
|
|
461
|
+
n_frames = pos.shape[0]
|
|
462
|
+
self.n_frames_used = n_frames
|
|
463
|
+
if n_frames < 2:
|
|
464
|
+
raise ValueError(
|
|
465
|
+
f"need at least 2 frames to form a displacement; got {n_frames}"
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
dt = self._dt if self._dt is not None else float(self._ts.dt)
|
|
469
|
+
self.dt = dt
|
|
470
|
+
if dt <= 0:
|
|
471
|
+
raise ValueError(f"dt must be positive, got {dt}")
|
|
472
|
+
|
|
473
|
+
# Unwrap parallel coordinates via NoJump.
|
|
474
|
+
u = self._ag.universe
|
|
475
|
+
if not getattr(u, "_qdiff_nojump_applied", False):
|
|
476
|
+
u.trajectory[0]
|
|
477
|
+
u.trajectory.add_transformations(NoJump(self._ag))
|
|
478
|
+
u._qdiff_nojump_applied = True
|
|
479
|
+
|
|
480
|
+
pos_unwrapped = np.empty_like(pos)
|
|
481
|
+
Lz = None
|
|
482
|
+
k = 0
|
|
483
|
+
for idx in range(0, u.trajectory.n_frames):
|
|
484
|
+
u.trajectory[idx]
|
|
485
|
+
if k >= n_frames:
|
|
486
|
+
break
|
|
487
|
+
pos_unwrapped[k] = self._ag.positions
|
|
488
|
+
Lz = u.dimensions[self._dim]
|
|
489
|
+
k += 1
|
|
490
|
+
if Lz is not None and Lz > 0:
|
|
491
|
+
pos_unwrapped[:, :, self._dim] %= Lz
|
|
492
|
+
|
|
493
|
+
# Build CDF from pooled wrapped-z.
|
|
494
|
+
z_pooled = pos_unwrapped[:, :, self._dim].ravel()
|
|
495
|
+
self.P, self.P_inv, self.rho, self.rho_prime, _, _ = build_cdf(z_pooled)
|
|
496
|
+
|
|
497
|
+
self.u_centers = _bin_centers_from_edges(self._edges_u)
|
|
498
|
+
self.z_centers = self.P_inv(self.u_centers)
|
|
499
|
+
n_bins = self._n_bins
|
|
500
|
+
|
|
501
|
+
# u-positions of all increments (starting frame of each pair).
|
|
502
|
+
z_start = pos_unwrapped[:-1, :, self._dim]
|
|
503
|
+
u_start = self.P(z_start.ravel())
|
|
504
|
+
|
|
505
|
+
# Perpendicular: z-space local estimator (Δz)²/(2Δt).
|
|
506
|
+
dz = np.diff(pos_unwrapped[:, :, self._dim], axis=0)
|
|
507
|
+
d_perp_local = (dz**2) / (2.0 * dt)
|
|
508
|
+
self.n_increments = int(d_perp_local.size)
|
|
509
|
+
|
|
510
|
+
self.D_perp, self.D_perp_std, self.n_eff_perp = _bin_local_estimator(
|
|
511
|
+
u_start,
|
|
512
|
+
d_perp_local.ravel(),
|
|
513
|
+
self._edges_u,
|
|
514
|
+
n_bins,
|
|
515
|
+
self._use_cic,
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
# Itô bias correction (perpendicular only; parallel has none).
|
|
519
|
+
if self._ito_correction:
|
|
520
|
+
rho_eval = self.rho(self.z_centers)
|
|
521
|
+
rho_prime_eval = self.rho_prime(self.z_centers)
|
|
522
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
523
|
+
phi = np.where(
|
|
524
|
+
rho_eval > 0,
|
|
525
|
+
self.D_perp * rho_prime_eval / rho_eval,
|
|
526
|
+
0.0,
|
|
527
|
+
)
|
|
528
|
+
self.ito_bias = 0.5 * dt * phi**2
|
|
529
|
+
self.D_perp = self.D_perp - self.ito_bias
|
|
530
|
+
else:
|
|
531
|
+
self.ito_bias = None
|
|
532
|
+
|
|
533
|
+
self.D_perp = np.clip(self.D_perp, 0, None)
|
|
534
|
+
|
|
535
|
+
# Parallel: Δx² + Δy², only z_start mapped to u.
|
|
536
|
+
d_para_acc = np.zeros_like(dz)
|
|
537
|
+
for d in self._para_dims:
|
|
538
|
+
diff_d = np.diff(pos_unwrapped[:, :, d], axis=0)
|
|
539
|
+
d_para_acc += diff_d**2
|
|
540
|
+
d_para_local = d_para_acc / (4.0 * dt)
|
|
541
|
+
|
|
542
|
+
self.D_para, self.D_para_std, self.n_eff_para = _bin_local_estimator(
|
|
543
|
+
u_start,
|
|
544
|
+
d_para_local.ravel(),
|
|
545
|
+
self._edges_u,
|
|
546
|
+
n_bins,
|
|
547
|
+
self._use_cic,
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def _bin_local_estimator(u_data, d_data, edges_u, n_bins, use_cic):
|
|
552
|
+
"""Bin a local estimator in u-space and return (D, D_std, n_eff).
|
|
553
|
+
|
|
554
|
+
Parameters
|
|
555
|
+
----------
|
|
556
|
+
u_data : array_like
|
|
557
|
+
``(N,)`` u-space positions of the increments.
|
|
558
|
+
d_data : array_like
|
|
559
|
+
``(N,)`` local estimator values (already carrying the
|
|
560
|
+
``1/(2Δt)`` or ``1/(4Δt)`` prefactor).
|
|
561
|
+
edges_u : array_like
|
|
562
|
+
``(n_bins + 1,)`` u-space bin edges in ``[0, 1]``.
|
|
563
|
+
n_bins : int
|
|
564
|
+
Number of bins.
|
|
565
|
+
use_cic : bool
|
|
566
|
+
``True`` for CIC assignment, ``False`` for hard assignment.
|
|
567
|
+
|
|
568
|
+
Returns
|
|
569
|
+
-------
|
|
570
|
+
D : numpy.ndarray
|
|
571
|
+
``(n_bins,)`` weighted mean of ``d_data`` per bin.
|
|
572
|
+
D_std : numpy.ndarray
|
|
573
|
+
``(n_bins,)`` standard error of the mean per bin.
|
|
574
|
+
n_eff : numpy.ndarray
|
|
575
|
+
``(n_bins,)`` effective sample size per bin.
|
|
576
|
+
"""
|
|
577
|
+
u = np.asarray(u_data, dtype=float).ravel()
|
|
578
|
+
d = np.asarray(d_data, dtype=float).ravel()
|
|
579
|
+
n_bins = int(n_bins)
|
|
580
|
+
|
|
581
|
+
if u.size == 0:
|
|
582
|
+
zeros = np.zeros(n_bins, dtype=np.float64)
|
|
583
|
+
return zeros, zeros, zeros
|
|
584
|
+
|
|
585
|
+
D_sum = np.zeros(n_bins, dtype=np.float64)
|
|
586
|
+
D2_sum = np.zeros(n_bins, dtype=np.float64)
|
|
587
|
+
w_sum = np.zeros(n_bins, dtype=np.float64)
|
|
588
|
+
|
|
589
|
+
if use_cic:
|
|
590
|
+
k0, k1, w0, w1 = cic_assign(u, n_bins)
|
|
591
|
+
np.add.at(D_sum, k0, d * w0)
|
|
592
|
+
np.add.at(D_sum, k1, d * w1)
|
|
593
|
+
np.add.at(D2_sum, k0, d * d * w0)
|
|
594
|
+
np.add.at(D2_sum, k1, d * d * w1)
|
|
595
|
+
np.add.at(w_sum, k0, w0)
|
|
596
|
+
np.add.at(w_sum, k1, w1)
|
|
597
|
+
else:
|
|
598
|
+
idx = np.clip(np.digitize(u, edges_u) - 1, 0, n_bins - 1)
|
|
599
|
+
np.add.at(D_sum, idx, d)
|
|
600
|
+
np.add.at(D2_sum, idx, d * d)
|
|
601
|
+
np.add.at(w_sum, idx, 1.0)
|
|
602
|
+
|
|
603
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
604
|
+
D = np.where(w_sum > 0, D_sum / w_sum, np.nan)
|
|
605
|
+
mean_d2 = np.where(w_sum > 0, D2_sum / w_sum, np.nan)
|
|
606
|
+
var_d = np.where(w_sum > 0, mean_d2 - D**2, 0.0)
|
|
607
|
+
var_d = np.maximum(var_d, 0.0)
|
|
608
|
+
n_eff = w_sum
|
|
609
|
+
D_std = np.where(
|
|
610
|
+
n_eff > 1,
|
|
611
|
+
np.sqrt(var_d / np.maximum(n_eff, 1.0)),
|
|
612
|
+
0.0,
|
|
613
|
+
)
|
|
614
|
+
return D, D_std, n_eff
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Sample Package Data
|
|
2
|
+
|
|
3
|
+
This directory contains sample additional data you may want to include with your package.
|
|
4
|
+
This is a place where non-code related additional information (such as data files, molecular structures, etc.) can
|
|
5
|
+
go that you want to ship alongside your code.
|
|
6
|
+
|
|
7
|
+
Please note that it is not recommended to place large files in your git directory. If your project requires files larger
|
|
8
|
+
than a few megabytes in size it is recommended to host these files elsewhere. This is especially true for binary files
|
|
9
|
+
as the `git` structure is unable to correctly take updates to these files and will store a complete copy of every version
|
|
10
|
+
in your `git` history which can quickly add up. As a note most `git` hosting services like GitHub have a 1 GB per repository
|
|
11
|
+
cap.
|
|
12
|
+
|
|
13
|
+
## Including package data
|
|
14
|
+
|
|
15
|
+
Modify your package's `setup.py` file and the `setup()` command. Include the
|
|
16
|
+
[`package_data`](http://setuptools.readthedocs.io/en/latest/setuptools.html#basic-use) keyword and point it at the
|
|
17
|
+
correct files.
|
|
File without changes
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Location of data files
|
|
3
|
+
======================
|
|
4
|
+
|
|
5
|
+
Use as ::
|
|
6
|
+
|
|
7
|
+
from qdiffusivity.data.files import *
|
|
8
|
+
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"MDANALYSIS_LOGO", # example file of MDAnalysis logo
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
import importlib.resources
|
|
16
|
+
|
|
17
|
+
data_directory = importlib.resources.files("qdiffusivity") / "data"
|
|
18
|
+
|
|
19
|
+
MDANALYSIS_LOGO = data_directory / "mda.txt"
|