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
|
@@ -0,0 +1,750 @@
|
|
|
1
|
+
r"""Kernel density estimator for position-dependent diffusivity.
|
|
2
|
+
|
|
3
|
+
This module provides a kernel-weighted local estimator for the
|
|
4
|
+
perpendicular (transverse) and parallel diffusivities of nanoconfined
|
|
5
|
+
molecular dynamics simulations, generalising the per-project script
|
|
6
|
+
``zn-el/analysis/diff_kde/kde_diffusivity.py`` into a reusable
|
|
7
|
+
:class:`~MDAnalysis.analysis.base.AnalysisBase` class.
|
|
8
|
+
|
|
9
|
+
Theory
|
|
10
|
+
------
|
|
11
|
+
The estimator works in *u-space*, the CDF-uniformised coordinate
|
|
12
|
+
:math:`u = F(z) \in [0, 1]` built from the pooled equilibrium positions.
|
|
13
|
+
In u-space the equilibrium measure is uniform, so a single global
|
|
14
|
+
bandwidth is appropriate across the whole confined region (including
|
|
15
|
+
near the walls, where a z-space bandwidth would over-smooth the sparse
|
|
16
|
+
adsorption peaks and under-resolve the dense bulk).
|
|
17
|
+
|
|
18
|
+
* **CDF map.** Each starting position :math:`z` is mapped to
|
|
19
|
+
:math:`u = F(z) \in [0, 1]` via the empirical CDF (piecewise-linear
|
|
20
|
+
interpolation through the sorted positions, using the midrank
|
|
21
|
+
:math:`p = (i + 0.5)/n` so the CDF never returns exactly 0 or 1).
|
|
22
|
+
|
|
23
|
+
* **Perpendicular (transverse) estimator.** For each trajectory
|
|
24
|
+
increment with starting position :math:`z_j` and displacement
|
|
25
|
+
:math:`\Delta z_j`, the z-space local estimator is
|
|
26
|
+
:math:`\hat d_j = (\Delta z_j)^2 / (2\Delta t)`. The kernel
|
|
27
|
+
localises in u-space (variance equalisation) but the estimator is the
|
|
28
|
+
z-space MLE, so :math:`D_\perp` is obtained directly — no
|
|
29
|
+
:math:`\rho^{-2}` conversion is applied:
|
|
30
|
+
|
|
31
|
+
.. math::
|
|
32
|
+
|
|
33
|
+
\hat{D}_\perp(u_0) =
|
|
34
|
+
\frac{\sum_j K_h(u_0 - u_j)\,(\Delta z_j)^2/(2\Delta t)}
|
|
35
|
+
{\sum_j K_h(u_0 - u_j)}.
|
|
36
|
+
|
|
37
|
+
* **Parallel estimator.** Only the *starting position* is mapped to
|
|
38
|
+
u-space; the parallel displacement
|
|
39
|
+
:math:`(\Delta x_j, \Delta y_j)` is not rescaled (parallel motion is
|
|
40
|
+
unbounded):
|
|
41
|
+
|
|
42
|
+
.. math::
|
|
43
|
+
|
|
44
|
+
\hat{D}_\parallel(u_0) =
|
|
45
|
+
\frac{\sum_j K_h(u_0 - u_j)\,[(\Delta x_j)^2 + (\Delta y_j)^2]
|
|
46
|
+
/(4\Delta t)}
|
|
47
|
+
{\sum_j K_h(u_0 - u_j)},
|
|
48
|
+
|
|
49
|
+
where :math:`\langle \Delta x^2 + \Delta y^2\rangle = 4 D_\parallel
|
|
50
|
+
\Delta t` gives the :math:`4\Delta t` denominator.
|
|
51
|
+
|
|
52
|
+
* **Kernel.** Either the Gaussian kernel
|
|
53
|
+
:math:`K_h(x) = \exp(-x^2/(2h^2))/(h\sqrt{2\pi})` (infinite support,
|
|
54
|
+
smooth derivatives — the default) or the Epanechnikov kernel
|
|
55
|
+
:math:`K_h(x) = \frac{3}{4h}(1 - (x/h)^2)` for :math:`|x| < h` (compact
|
|
56
|
+
support, no leakage beyond the boundaries).
|
|
57
|
+
|
|
58
|
+
* **Bandwidth.** ``"auto"`` uses a Sheather-Jones plug-in (a
|
|
59
|
+
kernel-appropriate pilot density is built on a fine grid, its second
|
|
60
|
+
derivative estimated by central differences, and the oracle bandwidth
|
|
61
|
+
:math:`h^* = (\|K\|^2 / (N\,\mu_2(K)^2\,\widehat{\int[\hat f'']^2}))^{1/5}`
|
|
62
|
+
evaluated); the Silverman rule of thumb is the fallback.
|
|
63
|
+
``"silverman"`` uses the rule of thumb directly, and a float fixes the
|
|
64
|
+
bandwidth.
|
|
65
|
+
|
|
66
|
+
* **Boundary handling.** Mirror reflection at :math:`u = 0` and
|
|
67
|
+
:math:`u = 1`: for each data point at :math:`u_j`, mirror copies at
|
|
68
|
+
:math:`-u_j` and :math:`2 - u_j` are added (with the same
|
|
69
|
+
:math:`\hat d_j`) before kernel evaluation, so kernel mass leaking
|
|
70
|
+
outside :math:`[0, 1]` is reflected back.
|
|
71
|
+
|
|
72
|
+
* **Evaluation grid.** ``n_points`` cell-centred points uniform in
|
|
73
|
+
u-space, :math:`u_m = (m + 0.5)/M` for :math:`m = 0, \ldots, M-1`,
|
|
74
|
+
mapped back to z via :math:`z_m = F^{-1}(u_m)`.
|
|
75
|
+
|
|
76
|
+
* **Error bars.** The weighted variance of :math:`\hat d_j` within the
|
|
77
|
+
kernel window, divided by the Kish effective sample size
|
|
78
|
+
:math:`N_{\mathrm{eff}}(u_0) = (\sum_j K_h)^2 / \sum_j K_h^2`, gives the
|
|
79
|
+
standard error of the mean at each grid point.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
from __future__ import annotations
|
|
83
|
+
|
|
84
|
+
import numpy as np
|
|
85
|
+
from MDAnalysis.analysis.base import AnalysisBase
|
|
86
|
+
from MDAnalysis.transformations import NoJump
|
|
87
|
+
|
|
88
|
+
from .density import epanechnikov_kernel
|
|
89
|
+
|
|
90
|
+
# Gaussian-kernel constants for the Sheather-Jones plug-in.
|
|
91
|
+
# ||K||_2^2 = 1 / (2*sqrt(pi)), mu_2(K) = 1
|
|
92
|
+
_GAU_KERNEL_NORM_SQ = 1.0 / (2.0 * np.sqrt(np.pi))
|
|
93
|
+
_GAU_KERNEL_MU2_SQ = 1.0
|
|
94
|
+
|
|
95
|
+
# Epanechnikov-kernel constants (reused from density.py for the plug-in).
|
|
96
|
+
# ||K||_2^2 = 3/5, mu_2(K) = 1/5
|
|
97
|
+
_EPA_KERNEL_NORM_SQ = 3.0 / 5.0
|
|
98
|
+
_EPA_KERNEL_MU2_SQ = (1.0 / 5.0) ** 2
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def gaussian_kernel(x, h):
|
|
102
|
+
r"""Gaussian kernel :math:`K_h(x) = \exp(-x^2/(2h^2))/(h\sqrt{2\pi})`.
|
|
103
|
+
|
|
104
|
+
Parameters
|
|
105
|
+
----------
|
|
106
|
+
x : array_like
|
|
107
|
+
Offsets (any shape).
|
|
108
|
+
h : float
|
|
109
|
+
Bandwidth (must be positive).
|
|
110
|
+
|
|
111
|
+
Returns
|
|
112
|
+
-------
|
|
113
|
+
numpy.ndarray
|
|
114
|
+
Kernel values, same shape as ``x``.
|
|
115
|
+
"""
|
|
116
|
+
s = np.asarray(x, dtype=float) / h
|
|
117
|
+
return np.exp(-0.5 * s * s) / (h * np.sqrt(2.0 * np.pi))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _kernel(name):
|
|
121
|
+
"""Return ``(kernel_fn, norm_sq, mu2_sq)`` for ``name`` in
|
|
122
|
+
``{"gaussian", "epanechnikov"}``."""
|
|
123
|
+
if name == "gaussian":
|
|
124
|
+
return (
|
|
125
|
+
gaussian_kernel,
|
|
126
|
+
_GAU_KERNEL_NORM_SQ,
|
|
127
|
+
_GAU_KERNEL_MU2_SQ,
|
|
128
|
+
)
|
|
129
|
+
if name == "epanechnikov":
|
|
130
|
+
return (
|
|
131
|
+
epanechnikov_kernel,
|
|
132
|
+
_EPA_KERNEL_NORM_SQ,
|
|
133
|
+
_EPA_KERNEL_MU2_SQ,
|
|
134
|
+
)
|
|
135
|
+
raise ValueError(
|
|
136
|
+
f"kernel must be 'gaussian' or 'epanechnikov', "
|
|
137
|
+
f"got {name!r}"
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def silverman_bw(u_data):
|
|
142
|
+
r"""Silverman's rule of thumb (Gaussian reference).
|
|
143
|
+
|
|
144
|
+
Returns :math:`h = 1.06\,\hat\sigma\,N^{-1/5}` with the robust
|
|
145
|
+
scaled-IQR scale
|
|
146
|
+
:math:`\hat\sigma = \min(\mathrm{std}, \mathrm{IQR}/1.34)`.
|
|
147
|
+
|
|
148
|
+
Parameters
|
|
149
|
+
----------
|
|
150
|
+
u_data : array_like
|
|
151
|
+
1-D sample (u-space positions).
|
|
152
|
+
|
|
153
|
+
Returns
|
|
154
|
+
-------
|
|
155
|
+
float
|
|
156
|
+
Bandwidth ``h``.
|
|
157
|
+
"""
|
|
158
|
+
u = np.asarray(u_data, dtype=float).ravel()
|
|
159
|
+
n = u.size
|
|
160
|
+
if n < 2:
|
|
161
|
+
return 0.05
|
|
162
|
+
std = float(np.std(u, ddof=1))
|
|
163
|
+
q75, q25 = np.percentile(u, [75, 25])
|
|
164
|
+
iqr = q75 - q25
|
|
165
|
+
sigma_hat = min(std, iqr / 1.34) if iqr > 0 else std
|
|
166
|
+
if sigma_hat <= 0:
|
|
167
|
+
sigma_hat = std if std > 0 else 0.1
|
|
168
|
+
h = 1.06 * sigma_hat * n ** (-1.0 / 5.0)
|
|
169
|
+
return float(h)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def sheather_jones_bw(u_data, kernel="gaussian"):
|
|
173
|
+
r"""Simplified Sheather-Jones plug-in bandwidth.
|
|
174
|
+
|
|
175
|
+
Stage 1 — a pilot density is built by histogramming the u-data with
|
|
176
|
+
~50 bins over ``[0, 1]`` and smoothing with a kernel of Silverman
|
|
177
|
+
bandwidth on a fine grid.
|
|
178
|
+
|
|
179
|
+
Stage 2 — :math:`\hat f''` is estimated by central finite
|
|
180
|
+
differences and :math:`\widehat{\int[\hat f'']^2}` computed.
|
|
181
|
+
|
|
182
|
+
Stage 3 — the oracle bandwidth
|
|
183
|
+
:math:`h^* = (\|K\|^2 / (N\,\mu_2(K)^2\,\widehat{\int[\hat f'']^2}))^{1/5}`
|
|
184
|
+
is evaluated with the kernel-appropriate constants. If the result is
|
|
185
|
+
NaN, non-positive, or larger than the Silverman fallback, the latter
|
|
186
|
+
is returned.
|
|
187
|
+
|
|
188
|
+
Parameters
|
|
189
|
+
----------
|
|
190
|
+
u_data : array_like
|
|
191
|
+
1-D sample (u-space positions).
|
|
192
|
+
kernel : {"gaussian", "epanechnikov"}, optional
|
|
193
|
+
Kernel whose constants are used in the oracle formula.
|
|
194
|
+
|
|
195
|
+
Returns
|
|
196
|
+
-------
|
|
197
|
+
float
|
|
198
|
+
Bandwidth ``h``.
|
|
199
|
+
"""
|
|
200
|
+
u = np.asarray(u_data, dtype=float).ravel()
|
|
201
|
+
n = u.size
|
|
202
|
+
if n < 2:
|
|
203
|
+
return 0.05
|
|
204
|
+
h_silver = silverman_bw(u)
|
|
205
|
+
kernel_fn, norm_sq, mu2_sq = _kernel(kernel)
|
|
206
|
+
|
|
207
|
+
n_hist = 50
|
|
208
|
+
u_min, u_max = 0.0, 1.0
|
|
209
|
+
counts, edges = np.histogram(u, bins=n_hist, range=(u_min, u_max))
|
|
210
|
+
centers = 0.5 * (edges[:-1] + edges[1:])
|
|
211
|
+
bin_width = edges[1] - edges[0]
|
|
212
|
+
f_pilot_raw = counts.astype(float) / (n * bin_width)
|
|
213
|
+
|
|
214
|
+
n_fine = 400
|
|
215
|
+
u_fine = np.linspace(u_min, u_max, n_fine)
|
|
216
|
+
du = u_fine[1] - u_fine[0]
|
|
217
|
+
h0 = max(h_silver, du)
|
|
218
|
+
diff = u_fine[:, None] - centers[None, :]
|
|
219
|
+
kernel_vals = kernel_fn(diff, h0)
|
|
220
|
+
f_pilot = kernel_vals @ f_pilot_raw
|
|
221
|
+
|
|
222
|
+
integral = np.trapezoid(f_pilot, u_fine)
|
|
223
|
+
if integral > 0:
|
|
224
|
+
f_pilot = f_pilot / integral
|
|
225
|
+
|
|
226
|
+
f_pp = np.empty_like(f_pilot)
|
|
227
|
+
f_pp[1:-1] = (f_pilot[2:] - 2.0 * f_pilot[1:-1] + f_pilot[:-2]) / (du**2)
|
|
228
|
+
f_pp[0] = f_pp[1]
|
|
229
|
+
f_pp[-1] = f_pp[-2]
|
|
230
|
+
|
|
231
|
+
int_fpp_sq = np.trapezoid(f_pp**2, u_fine)
|
|
232
|
+
|
|
233
|
+
if int_fpp_sq <= 0 or not np.isfinite(int_fpp_sq):
|
|
234
|
+
return h_silver
|
|
235
|
+
h_sj = (norm_sq / (n * mu2_sq * int_fpp_sq)) ** 0.2
|
|
236
|
+
h_sj *= 1.05
|
|
237
|
+
if not np.isfinite(h_sj) or h_sj <= 0 or h_sj > h_silver:
|
|
238
|
+
return h_silver
|
|
239
|
+
return float(h_sj)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def select_diff_bandwidth(u_data, method="auto", kernel="gaussian"):
|
|
243
|
+
"""Select the diffusivity KDE bandwidth.
|
|
244
|
+
|
|
245
|
+
Parameters
|
|
246
|
+
----------
|
|
247
|
+
u_data : array_like
|
|
248
|
+
1-D sample (u-space positions).
|
|
249
|
+
method : {"auto", "silverman"} or float
|
|
250
|
+
``"auto"`` uses :func:`sheather_jones_bw` with a
|
|
251
|
+
:func:`silverman_bw` fallback; ``"silverman"`` uses the rule of
|
|
252
|
+
thumb directly; a float fixes the bandwidth.
|
|
253
|
+
kernel : {"gaussian", "epanechnikov"}, optional
|
|
254
|
+
Kernel whose constants are used by the plug-in.
|
|
255
|
+
|
|
256
|
+
Returns
|
|
257
|
+
-------
|
|
258
|
+
float
|
|
259
|
+
Bandwidth ``h``.
|
|
260
|
+
"""
|
|
261
|
+
if method is None or method == "auto":
|
|
262
|
+
h = sheather_jones_bw(u_data, kernel=kernel)
|
|
263
|
+
if not np.isfinite(h) or h <= 0:
|
|
264
|
+
h = silverman_bw(u_data)
|
|
265
|
+
return float(h)
|
|
266
|
+
if method == "silverman":
|
|
267
|
+
return silverman_bw(u_data)
|
|
268
|
+
return float(method)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def build_cdf(z_pooled):
|
|
272
|
+
r"""Build a smooth CDF from pooled positions.
|
|
273
|
+
|
|
274
|
+
Returns the closures ``P(z)`` (maps physical z to ``[0, 1]``),
|
|
275
|
+
``P_inv(p)`` (maps ``[0, 1]`` back to z), ``rho(z)`` (the equilibrium
|
|
276
|
+
density, estimated as a Gaussian-smoothed histogram derivative of the
|
|
277
|
+
CDF and interpolated back to arbitrary z), and ``rho_prime(z)`` (the
|
|
278
|
+
spatial derivative of ``rho``, used by the Itô correction).
|
|
279
|
+
|
|
280
|
+
Uses the empirical sorted positions with midrank
|
|
281
|
+
``p = (i + 0.5)/n`` so the CDF never returns exactly 0 or 1 at the
|
|
282
|
+
extremes. The density ``rho`` is computed from a coarse histogram
|
|
283
|
+
(200 bins) Gaussian-smoothed to suppress Poisson noise before
|
|
284
|
+
interpolation — the perpendicular KDE would otherwise double any
|
|
285
|
+
noise in ``rho`` via the :math:`\rho^{-2}` conversion. The
|
|
286
|
+
derivative ``rho_prime`` is obtained from the same smoothed histogram
|
|
287
|
+
by central finite differences and interpolation, so it inherits the
|
|
288
|
+
noise suppression.
|
|
289
|
+
|
|
290
|
+
Parameters
|
|
291
|
+
----------
|
|
292
|
+
z_pooled : array_like
|
|
293
|
+
``(N,)`` pooled positions (e.g. all wrapped-z across all frames).
|
|
294
|
+
|
|
295
|
+
Returns
|
|
296
|
+
-------
|
|
297
|
+
P : callable
|
|
298
|
+
CDF closure ``P(z) -> [0, 1]``.
|
|
299
|
+
P_inv : callable
|
|
300
|
+
Inverse CDF closure ``P_inv(p) -> z``.
|
|
301
|
+
rho : callable
|
|
302
|
+
Equilibrium density closure ``rho(z) -> float`` (integrates to 1).
|
|
303
|
+
rho_prime : callable
|
|
304
|
+
Spatial derivative closure ``rho_prime(z) -> float`` (same units
|
|
305
|
+
as ``rho`` per length).
|
|
306
|
+
z_sorted : numpy.ndarray
|
|
307
|
+
Sorted unique positions used to build the CDF.
|
|
308
|
+
p_vals : numpy.ndarray
|
|
309
|
+
Midrank probabilities aligned with ``z_sorted``.
|
|
310
|
+
"""
|
|
311
|
+
z_sorted = np.sort(np.asarray(z_pooled, dtype=float).ravel())
|
|
312
|
+
n = z_sorted.size
|
|
313
|
+
if n == 0:
|
|
314
|
+
raise ValueError("z_pooled is empty; cannot build a CDF")
|
|
315
|
+
z_min = z_sorted[0]
|
|
316
|
+
z_max = z_sorted[-1]
|
|
317
|
+
p_vals = (np.arange(1, n + 1) - 0.5) / n
|
|
318
|
+
|
|
319
|
+
def P(z):
|
|
320
|
+
z = np.asarray(z, dtype=float)
|
|
321
|
+
return np.interp(z, z_sorted, p_vals, left=0.0, right=1.0)
|
|
322
|
+
|
|
323
|
+
def P_inv(p):
|
|
324
|
+
p = np.asarray(p, dtype=float)
|
|
325
|
+
return np.interp(p, p_vals, z_sorted, left=z_min, right=z_max)
|
|
326
|
+
|
|
327
|
+
# Density estimate: a coarse histogram Gaussian-smoothed to suppress
|
|
328
|
+
# Poisson noise before interpolation.
|
|
329
|
+
n_bins = 200
|
|
330
|
+
counts, edges = np.histogram(z_pooled, bins=n_bins, range=(z_min, z_max))
|
|
331
|
+
centers = 0.5 * (edges[:-1] + edges[1:])
|
|
332
|
+
bin_width = edges[1] - edges[0]
|
|
333
|
+
rho_raw = counts.astype(float) / (n * bin_width)
|
|
334
|
+
|
|
335
|
+
sigma_smooth = 2.0 * bin_width
|
|
336
|
+
n_pad = int(3 * sigma_smooth / bin_width) + 1
|
|
337
|
+
rho_padded = np.pad(rho_raw, n_pad, mode="edge")
|
|
338
|
+
kernel_sigma = sigma_smooth / bin_width
|
|
339
|
+
kernel_x = np.arange(-n_pad, n_pad + 1)
|
|
340
|
+
kernel = np.exp(-0.5 * (kernel_x / kernel_sigma) ** 2)
|
|
341
|
+
kernel /= kernel.sum()
|
|
342
|
+
rho_smooth = np.convolve(rho_padded, kernel, mode="same")[n_pad:-n_pad]
|
|
343
|
+
|
|
344
|
+
integral = np.trapezoid(rho_smooth, centers)
|
|
345
|
+
if integral > 0:
|
|
346
|
+
rho_smooth = rho_smooth / integral
|
|
347
|
+
|
|
348
|
+
# Spatial derivative of rho via central finite differences on the
|
|
349
|
+
# smoothed histogram grid, then interpolated back to arbitrary z.
|
|
350
|
+
# Same noise-suppression as rho itself (the Gaussian smoothing
|
|
351
|
+
# precedes the differencing, so the derivative is not dominated by
|
|
352
|
+
# Poisson noise from the histogram bins).
|
|
353
|
+
rho_prime_smooth = np.empty_like(rho_smooth)
|
|
354
|
+
rho_prime_smooth[1:-1] = (
|
|
355
|
+
rho_smooth[2:] - rho_smooth[:-2]
|
|
356
|
+
) / (2.0 * bin_width)
|
|
357
|
+
rho_prime_smooth[0] = rho_prime_smooth[1]
|
|
358
|
+
rho_prime_smooth[-1] = rho_prime_smooth[-2]
|
|
359
|
+
|
|
360
|
+
def rho(z):
|
|
361
|
+
z = np.asarray(z, dtype=float)
|
|
362
|
+
return np.interp(
|
|
363
|
+
z, centers, rho_smooth, left=rho_smooth[0], right=rho_smooth[-1]
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
def rho_prime(z):
|
|
367
|
+
z = np.asarray(z, dtype=float)
|
|
368
|
+
return np.interp(
|
|
369
|
+
z,
|
|
370
|
+
centers,
|
|
371
|
+
rho_prime_smooth,
|
|
372
|
+
left=rho_prime_smooth[0],
|
|
373
|
+
right=rho_prime_smooth[-1],
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
return P, P_inv, rho, rho_prime, z_sorted, p_vals
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def kde_estimate(
|
|
380
|
+
u_data, d_data, u_eval, h, kernel="gaussian",
|
|
381
|
+
chunk_size=50_000,
|
|
382
|
+
):
|
|
383
|
+
r"""Kernel-weighted local estimator in u-space with mirror reflection.
|
|
384
|
+
|
|
385
|
+
For each evaluation point :math:`u_0` in ``u_eval``,
|
|
386
|
+
|
|
387
|
+
.. math::
|
|
388
|
+
|
|
389
|
+
\hat{D}(u_0) = \frac{\sum_j K_h(u_0 - u_j)\,d_j}
|
|
390
|
+
{\sum_j K_h(u_0 - u_j)},
|
|
391
|
+
|
|
392
|
+
where ``d_data`` already carries the :math:`1/(2\Delta t)` (or
|
|
393
|
+
:math:`1/(4\Delta t)`) prefactor — i.e. the caller passes the local
|
|
394
|
+
estimator values :math:`(\Delta z_j)^2/(2\Delta t)` or
|
|
395
|
+
:math:`[(\Delta x_j)^2 + (\Delta y_j)^2]/(4\Delta t)`.
|
|
396
|
+
|
|
397
|
+
Boundary handling: mirror reflection at :math:`u = 0` and :math:`u =
|
|
398
|
+
1`. For each data point at :math:`u_j`, mirror copies at
|
|
399
|
+
:math:`-u_j` and :math:`2 - u_j` are added (with the same
|
|
400
|
+
:math:`d_j`) before kernel evaluation.
|
|
401
|
+
|
|
402
|
+
The :math:`O(N \cdot M)` kernel evaluation is performed in chunks of
|
|
403
|
+
``chunk_size`` increments to keep the broadcasting array
|
|
404
|
+
memory-bounded.
|
|
405
|
+
|
|
406
|
+
Parameters
|
|
407
|
+
----------
|
|
408
|
+
u_data : array_like
|
|
409
|
+
``(N,)`` u-space positions of the increments.
|
|
410
|
+
d_data : array_like
|
|
411
|
+
``(N,)`` local estimator values (already carrying the
|
|
412
|
+
:math:`1/(2\Delta t)` or :math:`1/(4\Delta t)` prefactor).
|
|
413
|
+
u_eval : array_like
|
|
414
|
+
``(M,)`` evaluation grid in u-space.
|
|
415
|
+
h : float
|
|
416
|
+
Bandwidth (must be positive).
|
|
417
|
+
kernel : {"gaussian", "epanechnikov"}, optional
|
|
418
|
+
Kernel function. Default ``"gaussian"``.
|
|
419
|
+
chunk_size : int, optional
|
|
420
|
+
Row-chunk size for the kernel matrix to bound memory. Default
|
|
421
|
+
50_000.
|
|
422
|
+
|
|
423
|
+
Returns
|
|
424
|
+
-------
|
|
425
|
+
D : numpy.ndarray
|
|
426
|
+
``(M,)`` kernel-weighted mean of ``d_data`` at each evaluation
|
|
427
|
+
point.
|
|
428
|
+
D_std : numpy.ndarray
|
|
429
|
+
``(M,)`` standard error of the mean (weighted standard deviation
|
|
430
|
+
divided by :math:`\sqrt{N_{\mathrm{eff}}}`).
|
|
431
|
+
n_eff : numpy.ndarray
|
|
432
|
+
``(M,)`` Kish effective sample size
|
|
433
|
+
:math:`(\sum_j K_h)^2 / \sum_j K_h^2`.
|
|
434
|
+
"""
|
|
435
|
+
u = np.asarray(u_data, dtype=float).ravel()
|
|
436
|
+
d = np.asarray(d_data, dtype=float).ravel()
|
|
437
|
+
u_eval = np.asarray(u_eval, dtype=float).ravel()
|
|
438
|
+
n = u.size
|
|
439
|
+
M = u_eval.size
|
|
440
|
+
|
|
441
|
+
if h <= 0:
|
|
442
|
+
raise ValueError(f"bandwidth must be positive, got {h}")
|
|
443
|
+
kernel_fn, _, _ = _kernel(kernel)
|
|
444
|
+
|
|
445
|
+
if n == 0 or M == 0:
|
|
446
|
+
zeros = np.zeros(M, dtype=np.float64)
|
|
447
|
+
return zeros, zeros, zeros
|
|
448
|
+
|
|
449
|
+
u_aug = np.concatenate([u, -u, 2.0 - u])
|
|
450
|
+
d_aug = np.concatenate([d, d, d])
|
|
451
|
+
|
|
452
|
+
w_sum = np.zeros(M, dtype=np.float64)
|
|
453
|
+
wd_sum = np.zeros(M, dtype=np.float64)
|
|
454
|
+
wd2_sum = np.zeros(M, dtype=np.float64)
|
|
455
|
+
w2_sum = np.zeros(M, dtype=np.float64)
|
|
456
|
+
|
|
457
|
+
n_aug = u_aug.size
|
|
458
|
+
for i0 in range(0, n_aug, chunk_size):
|
|
459
|
+
i1 = min(i0 + chunk_size, n_aug)
|
|
460
|
+
u_chunk = u_aug[i0:i1][:, None]
|
|
461
|
+
d_chunk = d_aug[i0:i1][:, None]
|
|
462
|
+
diff = u_eval[None, :] - u_chunk
|
|
463
|
+
w = kernel_fn(diff, h)
|
|
464
|
+
w_sum += w.sum(axis=0)
|
|
465
|
+
wd = w * d_chunk
|
|
466
|
+
wd_sum += wd.sum(axis=0)
|
|
467
|
+
wd2_sum += (wd * d_chunk).sum(axis=0)
|
|
468
|
+
w2_sum += (w * w).sum(axis=0)
|
|
469
|
+
|
|
470
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
471
|
+
D = np.where(w_sum > 0, wd_sum / w_sum, np.nan)
|
|
472
|
+
mean_d2 = np.where(w_sum > 0, wd2_sum / w_sum, np.nan)
|
|
473
|
+
var_d = np.where(w_sum > 0, mean_d2 - D**2, 0.0)
|
|
474
|
+
var_d = np.maximum(var_d, 0.0)
|
|
475
|
+
n_eff = np.where(w2_sum > 0, w_sum**2 / w2_sum, 0.0)
|
|
476
|
+
D_std = np.where(
|
|
477
|
+
n_eff > 1, np.sqrt(var_d / np.maximum(n_eff, 1.0)), 0.0
|
|
478
|
+
)
|
|
479
|
+
return D, D_std, n_eff
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
class LocalDiffusivityQKDE(AnalysisBase):
|
|
483
|
+
r"""Kernel-weighted local estimator for transverse diffusivity.
|
|
484
|
+
|
|
485
|
+
Pools per-frame positions of ``atomgroup``, builds a CDF-uniformised
|
|
486
|
+
u-space from the equilibrium positions, and evaluates a
|
|
487
|
+
kernel-weighted local estimator for the perpendicular
|
|
488
|
+
(:math:`D_\perp`) and parallel (:math:`D_\parallel`) diffusivities on
|
|
489
|
+
a uniform u-space grid mapped back to z. Kernel mass leaking beyond
|
|
490
|
+
:math:`u \in [0, 1]` is folded back by mirror reflection.
|
|
491
|
+
|
|
492
|
+
Parameters
|
|
493
|
+
----------
|
|
494
|
+
atomgroup : MDAnalysis.core.groups.AtomGroup
|
|
495
|
+
Atoms whose positions are sampled.
|
|
496
|
+
dim : int, optional
|
|
497
|
+
Confined-axis index (0=x, 1=y, 2=z). Default 2 (z). The
|
|
498
|
+
perpendicular displacement is taken along ``dim``; the parallel
|
|
499
|
+
displacement is taken along the other two axes.
|
|
500
|
+
n_points : int, optional
|
|
501
|
+
Number of evaluation grid points ``M`` (uniform in u-space).
|
|
502
|
+
Default 200.
|
|
503
|
+
bandwidth : {"auto", "silverman"} or float, optional
|
|
504
|
+
Bandwidth selection method (see
|
|
505
|
+
:func:`select_diff_bandwidth`). Default ``"auto"``.
|
|
506
|
+
kernel : {"gaussian", "epanechnikov"}, optional
|
|
507
|
+
Kernel function. Default ``"gaussian"``.
|
|
508
|
+
dt : float or None, optional
|
|
509
|
+
Time step between consecutive frames, in the units the caller
|
|
510
|
+
wishes the diffusivity to be expressed in (e.g. ps). If ``None``
|
|
511
|
+
(default) the trajectory's ``dt`` (:attr:`ts.dt`) is used. The
|
|
512
|
+
diffusivity is returned in (length² / time) with length in Å and
|
|
513
|
+
time in whatever unit ``dt`` carries.
|
|
514
|
+
ito_correction : bool, optional
|
|
515
|
+
If ``True``, subtract the :math:`O(\Delta t)` Itô bias
|
|
516
|
+
|
|
517
|
+
.. math::
|
|
518
|
+
|
|
519
|
+
\text{bias}_\perp(z) = \frac{\Delta t}{2}\,\Phi(z)^2
|
|
520
|
+
= \frac{\Delta t}{2}\left[D(z)\,\frac{\rho'(z)}{\rho(z)}\right]^2
|
|
521
|
+
|
|
522
|
+
from the perpendicular estimator, where :math:`\Phi = -\beta D V'
|
|
523
|
+
= D\,\rho'/\rho` in the isothermal (Hänggi–Klimontovich)
|
|
524
|
+
convention. The bias is computed from the uncorrected
|
|
525
|
+
:math:`D_\perp` and the equilibrium density :math:`\rho` (and its
|
|
526
|
+
derivative) produced by :func:`build_cdf`. The parallel
|
|
527
|
+
estimator has **zero** Itô bias (no parallel drift) and is left
|
|
528
|
+
unchanged. Default ``False`` (the bias is self-suppressing in
|
|
529
|
+
the electrode geometry — see the project notes — and within
|
|
530
|
+
the statistical error bars at typical frame spacings).
|
|
531
|
+
|
|
532
|
+
Attributes
|
|
533
|
+
----------
|
|
534
|
+
z_eval : numpy.ndarray
|
|
535
|
+
``(M,)`` evaluation grid in z-space.
|
|
536
|
+
u_eval : numpy.ndarray
|
|
537
|
+
``(M,)`` evaluation grid in u-space (uniform, cell-centred).
|
|
538
|
+
D_perp : numpy.ndarray
|
|
539
|
+
``(M,)`` perpendicular diffusivity (length²/time), clipped to
|
|
540
|
+
be non-negative. When ``ito_correction=True`` the Itô bias
|
|
541
|
+
(see :attr:`ito_bias`) is subtracted *before* clipping.
|
|
542
|
+
D_perp_std : numpy.ndarray
|
|
543
|
+
``(M,)`` standard error of :math:`D_\perp`.
|
|
544
|
+
n_eff_perp : numpy.ndarray
|
|
545
|
+
``(M,)`` Kish effective sample size for :math:`D_\perp`.
|
|
546
|
+
ito_bias : numpy.ndarray or None
|
|
547
|
+
``(M,)`` Itô bias :math:`\frac{\Delta t}{2}\Phi^2` subtracted
|
|
548
|
+
from :math:`D_\perp` when ``ito_correction=True``; ``None``
|
|
549
|
+
otherwise.
|
|
550
|
+
D_para : numpy.ndarray
|
|
551
|
+
``(M,)`` parallel diffusivity (length²/time).
|
|
552
|
+
D_para_std : numpy.ndarray
|
|
553
|
+
``(M,)`` standard error of :math:`D_\parallel`.
|
|
554
|
+
n_eff_para : numpy.ndarray
|
|
555
|
+
``(M,)`` Kish effective sample size for :math:`D_\parallel`.
|
|
556
|
+
bandwidth : float
|
|
557
|
+
Bandwidth used (in u-space units).
|
|
558
|
+
n_increments : int
|
|
559
|
+
Total number of trajectory increments pooled
|
|
560
|
+
(``n_frames_used - 1`` per atom, summed over atoms).
|
|
561
|
+
n_frames_used : int
|
|
562
|
+
Number of frames pooled.
|
|
563
|
+
dt : float
|
|
564
|
+
Time step used in the estimator (length²/time units).
|
|
565
|
+
P, P_inv, rho : callables
|
|
566
|
+
CDF, inverse-CDF and equilibrium density closures (see
|
|
567
|
+
:func:`build_cdf`).
|
|
568
|
+
rho_prime : callable
|
|
569
|
+
Spatial derivative of the equilibrium density (see
|
|
570
|
+
:func:`build_cdf`).
|
|
571
|
+
|
|
572
|
+
Notes
|
|
573
|
+
-----
|
|
574
|
+
Parallel (x/y) positions are unwrapped with MDAnalysis's ``NoJump``
|
|
575
|
+
transformation so that multi-frame displacements across periodic
|
|
576
|
+
boundaries are captured. The transformation is attached in place
|
|
577
|
+
(guarded so it is only attached once per Universe) and the full
|
|
578
|
+
trajectory is iterated from frame 0 (``NoJump`` is stateful).
|
|
579
|
+
|
|
580
|
+
Examples
|
|
581
|
+
--------
|
|
582
|
+
::
|
|
583
|
+
|
|
584
|
+
import MDAnalysis as mda
|
|
585
|
+
from qdiffusivity import LocalDiffusivityQKDE
|
|
586
|
+
|
|
587
|
+
u = mda.Universe("topology.data", "trajectory.xtc")
|
|
588
|
+
ag = u.select_atoms("type 1 2")
|
|
589
|
+
kde = LocalDiffusivityQKDE(
|
|
590
|
+
ag, dim=2, n_points=200, bandwidth="auto", kernel="gaussian",
|
|
591
|
+
)
|
|
592
|
+
kde.run()
|
|
593
|
+
# D_perp, D_para are in Ų/ps if the trajectory dt is in ps.
|
|
594
|
+
"""
|
|
595
|
+
|
|
596
|
+
def __init__(
|
|
597
|
+
self,
|
|
598
|
+
atomgroup,
|
|
599
|
+
*,
|
|
600
|
+
dim: int = 2,
|
|
601
|
+
n_points: int = 200,
|
|
602
|
+
bandwidth="auto",
|
|
603
|
+
kernel: str = "gaussian",
|
|
604
|
+
dt=None,
|
|
605
|
+
ito_correction: bool = False,
|
|
606
|
+
chunk_size: int = 50_000,
|
|
607
|
+
):
|
|
608
|
+
super().__init__(atomgroup.universe.trajectory)
|
|
609
|
+
self._ag = atomgroup
|
|
610
|
+
if dim not in (0, 1, 2):
|
|
611
|
+
raise ValueError(f"dim must be 0, 1 or 2, got {dim}")
|
|
612
|
+
self._dim = dim
|
|
613
|
+
self._para_dims = tuple(i for i in (0, 1, 2) if i != dim)
|
|
614
|
+
self._n_points = int(n_points)
|
|
615
|
+
if isinstance(bandwidth, str) and bandwidth not in (
|
|
616
|
+
"auto", "silverman"
|
|
617
|
+
):
|
|
618
|
+
raise ValueError(
|
|
619
|
+
f"bandwidth must be 'auto', 'silverman' or a float, "
|
|
620
|
+
f"got {bandwidth!r}"
|
|
621
|
+
)
|
|
622
|
+
self._bandwidth = bandwidth
|
|
623
|
+
if kernel not in ("gaussian", "epanechnikov"):
|
|
624
|
+
raise ValueError(
|
|
625
|
+
f"kernel must be 'gaussian' or 'epanechnikov', got {kernel!r}"
|
|
626
|
+
)
|
|
627
|
+
self._kernel = kernel
|
|
628
|
+
self._dt = dt
|
|
629
|
+
self._ito_correction = bool(ito_correction)
|
|
630
|
+
self._chunk_size = int(chunk_size)
|
|
631
|
+
|
|
632
|
+
def _prepare(self):
|
|
633
|
+
self._pos_frames = []
|
|
634
|
+
|
|
635
|
+
def _single_frame(self):
|
|
636
|
+
# Snapshot the positions so later NoJump unwrapping does not
|
|
637
|
+
# mutate the stored array. We store the wrapped positions here
|
|
638
|
+
# and unwrap lazily in _conclude via NoJump.
|
|
639
|
+
self._pos_frames.append(self._ag.positions.copy())
|
|
640
|
+
|
|
641
|
+
def _conclude(self):
|
|
642
|
+
pos = np.asarray(self._pos_frames, dtype=np.float64)
|
|
643
|
+
# (n_frames, n_atoms, 3) — these are wrapped; we need unwrapped
|
|
644
|
+
# parallel coords and wrapped confined coord. Re-iterate with
|
|
645
|
+
# NoJump to unwrap x/y, keeping z wrapped.
|
|
646
|
+
n_frames = pos.shape[0]
|
|
647
|
+
self.n_frames_used = n_frames
|
|
648
|
+
if n_frames < 2:
|
|
649
|
+
raise ValueError(
|
|
650
|
+
f"need at least 2 frames to form a displacement; got {n_frames}"
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
# Determine dt.
|
|
654
|
+
dt = self._dt if self._dt is not None else float(self._ts.dt)
|
|
655
|
+
self.dt = dt
|
|
656
|
+
if dt <= 0:
|
|
657
|
+
raise ValueError(f"dt must be positive, got {dt}")
|
|
658
|
+
|
|
659
|
+
# Unwrap the parallel coordinates via NoJump. We attach the
|
|
660
|
+
# transformation to the universe's trajectory (guarded so it is
|
|
661
|
+
# only attached once) and re-iterate from frame 0.
|
|
662
|
+
u = self._ag.universe
|
|
663
|
+
if not getattr(u, "_qdiff_nojump_applied", False):
|
|
664
|
+
u.trajectory[0]
|
|
665
|
+
u.trajectory.add_transformations(NoJump(self._ag))
|
|
666
|
+
u._qdiff_nojump_applied = True
|
|
667
|
+
|
|
668
|
+
pos_unwrapped = np.empty_like(pos)
|
|
669
|
+
Lz = None
|
|
670
|
+
k = 0
|
|
671
|
+
for idx in range(0, u.trajectory.n_frames):
|
|
672
|
+
u.trajectory[idx]
|
|
673
|
+
if k >= n_frames:
|
|
674
|
+
break
|
|
675
|
+
pos_unwrapped[k] = self._ag.positions
|
|
676
|
+
Lz = u.dimensions[self._dim]
|
|
677
|
+
k += 1
|
|
678
|
+
if Lz is not None and Lz > 0:
|
|
679
|
+
pos_unwrapped[:, :, self._dim] %= Lz
|
|
680
|
+
|
|
681
|
+
# Pass 1: build CDF from pooled wrapped-z.
|
|
682
|
+
z_pooled = pos_unwrapped[:, :, self._dim].ravel()
|
|
683
|
+
self.P, self.P_inv, self.rho, self.rho_prime, _, _ = build_cdf(z_pooled)
|
|
684
|
+
|
|
685
|
+
# Evaluation grid: uniform in u-space, mapped back to z.
|
|
686
|
+
M = self._n_points
|
|
687
|
+
self.u_eval = (np.arange(M) + 0.5) / M
|
|
688
|
+
self.z_eval = self.P_inv(self.u_eval)
|
|
689
|
+
|
|
690
|
+
# u-positions of all increments (starting frame of each pair).
|
|
691
|
+
z_start = pos_unwrapped[:-1, :, self._dim] # (n_frames-1, n_atoms)
|
|
692
|
+
u_start = self.P(z_start.ravel())
|
|
693
|
+
|
|
694
|
+
# Bandwidth from the u-positions of all increments.
|
|
695
|
+
self.bandwidth = select_diff_bandwidth(
|
|
696
|
+
u_start, method=self._bandwidth, kernel=self._kernel
|
|
697
|
+
)
|
|
698
|
+
|
|
699
|
+
# Perpendicular: z-space local estimator (Δz)²/(2Δt), kernel
|
|
700
|
+
# weighting in u-space. No ρ⁻² conversion.
|
|
701
|
+
dz = np.diff(pos_unwrapped[:, :, self._dim], axis=0)
|
|
702
|
+
d_perp_local = (dz**2) / (2.0 * dt)
|
|
703
|
+
self.n_increments = int(d_perp_local.size)
|
|
704
|
+
|
|
705
|
+
self.D_perp, self.D_perp_std, self.n_eff_perp = kde_estimate(
|
|
706
|
+
u_start.ravel(),
|
|
707
|
+
d_perp_local.ravel(),
|
|
708
|
+
self.u_eval,
|
|
709
|
+
self.bandwidth,
|
|
710
|
+
kernel=self._kernel,
|
|
711
|
+
chunk_size=self._chunk_size,
|
|
712
|
+
)
|
|
713
|
+
|
|
714
|
+
# Itô bias of the perpendicular estimator:
|
|
715
|
+
# bias(z) = (Δt/2) Φ(z)^2, Φ = -β D V' = D ρ'/ρ
|
|
716
|
+
# in the isothermal convention (β·k_BT = 1). Computed from the
|
|
717
|
+
# uncorrected D_perp and the equilibrium density. The parallel
|
|
718
|
+
# estimator has zero Itô bias (no parallel drift).
|
|
719
|
+
if self._ito_correction:
|
|
720
|
+
rho_eval = self.rho(self.z_eval)
|
|
721
|
+
rho_prime_eval = self.rho_prime(self.z_eval)
|
|
722
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
723
|
+
phi = np.where(
|
|
724
|
+
rho_eval > 0,
|
|
725
|
+
self.D_perp * rho_prime_eval / rho_eval,
|
|
726
|
+
0.0,
|
|
727
|
+
)
|
|
728
|
+
self.ito_bias = 0.5 * dt * phi**2
|
|
729
|
+
self.D_perp = self.D_perp - self.ito_bias
|
|
730
|
+
else:
|
|
731
|
+
self.ito_bias = None
|
|
732
|
+
|
|
733
|
+
self.D_perp = np.clip(self.D_perp, 0, None)
|
|
734
|
+
|
|
735
|
+
# Parallel: Δx² + Δy², only z_start mapped to u. Parallel
|
|
736
|
+
# displacement is not rescaled (parallel motion unbounded).
|
|
737
|
+
d_para_acc = np.zeros_like(dz)
|
|
738
|
+
for d in self._para_dims:
|
|
739
|
+
diff_d = np.diff(pos_unwrapped[:, :, d], axis=0)
|
|
740
|
+
d_para_acc += diff_d**2
|
|
741
|
+
d_para_local = d_para_acc / (4.0 * dt)
|
|
742
|
+
|
|
743
|
+
self.D_para, self.D_para_std, self.n_eff_para = kde_estimate(
|
|
744
|
+
u_start.ravel(),
|
|
745
|
+
d_para_local.ravel(),
|
|
746
|
+
self.u_eval,
|
|
747
|
+
self.bandwidth,
|
|
748
|
+
kernel=self._kernel,
|
|
749
|
+
chunk_size=self._chunk_size,
|
|
750
|
+
)
|