qdiffusivity 0.1.0__tar.gz → 0.2.0__tar.gz

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.
Files changed (30) hide show
  1. {qdiffusivity-0.1.0 → qdiffusivity-0.2.0}/PKG-INFO +1 -1
  2. {qdiffusivity-0.1.0 → qdiffusivity-0.2.0}/qdiffusivity/__init__.py +8 -4
  3. {qdiffusivity-0.1.0 → qdiffusivity-0.2.0}/qdiffusivity/binned.py +190 -86
  4. {qdiffusivity-0.1.0 → qdiffusivity-0.2.0}/qdiffusivity/density.py +136 -50
  5. {qdiffusivity-0.1.0 → qdiffusivity-0.2.0}/qdiffusivity/diffusivity.py +103 -50
  6. {qdiffusivity-0.1.0 → qdiffusivity-0.2.0}/qdiffusivity/tests/conftest.py +9 -4
  7. {qdiffusivity-0.1.0 → qdiffusivity-0.2.0}/qdiffusivity/tests/test_binned.py +99 -25
  8. {qdiffusivity-0.1.0 → qdiffusivity-0.2.0}/qdiffusivity/tests/test_density.py +77 -29
  9. {qdiffusivity-0.1.0 → qdiffusivity-0.2.0}/qdiffusivity/tests/test_diffusivity.py +83 -13
  10. {qdiffusivity-0.1.0 → qdiffusivity-0.2.0}/qdiffusivity.egg-info/PKG-INFO +1 -1
  11. {qdiffusivity-0.1.0 → qdiffusivity-0.2.0}/AUTHORS.md +0 -0
  12. {qdiffusivity-0.1.0 → qdiffusivity-0.2.0}/CHANGELOG.md +0 -0
  13. {qdiffusivity-0.1.0 → qdiffusivity-0.2.0}/CODE_OF_CONDUCT.md +0 -0
  14. {qdiffusivity-0.1.0 → qdiffusivity-0.2.0}/LICENSE +0 -0
  15. {qdiffusivity-0.1.0 → qdiffusivity-0.2.0}/MANIFEST.in +0 -0
  16. {qdiffusivity-0.1.0 → qdiffusivity-0.2.0}/README.md +0 -0
  17. {qdiffusivity-0.1.0 → qdiffusivity-0.2.0}/pyproject.toml +0 -0
  18. {qdiffusivity-0.1.0 → qdiffusivity-0.2.0}/qdiffusivity/data/README.md +0 -0
  19. {qdiffusivity-0.1.0 → qdiffusivity-0.2.0}/qdiffusivity/data/__init__.py +0 -0
  20. {qdiffusivity-0.1.0 → qdiffusivity-0.2.0}/qdiffusivity/data/files.py +0 -0
  21. {qdiffusivity-0.1.0 → qdiffusivity-0.2.0}/qdiffusivity/data/mda.txt +0 -0
  22. {qdiffusivity-0.1.0 → qdiffusivity-0.2.0}/qdiffusivity/example_api.py +0 -0
  23. {qdiffusivity-0.1.0 → qdiffusivity-0.2.0}/qdiffusivity/tests/__init__.py +0 -0
  24. {qdiffusivity-0.1.0 → qdiffusivity-0.2.0}/qdiffusivity/tests/test_qdiffusivity.py +0 -0
  25. {qdiffusivity-0.1.0 → qdiffusivity-0.2.0}/qdiffusivity/tests/utils.py +0 -0
  26. {qdiffusivity-0.1.0 → qdiffusivity-0.2.0}/qdiffusivity.egg-info/SOURCES.txt +0 -0
  27. {qdiffusivity-0.1.0 → qdiffusivity-0.2.0}/qdiffusivity.egg-info/dependency_links.txt +0 -0
  28. {qdiffusivity-0.1.0 → qdiffusivity-0.2.0}/qdiffusivity.egg-info/requires.txt +0 -0
  29. {qdiffusivity-0.1.0 → qdiffusivity-0.2.0}/qdiffusivity.egg-info/top_level.txt +0 -0
  30. {qdiffusivity-0.1.0 → qdiffusivity-0.2.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: qdiffusivity
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Quantile-based estimation of local diffusivities in molecular dynamics simulations of nanoconfined liquids
5
5
  Author-email: Shern Ren Tee <shernren@gmail.com>
6
6
  Maintainer-email: Shern Ren Tee <shernren@gmail.com>
@@ -9,12 +9,14 @@ from importlib.metadata import version
9
9
 
10
10
  from .binned import (
11
11
  LocalDiffusivityQBinned,
12
- TransverseDensityQBinned,
12
+ TransverseMassDensityQBinned,
13
+ TransverseNumDensityQBinned,
13
14
  cic_assign,
14
15
  resolve_bins,
15
16
  )
16
17
  from .density import (
17
- TransverseDensityQKDE,
18
+ TransverseMassDensityQKDE,
19
+ TransverseNumDensityQKDE,
18
20
  epanechnikov_kernel,
19
21
  kde_1d,
20
22
  select_bandwidth,
@@ -32,10 +34,12 @@ from .diffusivity import (
32
34
  __version__ = version("qdiffusivity")
33
35
 
34
36
  __all__ = [
35
- "TransverseDensityQBinned",
36
- "TransverseDensityQKDE",
37
37
  "LocalDiffusivityQBinned",
38
38
  "LocalDiffusivityQKDE",
39
+ "TransverseMassDensityQBinned",
40
+ "TransverseMassDensityQKDE",
41
+ "TransverseNumDensityQBinned",
42
+ "TransverseNumDensityQKDE",
39
43
  "build_cdf",
40
44
  "cic_assign",
41
45
  "epanechnikov_kernel",
@@ -30,8 +30,8 @@ from __future__ import annotations
30
30
 
31
31
  import numpy as np
32
32
  from MDAnalysis.analysis.base import AnalysisBase
33
- from MDAnalysis.transformations import NoJump
34
33
 
34
+ from .density import _AMU_PER_ANGSTROM3_TO_G_PER_CM3
35
35
  from .diffusivity import build_cdf
36
36
 
37
37
 
@@ -138,14 +138,10 @@ def _bin_centers_from_edges(edges):
138
138
  return 0.5 * (edges[:-1] + edges[1:])
139
139
 
140
140
 
141
- class TransverseDensityQBinned(AnalysisBase):
142
- r"""CDF-binned transverse number-density profile.
141
+ class _TransverseDensityQBinnedBase(AnalysisBase):
142
+ r"""Shared base for CDF-binned transverse density profiles.
143
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.
144
+ Subclasses set ``_mass_density`` to select number or mass density.
149
145
 
150
146
  Parameters
151
147
  ----------
@@ -175,15 +171,12 @@ class TransverseDensityQBinned(AnalysisBase):
175
171
  u_centers : numpy.ndarray
176
172
  ``(n_bins,)`` bin centres in u-space.
177
173
  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`.
174
+ ``(n_bins,)`` density profile. For number density:
175
+ particles/ų. For mass density: g/cm³.
182
176
  n_per_bin : numpy.ndarray
183
177
  ``(n_bins,)`` raw (weighted) population per bin.
184
178
  n_eff : numpy.ndarray
185
- ``(n_bins,)`` effective sample size (sum of CIC weights, or
186
- integer count for hard assignment).
179
+ ``(n_bins,)`` effective sample size.
187
180
  bin_edges_u : numpy.ndarray
188
181
  ``(n_bins + 1,)`` u-space bin edges.
189
182
  n_total : int
@@ -194,29 +187,10 @@ class TransverseDensityQBinned(AnalysisBase):
194
187
  Mean number of samples per frame.
195
188
  P, P_inv, rho : callables
196
189
  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
190
  """
219
191
 
192
+ _mass_density = False
193
+
220
194
  def __init__(
221
195
  self,
222
196
  atomgroup,
@@ -242,13 +216,13 @@ class TransverseDensityQBinned(AnalysisBase):
242
216
  self._grouping = grouping
243
217
 
244
218
  def _prepare(self):
219
+ self._masses = self._ag.masses.astype(np.float64)
245
220
  if self._grouping == "residues":
246
221
  residx = self._ag.resindices
247
222
  self._res_unique, self._res_inv = np.unique(
248
223
  residx, return_inverse=True
249
224
  )
250
225
  self._n_res = self._res_unique.size
251
- self._masses = self._ag.masses.astype(np.float64)
252
226
  self._res_mass = np.zeros(self._n_res, dtype=np.float64)
253
227
  np.add.at(self._res_mass, self._res_inv, self._masses)
254
228
  self._u_frames = []
@@ -282,7 +256,14 @@ class TransverseDensityQBinned(AnalysisBase):
282
256
  self.n_total / self.n_frames_used if self.n_frames_used > 0 else 0.0
283
257
  )
284
258
 
285
- self.P, self.P_inv, self.rho, _, _, _ = build_cdf(z_frames)
259
+ (
260
+ self.P,
261
+ self.P_inv,
262
+ self.rho,
263
+ self.rho_prime,
264
+ _,
265
+ _,
266
+ ) = build_cdf(z_frames)
286
267
  self.u_centers = _bin_centers_from_edges(self._edges_u)
287
268
  self.z_centers = self.P_inv(self.u_centers)
288
269
  n_bins = self._n_bins
@@ -302,8 +283,6 @@ class TransverseDensityQBinned(AnalysisBase):
302
283
  np.add.at(pop, k1, w1)
303
284
  self.n_eff = pop.copy()
304
285
  else:
305
- # Hard assignment via digitize. Samples exactly on the
306
- # upper edge (u == 1) are folded into the last bin.
307
286
  idx = np.clip(
308
287
  np.digitize(u_samples, self._edges_u) - 1,
309
288
  0,
@@ -314,26 +293,109 @@ class TransverseDensityQBinned(AnalysisBase):
314
293
 
315
294
  self.n_per_bin = pop
316
295
 
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
296
+ # Cross-sectional area.
324
297
  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]])
298
+ para = [i for i in (0, 1, 2) if i != self._dim]
299
+ A = float(dims[para[0]] * dims[para[1]])
300
+
328
301
  rho_centers = self.rho(self.z_centers)
329
302
  du = np.diff(self._edges_u)
330
303
  with np.errstate(divide="ignore", invalid="ignore"):
331
304
  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
- )
305
+
306
+ if self._mass_density:
307
+ # Mass-weighted population: each sample's CIC/hard
308
+ # weight is multiplied by its mass. For CIC we
309
+ # re-accumulate with mass weights; for hard assignment
310
+ # we use np.bincount with weights.
311
+ if self._use_cic:
312
+ mass_per_sample = (
313
+ np.tile(self._masses, self.n_frames_used)
314
+ if self._grouping == "atoms"
315
+ else np.repeat(self._res_mass, self.n_frames_used)
316
+ )
317
+ mass_pop = np.zeros(n_bins, dtype=np.float64)
318
+ np.add.at(mass_pop, k0, w0 * mass_per_sample)
319
+ np.add.at(mass_pop, k1, w1 * mass_per_sample)
320
+ else:
321
+ mass_per_sample = (
322
+ np.tile(self._masses, self.n_frames_used)
323
+ if self._grouping == "atoms"
324
+ else np.repeat(self._res_mass, self.n_frames_used)
325
+ )
326
+ mass_pop = np.bincount(
327
+ idx,
328
+ weights=mass_per_sample,
329
+ minlength=n_bins,
330
+ ).astype(np.float64)
331
+ # Mass density (amu/Å^3) -> g/cm^3.
332
+ self.density = np.where(
333
+ dz > 0,
334
+ mass_pop
335
+ / (self.n_frames_used * A * dz)
336
+ / _AMU_PER_ANGSTROM3_TO_G_PER_CM3,
337
+ 0.0,
338
+ )
339
+ else:
340
+ self.density = np.where(
341
+ dz > 0,
342
+ pop / (self.n_frames_used * A * dz),
343
+ 0.0,
344
+ )
345
+
346
+
347
+ class TransverseNumDensityQBinned(_TransverseDensityQBinnedBase):
348
+ r"""CDF-binned transverse number-density profile.
349
+
350
+ Returns number density in particles/ų. See
351
+ :class:`_TransverseDensityQBinnedBase` for parameters and attributes.
352
+
353
+ Examples
354
+ --------
355
+ ::
356
+
357
+ import MDAnalysis as mda
358
+ from qdiffusivity import TransverseNumDensityQBinned
359
+
360
+ u = mda.Universe("topology.data", "trajectory.xtc")
361
+ ag = u.select_atoms("type 1 2")
362
+ binned = TransverseNumDensityQBinned(
363
+ ag, dim=2, z_bot=10.0, z_top=90.0, bins=30,
364
+ )
365
+ binned.run()
366
+ # binned.density is in particles/Å^3.
367
+ """
368
+
369
+ _mass_density = False
370
+
371
+
372
+ class TransverseMassDensityQBinned(_TransverseDensityQBinnedBase):
373
+ r"""CDF-binned transverse mass-density profile.
374
+
375
+ Returns mass density in g/cm³ (matching MDAnalysis's
376
+ :class:`~MDAnalysis.analysis.lineardensity.LinearDensity`). Each
377
+ sample is weighted by its topology mass (per atom for
378
+ ``grouping="atoms"``; per-residue total mass for
379
+ ``grouping="residues"``). See
380
+ :class:`_TransverseDensityQBinnedBase` for parameters and attributes.
381
+
382
+ Examples
383
+ --------
384
+ ::
385
+
386
+ import MDAnalysis as mda
387
+ from qdiffusivity import TransverseMassDensityQBinned
388
+
389
+ u = mda.Universe("topology.data", "trajectory.xtc")
390
+ ag = u.select_atoms("type 1 2")
391
+ binned = TransverseMassDensityQBinned(
392
+ ag, dim=2, z_bot=10.0, z_top=90.0, bins=30,
393
+ )
394
+ binned.run()
395
+ # binned.density is in g/cm^3.
396
+ """
397
+
398
+ _mass_density = True
337
399
 
338
400
 
339
401
  class LocalDiffusivityQBinned(AnalysisBase):
@@ -373,6 +435,17 @@ class LocalDiffusivityQBinned(AnalysisBase):
373
435
  :math:`\frac{\Delta t}{2}\Phi(z)^2` (with
374
436
  :math:`\Phi = D\,\rho'/\rho` in the isothermal convention) from
375
437
  the perpendicular estimator. Default ``False``.
438
+ density_result : TransverseNumDensityQKDE or None, optional
439
+ A pre-computed density result whose CDF closures
440
+ (:attr:`P`, :attr:`P_inv`, :attr:`rho`, :attr:`rho_prime`)
441
+ are reused for the u-space mapping. If ``None`` (default),
442
+ a :class:`TransverseNumDensityQKDE` is run internally in
443
+ ``_prepare()`` to build the CDF automatically. Passing a
444
+ pre-computed result enables a **two-pass parallelization**
445
+ strategy: run the density analysis first (parallelizable via
446
+ split-apply-combine), then pass its result to the diffusivity
447
+ analysis (each frame's u-mapping is then stateless and
448
+ parallelizable).
376
449
 
377
450
  Attributes
378
451
  ----------
@@ -407,14 +480,18 @@ class LocalDiffusivityQBinned(AnalysisBase):
407
480
  Time step used in the estimator.
408
481
  P, P_inv, rho : callables
409
482
  CDF, inverse-CDF and equilibrium density closures.
483
+ rho_prime : callable
484
+ Spatial derivative of the equilibrium density.
410
485
 
411
486
  Notes
412
487
  -----
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).
488
+ Multi-frame displacements across periodic boundaries are computed
489
+ with the **minimum-image convention** applied directly to the
490
+ stored wrapped positions. This is a stateless, frame-local
491
+ operation only consecutive-frame positions are needed so the
492
+ estimator is compatible with split-apply-combine parallelization
493
+ (each worker block can be processed independently without missing
494
+ prior image corrections).
418
495
 
419
496
  Examples
420
497
  --------
@@ -439,6 +516,7 @@ class LocalDiffusivityQBinned(AnalysisBase):
439
516
  bins=30,
440
517
  dt=None,
441
518
  ito_correction: bool = False,
519
+ density_result=None,
442
520
  ):
443
521
  super().__init__(atomgroup.universe.trajectory)
444
522
  self._ag = atomgroup
@@ -449,8 +527,26 @@ class LocalDiffusivityQBinned(AnalysisBase):
449
527
  self._n_bins, self._edges_u, self._use_cic = resolve_bins(bins)
450
528
  self._dt = dt
451
529
  self._ito_correction = bool(ito_correction)
530
+ self._density_result = density_result
452
531
 
453
532
  def _prepare(self):
533
+ if self._density_result is not None:
534
+ dr = self._density_result
535
+ self.P = dr.P
536
+ self.P_inv = dr.P_inv
537
+ self.rho = dr.rho
538
+ self.rho_prime = dr.rho_prime
539
+ else:
540
+ dens = TransverseNumDensityQBinned(
541
+ self._ag,
542
+ dim=self._dim,
543
+ bins=self._n_bins,
544
+ )
545
+ dens.run()
546
+ self.P = dens.P
547
+ self.P_inv = dens.P_inv
548
+ self.rho = dens.rho
549
+ self.rho_prime = dens.rho_prime
454
550
  self._pos_frames = []
455
551
 
456
552
  def _single_frame(self):
@@ -470,40 +566,44 @@ class LocalDiffusivityQBinned(AnalysisBase):
470
566
  if dt <= 0:
471
567
  raise ValueError(f"dt must be positive, got {dt}")
472
568
 
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)
569
+ # Box lengths from the last analysed frame (constant-box / NVT
570
+ # assumption). Minimum-image correction is applied to the
571
+ # *displacement* of consecutive frames, not to the absolute
572
+ # positions — stateless and parallelization-friendly.
573
+ box = self._ts.dimensions
574
+ Lz = float(box[self._dim])
575
+ Lpara = {d: float(box[d]) for d in self._para_dims}
576
+
577
+ # Wrap the confined coordinate into [0, Lz] for CDF construction.
578
+ if Lz > 0:
579
+ pos[:, :, self._dim] %= Lz
580
+
581
+ # Build CDF from pooled wrapped-z — unless a pre-computed
582
+ # CDF was supplied (or auto-run in _prepare).
583
+ if self._density_result is None:
584
+ z_pooled = pos[:, :, self._dim].ravel()
585
+ (
586
+ self.P,
587
+ self.P_inv,
588
+ self.rho,
589
+ self.rho_prime,
590
+ _,
591
+ _,
592
+ ) = build_cdf(z_pooled)
496
593
 
497
594
  self.u_centers = _bin_centers_from_edges(self._edges_u)
498
595
  self.z_centers = self.P_inv(self.u_centers)
499
596
  n_bins = self._n_bins
500
597
 
501
598
  # u-positions of all increments (starting frame of each pair).
502
- z_start = pos_unwrapped[:-1, :, self._dim]
599
+ z_start = pos[:-1, :, self._dim]
503
600
  u_start = self.P(z_start.ravel())
504
601
 
505
602
  # Perpendicular: z-space local estimator (Δz)²/(2Δt).
506
- dz = np.diff(pos_unwrapped[:, :, self._dim], axis=0)
603
+ # Minimum-image correction on the wrapped-z displacement.
604
+ dz = np.diff(pos[:, :, self._dim], axis=0)
605
+ if Lz > 0:
606
+ dz -= np.rint(dz / Lz) * Lz
507
607
  d_perp_local = (dz**2) / (2.0 * dt)
508
608
  self.n_increments = int(d_perp_local.size)
509
609
 
@@ -532,10 +632,14 @@ class LocalDiffusivityQBinned(AnalysisBase):
532
632
 
533
633
  self.D_perp = np.clip(self.D_perp, 0, None)
534
634
 
535
- # Parallel: Δx² + Δy², only z_start mapped to u.
635
+ # Parallel: Δx² + Δy², only z_start mapped to u. Minimum-image
636
+ # correction on each parallel displacement.
536
637
  d_para_acc = np.zeros_like(dz)
537
638
  for d in self._para_dims:
538
- diff_d = np.diff(pos_unwrapped[:, :, d], axis=0)
639
+ diff_d = np.diff(pos[:, :, d], axis=0)
640
+ Ld = Lpara[d]
641
+ if Ld > 0:
642
+ diff_d -= np.rint(diff_d / Ld) * Ld
539
643
  d_para_acc += diff_d**2
540
644
  d_para_local = d_para_acc / (4.0 * dt)
541
645