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,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"