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
|
File without changes
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Shared pytest fixtures for the qdiffusivity test suite."""
|
|
2
|
+
|
|
3
|
+
import MDAnalysis as mda
|
|
4
|
+
import numpy as np
|
|
5
|
+
import pytest
|
|
6
|
+
from MDAnalysis.coordinates.memory import MemoryReader
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _make_universe(n_atoms, n_res, n_frames, Lx, Ly, Lz, pos, seed):
|
|
10
|
+
"""Build a Universe with masses, types, residues, and a trajectory."""
|
|
11
|
+
u = mda.Universe.empty(
|
|
12
|
+
n_atoms=n_atoms,
|
|
13
|
+
n_residues=n_res,
|
|
14
|
+
atom_resindex=np.arange(n_atoms) % n_res,
|
|
15
|
+
trajectory=True,
|
|
16
|
+
)
|
|
17
|
+
u.add_TopologyAttr("masses", values=np.ones(n_atoms))
|
|
18
|
+
u.add_TopologyAttr("types", values=["X"] * n_atoms)
|
|
19
|
+
u.add_TopologyAttr("resids", values=np.arange(1, n_res + 1))
|
|
20
|
+
u.add_TopologyAttr("resnames", values=["RES"] * n_res)
|
|
21
|
+
dims = np.tile([Lx, Ly, Lz, 90.0, 90.0, 90.0], (n_frames, 1))
|
|
22
|
+
u.trajectory = MemoryReader(pos, dimensions=dims)
|
|
23
|
+
return u
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@pytest.fixture
|
|
27
|
+
def diff_universe():
|
|
28
|
+
"""Universe whose atoms perform independent Brownian walks.
|
|
29
|
+
|
|
30
|
+
Atoms diffuse with prescribed D_perp (along z) and D_para (along
|
|
31
|
+
x/y) with dt=1. z is wrapped to [0, Lz]; x/y are wrapped for the
|
|
32
|
+
topology (NoJump unwraps them during analysis).
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def _build(
|
|
36
|
+
n_atoms=100,
|
|
37
|
+
n_frames=20,
|
|
38
|
+
Lx=30.0,
|
|
39
|
+
Ly=30.0,
|
|
40
|
+
Lz=80.0,
|
|
41
|
+
D_perp=0.05,
|
|
42
|
+
D_para=0.1,
|
|
43
|
+
seed=0,
|
|
44
|
+
):
|
|
45
|
+
rng = np.random.default_rng(seed)
|
|
46
|
+
pos = np.empty((n_frames, n_atoms, 3), dtype=np.float64)
|
|
47
|
+
z = rng.uniform(0.0, Lz, size=n_atoms)
|
|
48
|
+
x = rng.uniform(0.0, Lx, size=n_atoms)
|
|
49
|
+
y = rng.uniform(0.0, Ly, size=n_atoms)
|
|
50
|
+
sp = np.sqrt(2.0 * D_perp)
|
|
51
|
+
sq = np.sqrt(2.0 * D_para)
|
|
52
|
+
for f in range(n_frames):
|
|
53
|
+
x = (x + rng.normal(0.0, sq, size=n_atoms)) % Lx
|
|
54
|
+
y = (y + rng.normal(0.0, sq, size=n_atoms)) % Ly
|
|
55
|
+
z = (z + rng.normal(0.0, sp, size=n_atoms)) % Lz
|
|
56
|
+
pos[f, :, 0] = x
|
|
57
|
+
pos[f, :, 1] = y
|
|
58
|
+
pos[f, :, 2] = z
|
|
59
|
+
return _make_universe(n_atoms, n_atoms, n_frames, Lx, Ly, Lz, pos, seed)
|
|
60
|
+
|
|
61
|
+
return _build
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@pytest.fixture
|
|
65
|
+
def density_universe():
|
|
66
|
+
"""Universe with atoms random-walking in z (no drift, uniform density)."""
|
|
67
|
+
|
|
68
|
+
def _build(
|
|
69
|
+
n_atoms=200, n_res=10, n_frames=5,
|
|
70
|
+
Lx=20.0, Ly=20.0, Lz=100.0, seed=0,
|
|
71
|
+
):
|
|
72
|
+
rng = np.random.default_rng(seed)
|
|
73
|
+
pos = np.empty((n_frames, n_atoms, 3), dtype=np.float64)
|
|
74
|
+
base = rng.uniform(0.0, Lz, size=n_atoms)
|
|
75
|
+
for f in range(n_frames):
|
|
76
|
+
pos[f, :, 0] = rng.uniform(0.0, Lx, size=n_atoms)
|
|
77
|
+
pos[f, :, 1] = rng.uniform(0.0, Ly, size=n_atoms)
|
|
78
|
+
base = (base + rng.normal(0.0, 1.0, size=n_atoms)) % Lz
|
|
79
|
+
pos[f, :, 2] = base
|
|
80
|
+
return _make_universe(n_atoms, n_res, n_frames, Lx, Ly, Lz, pos, seed)
|
|
81
|
+
|
|
82
|
+
return _build
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Tests for the binned density/diffusivity module."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from qdiffusivity.binned import (
|
|
7
|
+
LocalDiffusivityQBinned,
|
|
8
|
+
TransverseDensityQBinned,
|
|
9
|
+
cic_assign,
|
|
10
|
+
resolve_bins,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_resolve_bins():
|
|
15
|
+
"""int, 'quantile', explicit edges (padded/full), and invalid specs."""
|
|
16
|
+
n, edges, cic = resolve_bins(20)
|
|
17
|
+
assert n == 20 and edges.shape == (21,) and cic is True
|
|
18
|
+
n, _, cic = resolve_bins("quantile")
|
|
19
|
+
assert n == 30 and cic is True
|
|
20
|
+
assert resolve_bins("quantile", n_default=15)[0] == 15
|
|
21
|
+
n, edges, cic = resolve_bins(np.array([0.1, 0.3, 0.7, 0.9]))
|
|
22
|
+
assert n == 5 and edges[0] == 0.0 and edges[-1] == 1.0 and cic is False
|
|
23
|
+
n, edges, cic = resolve_bins(np.array([0, 0.25, 0.5, 0.75, 1.0]))
|
|
24
|
+
assert n == 4 and cic is False
|
|
25
|
+
for bad in [
|
|
26
|
+
"bogus",
|
|
27
|
+
0,
|
|
28
|
+
np.array([0, 0.5, 0.3, 1.0]),
|
|
29
|
+
np.array([-0.1, 0.5, 1.0]),
|
|
30
|
+
np.array([0, 0.5, 1.1]),
|
|
31
|
+
]:
|
|
32
|
+
with pytest.raises(ValueError):
|
|
33
|
+
resolve_bins(bad)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_cic_assign():
|
|
37
|
+
"""Weights sum to 1, center -> single bin, edge -> 50/50, mirror,
|
|
38
|
+
total population preserved."""
|
|
39
|
+
rng = np.random.default_rng(40)
|
|
40
|
+
u = rng.uniform(0.0, 1.0, size=5_000)
|
|
41
|
+
k0, k1, w0, w1 = cic_assign(u, 20)
|
|
42
|
+
assert np.allclose(w0 + w1, 1.0)
|
|
43
|
+
assert np.all(k0 >= 0) and np.all(k0 < 20)
|
|
44
|
+
# Center of bin 10: u = 10.5/20 = 0.525.
|
|
45
|
+
_, _, w0c, w1c = cic_assign(np.array([0.525]), 20)
|
|
46
|
+
assert w0c[0] == pytest.approx(1.0) and w1c[0] == pytest.approx(0.0)
|
|
47
|
+
# Edge: u = 0.5 -> 50/50.
|
|
48
|
+
_, _, w0e, w1e = cic_assign(np.array([0.5]), 20)
|
|
49
|
+
assert w0e[0] == pytest.approx(0.5) and w1e[0] == pytest.approx(0.5)
|
|
50
|
+
# Mirror: u = 0.01 -> q = -0.3 -> reflected to 0.3 -> w0 = 0.7.
|
|
51
|
+
k0m, _, w0m, _ = cic_assign(np.array([0.01]), 20)
|
|
52
|
+
assert k0m[0] == 0 and w0m[0] == pytest.approx(0.7)
|
|
53
|
+
# Total population preserved.
|
|
54
|
+
pop = np.zeros(20)
|
|
55
|
+
np.add.at(pop, k0, w0)
|
|
56
|
+
np.add.at(pop, k1, w1)
|
|
57
|
+
assert pop.sum() == pytest.approx(5_000.0)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@pytest.mark.parametrize(
|
|
61
|
+
"bins,expected_n",
|
|
62
|
+
[(20, 20), ("quantile", 30), (np.array([0, 0.25, 0.5, 0.75, 1.0]), 4)],
|
|
63
|
+
)
|
|
64
|
+
@pytest.mark.parametrize("grouping", ["atoms", "residues"])
|
|
65
|
+
def test_density_binned(density_universe, bins, expected_n, grouping):
|
|
66
|
+
"""Run, attributes, density positive, all bin specs, grouping."""
|
|
67
|
+
u = density_universe(n_atoms=200, n_res=20, n_frames=5, Lz=100.0, seed=50)
|
|
68
|
+
binned = TransverseDensityQBinned(
|
|
69
|
+
u.select_atoms("all"),
|
|
70
|
+
dim=2,
|
|
71
|
+
z_bot=0.0,
|
|
72
|
+
z_top=100.0,
|
|
73
|
+
bins=bins,
|
|
74
|
+
grouping=grouping,
|
|
75
|
+
)
|
|
76
|
+
binned.run()
|
|
77
|
+
assert binned.density.shape == (expected_n,)
|
|
78
|
+
assert binned.z_centers.shape == (expected_n,)
|
|
79
|
+
assert np.all(binned.density >= 0.0)
|
|
80
|
+
assert binned.n_frames_used == 5
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@pytest.mark.parametrize(
|
|
84
|
+
"bins,expected_n",
|
|
85
|
+
[(20, 20), ("quantile", 30),
|
|
86
|
+
(np.array([0.1, 0.3, 0.7, 0.9]), 5)]
|
|
87
|
+
)
|
|
88
|
+
def test_diffusivity_binned(diff_universe, bins, expected_n):
|
|
89
|
+
"""Run, attributes, known-D recovery, all bin specs."""
|
|
90
|
+
D_perp_true, D_para_true = 0.05, 0.1
|
|
91
|
+
u = diff_universe(
|
|
92
|
+
n_atoms=400,
|
|
93
|
+
n_frames=40,
|
|
94
|
+
Lz=60.0,
|
|
95
|
+
D_perp=D_perp_true,
|
|
96
|
+
D_para=D_para_true,
|
|
97
|
+
seed=61,
|
|
98
|
+
)
|
|
99
|
+
binned = LocalDiffusivityQBinned(u.select_atoms("all"), dim=2, bins=bins)
|
|
100
|
+
binned.run()
|
|
101
|
+
assert binned.D_perp.shape == (expected_n,)
|
|
102
|
+
assert binned.n_increments == 400 * 39
|
|
103
|
+
assert binned.dt == pytest.approx(1.0)
|
|
104
|
+
bulk = (binned.z_centers > 20) & (binned.z_centers < 40)
|
|
105
|
+
valid = binned.n_eff_perp > 5
|
|
106
|
+
assert np.median(binned.D_perp[bulk & valid]) == pytest.approx(
|
|
107
|
+
D_perp_true, rel=0.3
|
|
108
|
+
)
|
|
109
|
+
assert np.median(binned.D_para[bulk & valid]) == pytest.approx(
|
|
110
|
+
D_para_true, rel=0.3
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@pytest.mark.parametrize(
|
|
115
|
+
"cls,kwargs",
|
|
116
|
+
[
|
|
117
|
+
(TransverseDensityQBinned, {"dim": 5}),
|
|
118
|
+
(TransverseDensityQBinned, {"grouping": "molecules"}),
|
|
119
|
+
(LocalDiffusivityQBinned, {"dim": 5}),
|
|
120
|
+
],
|
|
121
|
+
)
|
|
122
|
+
def test_binned_validation(density_universe, diff_universe, cls, kwargs):
|
|
123
|
+
if cls is TransverseDensityQBinned:
|
|
124
|
+
u = density_universe(n_atoms=10, n_res=2, n_frames=1, Lz=10.0)
|
|
125
|
+
else:
|
|
126
|
+
u = diff_universe(n_atoms=10, n_frames=3, Lz=10.0)
|
|
127
|
+
with pytest.raises(ValueError):
|
|
128
|
+
cls(u.select_atoms("all"), **kwargs)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_diffusivity_binned_single_frame_raises(diff_universe):
|
|
132
|
+
u = diff_universe(n_atoms=10, n_frames=1, Lz=10.0)
|
|
133
|
+
binned = LocalDiffusivityQBinned(u.select_atoms("all"), bins=10)
|
|
134
|
+
with pytest.raises(ValueError):
|
|
135
|
+
binned.run()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_diffusivity_binned_explicit_dt(diff_universe):
|
|
139
|
+
u = diff_universe(n_atoms=50, n_frames=5, Lz=40.0, seed=68)
|
|
140
|
+
binned = LocalDiffusivityQBinned(
|
|
141
|
+
u.select_atoms("all"), dim=2, bins=10, dt=2.0
|
|
142
|
+
)
|
|
143
|
+
binned.run()
|
|
144
|
+
assert binned.dt == pytest.approx(2.0)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@pytest.mark.parametrize("ito", [False, True])
|
|
148
|
+
def test_diffusivity_binned_ito(diff_universe, ito):
|
|
149
|
+
"""Default-off (None), on (finite non-neg), reduces D_perp, no effect
|
|
150
|
+
on D_para."""
|
|
151
|
+
u = diff_universe(n_atoms=200, n_frames=20, Lz=60.0, seed=66)
|
|
152
|
+
ag = u.select_atoms("all")
|
|
153
|
+
binned = LocalDiffusivityQBinned(ag, dim=2, bins=15, ito_correction=ito)
|
|
154
|
+
binned.run()
|
|
155
|
+
if not ito:
|
|
156
|
+
assert binned.ito_bias is None
|
|
157
|
+
return
|
|
158
|
+
assert binned.ito_bias is not None
|
|
159
|
+
assert np.all(binned.ito_bias >= 0)
|
|
160
|
+
b_unc = LocalDiffusivityQBinned(ag, dim=2, bins=15)
|
|
161
|
+
b_unc.run()
|
|
162
|
+
assert np.all(binned.D_perp <= b_unc.D_perp + 1e-12)
|
|
163
|
+
assert np.allclose(binned.D_para, b_unc.D_para)
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Tests for the density QKDE module."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from qdiffusivity.density import (
|
|
7
|
+
TransverseDensityQKDE,
|
|
8
|
+
epanechnikov_kernel,
|
|
9
|
+
kde_1d,
|
|
10
|
+
select_bandwidth,
|
|
11
|
+
silverman_bw,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_epanechnikov_kernel():
|
|
16
|
+
"""Normalised, compact support, non-negative, peak at zero."""
|
|
17
|
+
x = np.linspace(-3.0, 3.0, 200_001)
|
|
18
|
+
assert np.trapezoid(
|
|
19
|
+
epanechnikov_kernel(x, 1.0), x
|
|
20
|
+
) == pytest.approx(1.0, abs=1e-4)
|
|
21
|
+
assert epanechnikov_kernel(np.array([1.6]), 1.5)[0] == 0.0
|
|
22
|
+
assert epanechnikov_kernel(
|
|
23
|
+
np.array([0.0]), 1.5
|
|
24
|
+
)[0] == pytest.approx(0.75 / 1.5)
|
|
25
|
+
assert np.all(epanechnikov_kernel(x, 2.0) >= 0.0)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_bandwidth_selection():
|
|
29
|
+
"""Silverman, Sheather-Jones, select_bandwidth, and edge cases."""
|
|
30
|
+
rng = np.random.default_rng(0)
|
|
31
|
+
z = rng.normal(0.0, 1.0, size=10_000)
|
|
32
|
+
assert silverman_bw(z) == pytest.approx(1.06 * 10_000 ** (-0.2), rel=0.3)
|
|
33
|
+
assert silverman_bw(np.array([1.0])) == 0.1
|
|
34
|
+
from qdiffusivity.density import sheather_jones_bw
|
|
35
|
+
|
|
36
|
+
z_bimodal = np.concatenate(
|
|
37
|
+
[rng.normal(-3.0, 0.5, 2000), rng.normal(3.0, 0.5, 2000)]
|
|
38
|
+
)
|
|
39
|
+
assert 0 < sheather_jones_bw(z_bimodal, -10, 10) <= silverman_bw(z_bimodal)
|
|
40
|
+
assert select_bandwidth(z, -5, 5, method=0.42) == pytest.approx(0.42)
|
|
41
|
+
with pytest.raises(ValueError):
|
|
42
|
+
select_bandwidth(z, -5, 5, method="bogus")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_kde_1d():
|
|
46
|
+
"""Integration to 1, bandwidth validation, ESS bounds, empty data."""
|
|
47
|
+
rng = np.random.default_rng(1)
|
|
48
|
+
z = rng.normal(50.0, 5.0, size=20_000)
|
|
49
|
+
z_eval = np.linspace(20.0, 80.0, 2001)
|
|
50
|
+
rho, n_eff = kde_1d(z, z_eval, 0.5, 0.0, 100.0)
|
|
51
|
+
assert np.trapezoid(rho, z_eval) == pytest.approx(1.0, abs=0.02)
|
|
52
|
+
# ESS is positive in the well-sampled interior and bounded by N.
|
|
53
|
+
interior = (z_eval > 40) & (z_eval < 60)
|
|
54
|
+
assert np.all(n_eff[interior] > 0)
|
|
55
|
+
assert np.all(n_eff <= z.size)
|
|
56
|
+
with pytest.raises(ValueError):
|
|
57
|
+
kde_1d(z, z_eval, 0.0, 0.0, 100.0)
|
|
58
|
+
rho_e, n_eff_e = kde_1d(np.array([]), np.linspace(0, 10, 11), 0.5, 0, 10)
|
|
59
|
+
assert np.all(rho_e == 0.0) and np.all(n_eff_e == 0.0)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@pytest.mark.parametrize("grouping", ["atoms", "residues"])
|
|
63
|
+
def test_density_qkde(density_universe, grouping):
|
|
64
|
+
"""Run, attributes, normalisation, auto bandwidth, auto boundaries."""
|
|
65
|
+
n_atoms, n_res, n_frames = 200, 10, 5
|
|
66
|
+
u = density_universe(
|
|
67
|
+
n_atoms=n_atoms, n_res=n_res,
|
|
68
|
+
n_frames=n_frames, Lz=100.0,
|
|
69
|
+
)
|
|
70
|
+
ag = u.select_atoms("all")
|
|
71
|
+
kde = TransverseDensityQKDE(
|
|
72
|
+
ag,
|
|
73
|
+
dim=2,
|
|
74
|
+
z_bot=0.0,
|
|
75
|
+
z_top=100.0,
|
|
76
|
+
n_points=80,
|
|
77
|
+
bandwidth="auto",
|
|
78
|
+
grouping=grouping,
|
|
79
|
+
)
|
|
80
|
+
kde.run()
|
|
81
|
+
assert kde.rho.shape == (80,)
|
|
82
|
+
assert np.all(kde.rho >= 0.0)
|
|
83
|
+
assert np.trapezoid(kde.rho, kde.z_eval) == pytest.approx(1.0, abs=0.05)
|
|
84
|
+
assert kde.n_frames_used == n_frames
|
|
85
|
+
if grouping == "residues":
|
|
86
|
+
expected_n = n_res * n_frames
|
|
87
|
+
else:
|
|
88
|
+
expected_n = n_atoms * n_frames
|
|
89
|
+
assert kde.n_total == expected_n
|
|
90
|
+
assert np.isfinite(kde.bandwidth) and kde.bandwidth > 0
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_density_qkde_auto_boundaries(density_universe):
|
|
94
|
+
u = density_universe(n_atoms=100, n_frames=2, Lz=40.0)
|
|
95
|
+
kde = TransverseDensityQKDE(
|
|
96
|
+
u.select_atoms("all"), dim=2, n_points=40, bandwidth=0.3
|
|
97
|
+
)
|
|
98
|
+
kde.run()
|
|
99
|
+
assert kde.z_eval[0] > 0.0
|
|
100
|
+
assert kde.z_eval[-1] < 40.0
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@pytest.mark.parametrize(
|
|
104
|
+
"kwargs",
|
|
105
|
+
[
|
|
106
|
+
{"dim": 5},
|
|
107
|
+
{"grouping": "molecules"},
|
|
108
|
+
{"bandwidth": "bogus"},
|
|
109
|
+
],
|
|
110
|
+
)
|
|
111
|
+
def test_density_qkde_validation(density_universe, kwargs):
|
|
112
|
+
u = density_universe(n_atoms=10, n_res=2, n_frames=1, Lz=10.0)
|
|
113
|
+
with pytest.raises(ValueError):
|
|
114
|
+
TransverseDensityQKDE(u.select_atoms("all"), **kwargs)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_density_qkde_inverted_boundaries(density_universe):
|
|
118
|
+
u = density_universe(n_atoms=10, n_res=2, n_frames=1, Lz=10.0)
|
|
119
|
+
kde = TransverseDensityQKDE(
|
|
120
|
+
u.select_atoms("all"), dim=2, z_bot=10.0, z_top=0.0, bandwidth=0.5
|
|
121
|
+
)
|
|
122
|
+
with pytest.raises(ValueError):
|
|
123
|
+
kde.run()
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Tests for the diffusivity QKDE module."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
import qdiffusivity
|
|
7
|
+
from qdiffusivity.diffusivity import (
|
|
8
|
+
LocalDiffusivityQKDE,
|
|
9
|
+
build_cdf,
|
|
10
|
+
gaussian_kernel,
|
|
11
|
+
kde_estimate,
|
|
12
|
+
select_diff_bandwidth,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_gaussian_kernel():
|
|
17
|
+
"""Normalised, symmetric, peak at zero."""
|
|
18
|
+
x = np.linspace(-10.0, 10.0, 200_001)
|
|
19
|
+
assert np.trapezoid(
|
|
20
|
+
gaussian_kernel(x, 1.0), x
|
|
21
|
+
) == pytest.approx(1.0, abs=1e-4)
|
|
22
|
+
k = gaussian_kernel(np.array([-3, -1, 0, 1, 3.0]), 1.5)
|
|
23
|
+
assert k[0] == pytest.approx(k[-1])
|
|
24
|
+
assert k[2] > k[1] > k[0]
|
|
25
|
+
assert k[2] == pytest.approx(1.0 / (1.5 * np.sqrt(2 * np.pi)))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_bandwidth_selection():
|
|
29
|
+
"""Silverman, SJ for both kernels, select_diff_bandwidth, invalid."""
|
|
30
|
+
rng = np.random.default_rng(10)
|
|
31
|
+
u = rng.uniform(0.0, 1.0, size=5_000)
|
|
32
|
+
from qdiffusivity.diffusivity import sheather_jones_bw, silverman_bw
|
|
33
|
+
|
|
34
|
+
assert silverman_bw(u) > 0
|
|
35
|
+
for kernel in ("gaussian", "epanechnikov"):
|
|
36
|
+
h = sheather_jones_bw(u, kernel=kernel)
|
|
37
|
+
assert np.isfinite(h) and h > 0
|
|
38
|
+
h_unif = sheather_jones_bw(np.linspace(0, 1, 5000), kernel="gaussian")
|
|
39
|
+
assert h_unif <= silverman_bw(np.linspace(0, 1, 5000))
|
|
40
|
+
assert select_diff_bandwidth(u, method=0.42) == pytest.approx(0.42)
|
|
41
|
+
with pytest.raises(ValueError):
|
|
42
|
+
select_diff_bandwidth(u, method="bogus", kernel="gaussian")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_build_cdf():
|
|
46
|
+
"""Monotonic, in-bounds, inverse roundtrip, rho integrates to 1,
|
|
47
|
+
rho_prime sign at mode, empty raises."""
|
|
48
|
+
rng = np.random.default_rng(13)
|
|
49
|
+
z = rng.normal(50.0, 5.0, size=20_000)
|
|
50
|
+
P, P_inv, rho, rho_prime, _, _ = build_cdf(z)
|
|
51
|
+
z_grid = np.linspace(40, 60, 101)
|
|
52
|
+
u_grid = P(z_grid)
|
|
53
|
+
assert np.all(np.diff(u_grid) >= -1e-12)
|
|
54
|
+
assert np.all(u_grid >= 0) and np.all(u_grid <= 1)
|
|
55
|
+
z_back = P_inv(P(z_grid))
|
|
56
|
+
assert np.allclose(z_back, z_grid, atol=0.1)
|
|
57
|
+
rho_vals = rho(z_grid)
|
|
58
|
+
assert np.all(rho_vals >= 0)
|
|
59
|
+
assert np.trapezoid(rho_vals, z_grid) == pytest.approx(1.0, abs=0.1)
|
|
60
|
+
rho_p = rho_prime(np.array([45.0, 50.0, 55.0]))
|
|
61
|
+
assert rho_p[0] > 0 and rho_p[2] < 0
|
|
62
|
+
assert rho_p[1] == pytest.approx(0.0, abs=2e-3)
|
|
63
|
+
with pytest.raises(ValueError):
|
|
64
|
+
build_cdf(np.array([]))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_kde_estimate():
|
|
68
|
+
"""Constant estimator, weighted mean, bandwidth validation, empty,
|
|
69
|
+
ESS bound, kernels agree at wide bandwidth."""
|
|
70
|
+
rng = np.random.default_rng(16)
|
|
71
|
+
u = rng.uniform(0.0, 1.0, size=5_000)
|
|
72
|
+
D, D_std, n_eff = kde_estimate(
|
|
73
|
+
u, np.full(5000, 0.3), np.array([0.25, 0.5, 0.75]), h=0.05
|
|
74
|
+
)
|
|
75
|
+
assert np.allclose(D, 0.3, atol=1e-10)
|
|
76
|
+
assert np.all(n_eff > 0)
|
|
77
|
+
# Two clusters.
|
|
78
|
+
u2 = np.concatenate([np.full(2000, 0.2), np.full(2000, 0.8)])
|
|
79
|
+
d2 = np.concatenate([np.full(2000, 1.0), np.full(2000, 2.0)])
|
|
80
|
+
D2, _, _ = kde_estimate(u2, d2, np.array([0.2, 0.8]), h=0.05)
|
|
81
|
+
assert D2[0] == pytest.approx(1.0, abs=0.05)
|
|
82
|
+
assert D2[1] == pytest.approx(2.0, abs=0.05)
|
|
83
|
+
with pytest.raises(ValueError):
|
|
84
|
+
kde_estimate(u, np.ones(5000), np.array([0.5]), h=0.0)
|
|
85
|
+
De, _, ne = kde_estimate(
|
|
86
|
+
np.array([]), np.array([]), np.linspace(0.1, 0.9, 5), h=0.1
|
|
87
|
+
)
|
|
88
|
+
assert np.all(De == 0) and np.all(ne == 0)
|
|
89
|
+
# Kernels agree at wide bandwidth.
|
|
90
|
+
d3 = rng.normal(1.0, 0.2, size=2000)
|
|
91
|
+
Dg, _, _ = kde_estimate(
|
|
92
|
+
u2[:2000], d3, np.array([0.5]), h=2.0,
|
|
93
|
+
kernel="gaussian",
|
|
94
|
+
)
|
|
95
|
+
Dep, _, _ = kde_estimate(
|
|
96
|
+
u2[:2000], d3, np.array([0.5]), h=2.0, kernel="epanechnikov"
|
|
97
|
+
)
|
|
98
|
+
assert Dg[0] == pytest.approx(np.mean(d3), abs=0.05)
|
|
99
|
+
assert Dep[0] == pytest.approx(np.mean(d3), abs=0.05)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@pytest.mark.parametrize("kernel", ["gaussian", "epanechnikov"])
|
|
103
|
+
def test_diffusivity_qkde(diff_universe, kernel):
|
|
104
|
+
"""Run, attributes, known-D recovery, auto bandwidth."""
|
|
105
|
+
D_perp_true, D_para_true = 0.05, 0.1
|
|
106
|
+
u = diff_universe(
|
|
107
|
+
n_atoms=400,
|
|
108
|
+
n_frames=40,
|
|
109
|
+
Lz=60.0,
|
|
110
|
+
D_perp=D_perp_true,
|
|
111
|
+
D_para=D_para_true,
|
|
112
|
+
seed=21,
|
|
113
|
+
)
|
|
114
|
+
ag = u.select_atoms("all")
|
|
115
|
+
kde = LocalDiffusivityQKDE(
|
|
116
|
+
ag,
|
|
117
|
+
dim=2,
|
|
118
|
+
n_points=60,
|
|
119
|
+
bandwidth="auto",
|
|
120
|
+
kernel=kernel,
|
|
121
|
+
)
|
|
122
|
+
kde.run()
|
|
123
|
+
assert kde.D_perp.shape == (60,)
|
|
124
|
+
assert kde.D_para.shape == (60,)
|
|
125
|
+
assert kde.n_increments == 400 * 39
|
|
126
|
+
assert kde.dt == pytest.approx(1.0)
|
|
127
|
+
assert np.all(kde.D_perp >= 0)
|
|
128
|
+
bulk = (kde.z_eval > 20) & (kde.z_eval < 40)
|
|
129
|
+
valid = kde.n_eff_perp > 5
|
|
130
|
+
assert np.median(kde.D_perp[bulk & valid]) == pytest.approx(
|
|
131
|
+
D_perp_true, rel=0.3
|
|
132
|
+
)
|
|
133
|
+
assert np.median(kde.D_para[bulk & valid]) == pytest.approx(
|
|
134
|
+
D_para_true, rel=0.3
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@pytest.mark.parametrize(
|
|
139
|
+
"kwargs", [{"dim": 5}, {"bandwidth": "bogus"}, {"kernel": "tri"}]
|
|
140
|
+
)
|
|
141
|
+
def test_diffusivity_qkde_validation(diff_universe, kwargs):
|
|
142
|
+
u = diff_universe(n_atoms=10, n_frames=3, Lz=10.0)
|
|
143
|
+
with pytest.raises(ValueError):
|
|
144
|
+
LocalDiffusivityQKDE(u.select_atoms("all"), **kwargs)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_diffusivity_qkde_single_frame_raises(diff_universe):
|
|
148
|
+
u = diff_universe(n_atoms=10, n_frames=1, Lz=10.0)
|
|
149
|
+
kde = LocalDiffusivityQKDE(
|
|
150
|
+
u.select_atoms("all"), n_points=10, bandwidth=0.1
|
|
151
|
+
)
|
|
152
|
+
with pytest.raises(ValueError):
|
|
153
|
+
kde.run()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_diffusivity_qkde_explicit_dt(diff_universe):
|
|
157
|
+
u = diff_universe(n_atoms=50, n_frames=5, Lz=40.0, seed=24)
|
|
158
|
+
kde = LocalDiffusivityQKDE(
|
|
159
|
+
u.select_atoms("all"), n_points=20, bandwidth=0.1, dt=2.0
|
|
160
|
+
)
|
|
161
|
+
kde.run()
|
|
162
|
+
assert kde.dt == pytest.approx(2.0)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@pytest.mark.parametrize("ito", [False, True])
|
|
166
|
+
def test_ito_correction(diff_universe, ito):
|
|
167
|
+
"""Default-off (None), on (finite non-neg array), reduces D_perp,
|
|
168
|
+
no effect on D_para, recovers known D, zero bias for uniform density."""
|
|
169
|
+
u = diff_universe(n_atoms=200, n_frames=20, Lz=60.0, seed=32)
|
|
170
|
+
ag = u.select_atoms("all")
|
|
171
|
+
kde = LocalDiffusivityQKDE(
|
|
172
|
+
ag,
|
|
173
|
+
dim=2,
|
|
174
|
+
n_points=40,
|
|
175
|
+
bandwidth=0.08,
|
|
176
|
+
ito_correction=ito,
|
|
177
|
+
)
|
|
178
|
+
kde.run()
|
|
179
|
+
if not ito:
|
|
180
|
+
assert kde.ito_bias is None
|
|
181
|
+
return
|
|
182
|
+
assert kde.ito_bias is not None
|
|
183
|
+
assert np.all(kde.ito_bias >= 0)
|
|
184
|
+
# Compare with uncorrected.
|
|
185
|
+
kde_unc = LocalDiffusivityQKDE(ag, dim=2, n_points=40, bandwidth=0.08)
|
|
186
|
+
kde_unc.run()
|
|
187
|
+
assert np.all(kde.D_perp <= kde_unc.D_perp + 1e-12)
|
|
188
|
+
assert np.allclose(kde.D_para, kde_unc.D_para)
|
|
189
|
+
# Bulk bias should be tiny (uniform density -> rho' ~ 0).
|
|
190
|
+
bulk = (kde.z_eval > 20) & (kde.z_eval < 40)
|
|
191
|
+
assert np.all(kde.ito_bias[bulk] < 1e-3)
|
|
192
|
+
# Still recovers known D in bulk.
|
|
193
|
+
u2 = diff_universe(n_atoms=400, n_frames=40, Lz=60.0, D_perp=0.05, seed=35)
|
|
194
|
+
kde2 = LocalDiffusivityQKDE(
|
|
195
|
+
u2.select_atoms("all"),
|
|
196
|
+
n_points=60,
|
|
197
|
+
bandwidth=0.08,
|
|
198
|
+
ito_correction=True,
|
|
199
|
+
)
|
|
200
|
+
kde2.run()
|
|
201
|
+
bulk2 = (kde2.z_eval > 20) & (kde2.z_eval < 40)
|
|
202
|
+
valid2 = kde2.n_eff_perp > 5
|
|
203
|
+
assert np.median(kde2.D_perp[bulk2 & valid2]) == pytest.approx(
|
|
204
|
+
0.05, rel=0.3
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def test_package_exports():
|
|
209
|
+
"""All public names are importable from qdiffusivity."""
|
|
210
|
+
for name in (
|
|
211
|
+
"TransverseDensityQKDE",
|
|
212
|
+
"TransverseDensityQBinned",
|
|
213
|
+
"LocalDiffusivityQKDE",
|
|
214
|
+
"LocalDiffusivityQBinned",
|
|
215
|
+
"build_cdf",
|
|
216
|
+
"cic_assign",
|
|
217
|
+
"epanechnikov_kernel",
|
|
218
|
+
"gaussian_kernel",
|
|
219
|
+
"kde_1d",
|
|
220
|
+
"kde_estimate",
|
|
221
|
+
"resolve_bins",
|
|
222
|
+
"select_bandwidth",
|
|
223
|
+
"select_diff_bandwidth",
|
|
224
|
+
"sheather_jones_bw",
|
|
225
|
+
"silverman_bw",
|
|
226
|
+
):
|
|
227
|
+
assert hasattr(qdiffusivity, name)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from typing import Tuple
|
|
2
|
+
|
|
3
|
+
import MDAnalysis as mda
|
|
4
|
+
import numpy as np
|
|
5
|
+
from MDAnalysis.coordinates.memory import MemoryReader
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def make_Universe(
|
|
9
|
+
extras: Tuple[str] = tuple(),
|
|
10
|
+
size: Tuple[int, int, int] = (125, 25, 5),
|
|
11
|
+
n_frames: int = 0,
|
|
12
|
+
velocities: bool = False,
|
|
13
|
+
forces: bool = False
|
|
14
|
+
) -> mda.Universe:
|
|
15
|
+
"""Make a dummy reference Universe
|
|
16
|
+
|
|
17
|
+
Allows the construction of arbitrary-sized Universes. Suitable for
|
|
18
|
+
the generation of structures for output.
|
|
19
|
+
|
|
20
|
+
Preferable for testing core components because:
|
|
21
|
+
* minimises dependencies within the package
|
|
22
|
+
* very fast compared to a "real" Universe
|
|
23
|
+
|
|
24
|
+
Parameters
|
|
25
|
+
----------
|
|
26
|
+
extras : tuple of strings, optional
|
|
27
|
+
extra attributes to add to Universe:
|
|
28
|
+
u = make_Universe(('masses', 'charges'))
|
|
29
|
+
Creates a lightweight Universe with only masses and charges.
|
|
30
|
+
size : tuple of int, optional
|
|
31
|
+
number of elements of the Universe (n_atoms, n_residues, n_segments)
|
|
32
|
+
n_frames : int
|
|
33
|
+
If positive, create a fake Reader object attached to Universe
|
|
34
|
+
velocities : bool, optional
|
|
35
|
+
if the fake Reader provides velocities
|
|
36
|
+
force : bool, optional
|
|
37
|
+
if the fake Reader provides forces
|
|
38
|
+
|
|
39
|
+
Returns
|
|
40
|
+
-------
|
|
41
|
+
MDAnalysis.core.universe.Universe object
|
|
42
|
+
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
n_atoms, n_residues, n_segments = size
|
|
46
|
+
trajectory = n_frames > 0
|
|
47
|
+
u = mda.Universe.empty(
|
|
48
|
+
# topology things
|
|
49
|
+
n_atoms=n_atoms,
|
|
50
|
+
n_residues=n_residues,
|
|
51
|
+
n_segments=n_segments,
|
|
52
|
+
atom_resindex=np.repeat(
|
|
53
|
+
np.arange(n_residues), n_atoms // n_residues),
|
|
54
|
+
residue_segindex=np.repeat(
|
|
55
|
+
np.arange(n_segments), n_residues // n_segments),
|
|
56
|
+
# trajectory things
|
|
57
|
+
trajectory=trajectory,
|
|
58
|
+
velocities=velocities,
|
|
59
|
+
forces=forces,
|
|
60
|
+
)
|
|
61
|
+
if extras is None:
|
|
62
|
+
extras = []
|
|
63
|
+
for ex in extras:
|
|
64
|
+
u.add_TopologyAttr(ex)
|
|
65
|
+
|
|
66
|
+
if trajectory:
|
|
67
|
+
pos = np.arange(3 * n_atoms * n_frames).reshape(n_frames, n_atoms, 3)
|
|
68
|
+
vel = pos + 100 if velocities else None
|
|
69
|
+
fcs = pos + 10000 if forces else None
|
|
70
|
+
reader = MemoryReader(
|
|
71
|
+
pos,
|
|
72
|
+
velocities=vel,
|
|
73
|
+
forces=fcs,
|
|
74
|
+
)
|
|
75
|
+
u.trajectory = reader
|
|
76
|
+
|
|
77
|
+
return u
|