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.
@@ -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
+ )