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,3 @@
1
+ def example_function(number: float) -> float:
2
+ """Return `2*number`. This is and example for tests."""
3
+ return 2 * number
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,8 @@
1
+ """Smoke test: the package imports and exposes its version."""
2
+
3
+ import qdiffusivity
4
+
5
+
6
+ def test_version():
7
+ assert isinstance(qdiffusivity.__version__, str)
8
+ assert len(qdiffusivity.__version__) > 0
@@ -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