AtomVoxelizer 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.
- atomvoxelizer/__init__.py +30 -0
- atomvoxelizer/analysis.py +207 -0
- atomvoxelizer/cupy_backend.py +88 -0
- atomvoxelizer/numba_backend.py +135 -0
- atomvoxelizer/taichi_backend.py +157 -0
- atomvoxelizer/voxelgrid.py +308 -0
- atomvoxelizer-0.1.0.dist-info/METADATA +174 -0
- atomvoxelizer-0.1.0.dist-info/RECORD +10 -0
- atomvoxelizer-0.1.0.dist-info/WHEEL +5 -0
- atomvoxelizer-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""AtomVoxelizer public API."""
|
|
2
|
+
|
|
3
|
+
from .voxelgrid import VoxelGrid, VoxelGridNumPy
|
|
4
|
+
from .analysis import VoxelGridAnalysis, VoxelRegion
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"VoxelGrid",
|
|
8
|
+
"VoxelGridAnalysis",
|
|
9
|
+
"VoxelGridCuPy",
|
|
10
|
+
"VoxelGridNumPy",
|
|
11
|
+
"VoxelGridNumba",
|
|
12
|
+
"VoxelGridTaichi",
|
|
13
|
+
"VoxelRegion",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def __getattr__(name):
|
|
18
|
+
if name == "VoxelGridCuPy":
|
|
19
|
+
from .cupy_backend import VoxelGridCuPy
|
|
20
|
+
|
|
21
|
+
return VoxelGridCuPy
|
|
22
|
+
if name == "VoxelGridNumba":
|
|
23
|
+
from .numba_backend import VoxelGridNumba
|
|
24
|
+
|
|
25
|
+
return VoxelGridNumba
|
|
26
|
+
if name == "VoxelGridTaichi":
|
|
27
|
+
from .taichi_backend import VoxelGridTaichi
|
|
28
|
+
|
|
29
|
+
return VoxelGridTaichi
|
|
30
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class VoxelRegion:
|
|
10
|
+
"""Summary of one connected voxel region."""
|
|
11
|
+
|
|
12
|
+
label: int
|
|
13
|
+
voxel_count: int
|
|
14
|
+
volume: float
|
|
15
|
+
surface_area: float
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class VoxelGridAnalysis:
|
|
19
|
+
"""Analyze connected voxel volumes and their surfaces."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, voxel_grid):
|
|
22
|
+
self.voxel_grid = voxel_grid
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def grid(self):
|
|
26
|
+
return self.voxel_grid.to_numpy()
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def cell(self):
|
|
30
|
+
return self.voxel_grid.cell
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def gpts(self):
|
|
34
|
+
return self.voxel_grid.gpts
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def voxel_volume(self):
|
|
38
|
+
return abs(float(np.linalg.det(self.cell))) / float(np.prod(self.gpts))
|
|
39
|
+
|
|
40
|
+
def mask(self, min_value=None, max_value=None, threshold=None, above=True):
|
|
41
|
+
"""Build a boolean mask from voxel values."""
|
|
42
|
+
if threshold is not None and (min_value is not None or max_value is not None):
|
|
43
|
+
raise ValueError("Specify either threshold or min_value/max_value, not both")
|
|
44
|
+
|
|
45
|
+
grid = self.grid
|
|
46
|
+
if threshold is not None:
|
|
47
|
+
return grid > threshold if above else grid < threshold
|
|
48
|
+
|
|
49
|
+
selected = np.ones(grid.shape, dtype=bool)
|
|
50
|
+
if min_value is not None:
|
|
51
|
+
selected &= grid >= min_value
|
|
52
|
+
if max_value is not None:
|
|
53
|
+
selected &= grid <= max_value
|
|
54
|
+
return selected
|
|
55
|
+
|
|
56
|
+
def connected_components(self, selected, connectivity=1, periodic=True):
|
|
57
|
+
"""Label connected components in a boolean mask."""
|
|
58
|
+
try:
|
|
59
|
+
from skimage.measure import label
|
|
60
|
+
except ImportError as exc: # pragma: no cover - depends on optional dependency
|
|
61
|
+
raise ImportError(
|
|
62
|
+
"VoxelGridAnalysis requires scikit-image. Install it with "
|
|
63
|
+
"`pip install AtomVoxelizer[analysis]`."
|
|
64
|
+
) from exc
|
|
65
|
+
|
|
66
|
+
selected = np.asarray(selected, dtype=bool)
|
|
67
|
+
labels = label(selected, connectivity=connectivity)
|
|
68
|
+
if periodic:
|
|
69
|
+
labels = self._merge_periodic_labels(labels, selected)
|
|
70
|
+
return labels, int(labels.max())
|
|
71
|
+
|
|
72
|
+
def region_volume(self, selected):
|
|
73
|
+
"""Return the volume represented by a boolean mask."""
|
|
74
|
+
return int(np.count_nonzero(selected)) * self.voxel_volume
|
|
75
|
+
|
|
76
|
+
def surface_area(self, selected, periodic=True):
|
|
77
|
+
"""Estimate the surface area of a boolean region with marching cubes."""
|
|
78
|
+
try:
|
|
79
|
+
from skimage.measure import marching_cubes
|
|
80
|
+
except ImportError as exc: # pragma: no cover - depends on optional dependency
|
|
81
|
+
raise ImportError(
|
|
82
|
+
"VoxelGridAnalysis requires scikit-image. Install it with "
|
|
83
|
+
"`pip install AtomVoxelizer[analysis]`."
|
|
84
|
+
) from exc
|
|
85
|
+
|
|
86
|
+
selected = np.asarray(selected, dtype=bool)
|
|
87
|
+
if not np.any(selected):
|
|
88
|
+
return 0.0
|
|
89
|
+
if periodic and np.all(selected):
|
|
90
|
+
return 0.0
|
|
91
|
+
|
|
92
|
+
if periodic:
|
|
93
|
+
values = np.tile(selected.astype(np.float32), (3, 3, 3))
|
|
94
|
+
vertices, faces, _normals, _values = marching_cubes(values, level=0.5)
|
|
95
|
+
central_offset = self.gpts
|
|
96
|
+
centroids = vertices[faces].mean(axis=1)
|
|
97
|
+
in_central_cell = np.all((centroids >= central_offset) & (centroids < 2 * central_offset), axis=1)
|
|
98
|
+
faces = faces[in_central_cell]
|
|
99
|
+
vertices = vertices - central_offset
|
|
100
|
+
else:
|
|
101
|
+
values = np.pad(selected.astype(np.float32), 1, mode="constant", constant_values=0.0)
|
|
102
|
+
vertices, faces, _normals, _values = marching_cubes(values, level=0.5)
|
|
103
|
+
vertices = vertices - 1.0
|
|
104
|
+
|
|
105
|
+
if faces.size == 0:
|
|
106
|
+
return 0.0
|
|
107
|
+
|
|
108
|
+
real_vertices = self._index_vertices_to_real(vertices)
|
|
109
|
+
return self._mesh_surface_area(real_vertices, faces)
|
|
110
|
+
|
|
111
|
+
def analyze_regions(
|
|
112
|
+
self,
|
|
113
|
+
min_value=None,
|
|
114
|
+
max_value=None,
|
|
115
|
+
threshold=None,
|
|
116
|
+
above=True,
|
|
117
|
+
connectivity=1,
|
|
118
|
+
periodic=True,
|
|
119
|
+
):
|
|
120
|
+
"""Return volume and marching-cubes area for each connected region."""
|
|
121
|
+
selected = self.mask(min_value=min_value, max_value=max_value, threshold=threshold, above=above)
|
|
122
|
+
labels, label_count = self.connected_components(selected, connectivity=connectivity, periodic=periodic)
|
|
123
|
+
|
|
124
|
+
regions = []
|
|
125
|
+
for label_id in range(1, label_count + 1):
|
|
126
|
+
region_mask = labels == label_id
|
|
127
|
+
voxel_count = int(np.count_nonzero(region_mask))
|
|
128
|
+
regions.append(
|
|
129
|
+
VoxelRegion(
|
|
130
|
+
label=label_id,
|
|
131
|
+
voxel_count=voxel_count,
|
|
132
|
+
volume=voxel_count * self.voxel_volume,
|
|
133
|
+
surface_area=self.surface_area(region_mask, periodic=periodic),
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
return regions
|
|
137
|
+
|
|
138
|
+
@staticmethod
|
|
139
|
+
def volume_angstrom3_to_cm3_per_g(volume_angstrom3, mass_amu):
|
|
140
|
+
"""Convert a cell/supercell volume from Angstrom^3 to cm^3/g."""
|
|
141
|
+
if mass_amu <= 0:
|
|
142
|
+
raise ValueError("mass_amu must be positive")
|
|
143
|
+
mass_g = float(mass_amu) * 1.66053906660e-24
|
|
144
|
+
return float(volume_angstrom3) * 1.0e-24 / mass_g
|
|
145
|
+
|
|
146
|
+
@staticmethod
|
|
147
|
+
def area_angstrom2_to_m2_per_g(area_angstrom2, mass_amu):
|
|
148
|
+
"""Convert a cell/supercell area from Angstrom^2 to m^2/g."""
|
|
149
|
+
if mass_amu <= 0:
|
|
150
|
+
raise ValueError("mass_amu must be positive")
|
|
151
|
+
mass_g = float(mass_amu) * 1.66053906660e-24
|
|
152
|
+
return float(area_angstrom2) * 1.0e-20 / mass_g
|
|
153
|
+
|
|
154
|
+
def _index_vertices_to_real(self, vertices):
|
|
155
|
+
frac = vertices / self.gpts
|
|
156
|
+
return frac @ self.cell
|
|
157
|
+
|
|
158
|
+
@staticmethod
|
|
159
|
+
def _merge_periodic_labels(labels, selected):
|
|
160
|
+
label_count = int(labels.max())
|
|
161
|
+
if label_count == 0:
|
|
162
|
+
return labels
|
|
163
|
+
|
|
164
|
+
parent = np.arange(label_count + 1)
|
|
165
|
+
|
|
166
|
+
def find(label_id):
|
|
167
|
+
while parent[label_id] != label_id:
|
|
168
|
+
parent[label_id] = parent[parent[label_id]]
|
|
169
|
+
label_id = parent[label_id]
|
|
170
|
+
return label_id
|
|
171
|
+
|
|
172
|
+
def union(a, b):
|
|
173
|
+
if a == 0 or b == 0:
|
|
174
|
+
return
|
|
175
|
+
root_a = find(int(a))
|
|
176
|
+
root_b = find(int(b))
|
|
177
|
+
if root_a != root_b:
|
|
178
|
+
parent[root_b] = root_a
|
|
179
|
+
|
|
180
|
+
for axis in range(labels.ndim):
|
|
181
|
+
first_labels = np.take(labels, 0, axis=axis)
|
|
182
|
+
last_labels = np.take(labels, -1, axis=axis)
|
|
183
|
+
first_selected = np.take(selected, 0, axis=axis)
|
|
184
|
+
last_selected = np.take(selected, -1, axis=axis)
|
|
185
|
+
for a, b in zip(first_labels[first_selected & last_selected], last_labels[first_selected & last_selected]):
|
|
186
|
+
union(a, b)
|
|
187
|
+
|
|
188
|
+
root_to_new_label = {}
|
|
189
|
+
next_label = 1
|
|
190
|
+
merged = np.zeros_like(labels)
|
|
191
|
+
for label_id in range(1, label_count + 1):
|
|
192
|
+
root = find(label_id)
|
|
193
|
+
if root not in root_to_new_label:
|
|
194
|
+
root_to_new_label[root] = next_label
|
|
195
|
+
next_label += 1
|
|
196
|
+
merged[labels == label_id] = root_to_new_label[root]
|
|
197
|
+
|
|
198
|
+
return merged
|
|
199
|
+
|
|
200
|
+
@staticmethod
|
|
201
|
+
def _mesh_surface_area(vertices, faces):
|
|
202
|
+
triangles = vertices[faces]
|
|
203
|
+
cross = np.cross(triangles[:, 1] - triangles[:, 0], triangles[:, 2] - triangles[:, 0])
|
|
204
|
+
return float(0.5 * np.linalg.norm(cross, axis=1).sum())
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
__all__ = ["VoxelGridAnalysis", "VoxelRegion"]
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
from .voxelgrid import VoxelGrid, _cached_sphere_offsets
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
from .numba_backend import VoxelGridNumba as _BaseVoxelGrid
|
|
9
|
+
except ImportError: # pragma: no cover - depends on optional dependency
|
|
10
|
+
_BaseVoxelGrid = VoxelGrid
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
import cupy as cp
|
|
14
|
+
except ImportError as exc: # pragma: no cover - depends on optional dependency
|
|
15
|
+
raise ImportError(
|
|
16
|
+
"VoxelGridCuPy requires CuPy. Install it with `pip install AtomVoxelizer[cupy]`."
|
|
17
|
+
) from exc
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class VoxelGridCuPy(_BaseVoxelGrid):
|
|
21
|
+
"""CuPy-backed voxel grid.
|
|
22
|
+
|
|
23
|
+
The class keeps the same public API as :class:`atomvoxelizer.VoxelGrid`, uses
|
|
24
|
+
the Numba backend as its base when available, stores ``grid`` as a CuPy array,
|
|
25
|
+
and overrides the mutating sphere operations.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def backend_name(self):
|
|
30
|
+
return "cupy"
|
|
31
|
+
|
|
32
|
+
def __init__(self, cell, resolution=None, gpts=None):
|
|
33
|
+
super().__init__(cell=cell, resolution=resolution, gpts=gpts)
|
|
34
|
+
self.grid = cp.asarray(self.grid)
|
|
35
|
+
|
|
36
|
+
def to_numpy(self):
|
|
37
|
+
"""Return the voxel values as a NumPy array."""
|
|
38
|
+
return cp.asnumpy(self.grid)
|
|
39
|
+
|
|
40
|
+
def _sphere_indices(self, center, radius):
|
|
41
|
+
center_frac = np.asarray(center, dtype=np.float64) @ self.cell_inv % 1.0
|
|
42
|
+
center_idx = np.floor(center_frac * self.gpts).astype(np.int32)
|
|
43
|
+
offsets = _cached_sphere_offsets(float(radius), tuple(self.gpts), tuple(map(tuple, self.cell)))
|
|
44
|
+
indices = (offsets + center_idx) % self.gpts
|
|
45
|
+
return tuple(cp.asarray(indices[:, axis]) for axis in range(3))
|
|
46
|
+
|
|
47
|
+
def set_sphere(self, center, radius, value=1):
|
|
48
|
+
self.grid[self._sphere_indices(center, radius)] = value
|
|
49
|
+
|
|
50
|
+
def add_sphere(self, center, radius, value=1):
|
|
51
|
+
cp.add.at(self.grid, self._sphere_indices(center, radius), value)
|
|
52
|
+
|
|
53
|
+
def mul_sphere(self, center, radius, factor=2):
|
|
54
|
+
indices = self._sphere_indices(center, radius)
|
|
55
|
+
self.grid[indices] *= factor
|
|
56
|
+
|
|
57
|
+
def div_sphere(self, center, radius, factor=2):
|
|
58
|
+
indices = self._sphere_indices(center, radius)
|
|
59
|
+
self.grid[indices] /= factor
|
|
60
|
+
|
|
61
|
+
def add_spheres(self, centers, radii, value=1):
|
|
62
|
+
centers = np.asarray(centers, dtype=np.float64)
|
|
63
|
+
radii = np.asarray(radii, dtype=np.float64)
|
|
64
|
+
if centers.ndim != 2 or centers.shape[1] != 3:
|
|
65
|
+
raise ValueError("centers must have shape (N, 3)")
|
|
66
|
+
if radii.ndim != 1 or radii.shape[0] != centers.shape[0]:
|
|
67
|
+
raise ValueError("radii must have shape (N,)")
|
|
68
|
+
|
|
69
|
+
for center, radius in zip(centers, radii):
|
|
70
|
+
self.add_sphere(center, float(radius), value=value)
|
|
71
|
+
|
|
72
|
+
def set_spheres(self, centers, radii, value=1):
|
|
73
|
+
centers = np.asarray(centers, dtype=np.float64)
|
|
74
|
+
radii = np.asarray(radii, dtype=np.float64)
|
|
75
|
+
if centers.ndim != 2 or centers.shape[1] != 3:
|
|
76
|
+
raise ValueError("centers must have shape (N, 3)")
|
|
77
|
+
if radii.ndim != 1 or radii.shape[0] != centers.shape[0]:
|
|
78
|
+
raise ValueError("radii must have shape (N,)")
|
|
79
|
+
|
|
80
|
+
for center, radius in zip(centers, radii):
|
|
81
|
+
self.set_sphere(center, float(radius), value=value)
|
|
82
|
+
|
|
83
|
+
def clamp_grid(self, min_val=0.0, max_val=1.0):
|
|
84
|
+
self.grid = cp.clip(self.grid, min_val, max_val)
|
|
85
|
+
|
|
86
|
+
def synchronize(self):
|
|
87
|
+
"""Synchronize the current CuPy device."""
|
|
88
|
+
cp.cuda.Stream.null.synchronize()
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
from numba import njit, prange
|
|
5
|
+
|
|
6
|
+
from .voxelgrid import VoxelGrid, _cached_sphere_offsets
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@njit
|
|
10
|
+
def _set_sphere_offsets(grid, center_idx, offsets, value):
|
|
11
|
+
nx, ny, nz = grid.shape
|
|
12
|
+
for n in range(offsets.shape[0]):
|
|
13
|
+
x = (center_idx[0] + offsets[n, 0]) % nx
|
|
14
|
+
y = (center_idx[1] + offsets[n, 1]) % ny
|
|
15
|
+
z = (center_idx[2] + offsets[n, 2]) % nz
|
|
16
|
+
grid[x, y, z] = value
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@njit
|
|
20
|
+
def _add_sphere_offsets(grid, center_idx, offsets, value):
|
|
21
|
+
nx, ny, nz = grid.shape
|
|
22
|
+
for n in range(offsets.shape[0]):
|
|
23
|
+
x = (center_idx[0] + offsets[n, 0]) % nx
|
|
24
|
+
y = (center_idx[1] + offsets[n, 1]) % ny
|
|
25
|
+
z = (center_idx[2] + offsets[n, 2]) % nz
|
|
26
|
+
grid[x, y, z] += value
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@njit
|
|
30
|
+
def _mul_sphere_offsets(grid, center_idx, offsets, factor):
|
|
31
|
+
nx, ny, nz = grid.shape
|
|
32
|
+
for n in range(offsets.shape[0]):
|
|
33
|
+
x = (center_idx[0] + offsets[n, 0]) % nx
|
|
34
|
+
y = (center_idx[1] + offsets[n, 1]) % ny
|
|
35
|
+
z = (center_idx[2] + offsets[n, 2]) % nz
|
|
36
|
+
grid[x, y, z] *= factor
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@njit
|
|
40
|
+
def _div_sphere_offsets(grid, center_idx, offsets, divisor):
|
|
41
|
+
nx, ny, nz = grid.shape
|
|
42
|
+
for n in range(offsets.shape[0]):
|
|
43
|
+
x = (center_idx[0] + offsets[n, 0]) % nx
|
|
44
|
+
y = (center_idx[1] + offsets[n, 1]) % ny
|
|
45
|
+
z = (center_idx[2] + offsets[n, 2]) % nz
|
|
46
|
+
grid[x, y, z] /= divisor
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@njit
|
|
50
|
+
def _set_many_sphere_offsets(grid, center_indices, offsets, value):
|
|
51
|
+
nx, ny, nz = grid.shape
|
|
52
|
+
for c in range(center_indices.shape[0]):
|
|
53
|
+
cx = center_indices[c, 0]
|
|
54
|
+
cy = center_indices[c, 1]
|
|
55
|
+
cz = center_indices[c, 2]
|
|
56
|
+
for n in range(offsets.shape[0]):
|
|
57
|
+
x = (cx + offsets[n, 0]) % nx
|
|
58
|
+
y = (cy + offsets[n, 1]) % ny
|
|
59
|
+
z = (cz + offsets[n, 2]) % nz
|
|
60
|
+
grid[x, y, z] = value
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@njit
|
|
64
|
+
def _add_many_sphere_offsets(grid, center_indices, offsets, value):
|
|
65
|
+
nx, ny, nz = grid.shape
|
|
66
|
+
for c in range(center_indices.shape[0]):
|
|
67
|
+
cx = center_indices[c, 0]
|
|
68
|
+
cy = center_indices[c, 1]
|
|
69
|
+
cz = center_indices[c, 2]
|
|
70
|
+
for n in range(offsets.shape[0]):
|
|
71
|
+
x = (cx + offsets[n, 0]) % nx
|
|
72
|
+
y = (cy + offsets[n, 1]) % ny
|
|
73
|
+
z = (cz + offsets[n, 2]) % nz
|
|
74
|
+
grid[x, y, z] += value
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@njit(parallel=True)
|
|
78
|
+
def _clamp_grid(grid, min_val, max_val):
|
|
79
|
+
nx, ny, nz = grid.shape
|
|
80
|
+
for i in prange(nx):
|
|
81
|
+
for j in range(ny):
|
|
82
|
+
for k in range(nz):
|
|
83
|
+
v = grid[i, j, k]
|
|
84
|
+
if v < min_val:
|
|
85
|
+
grid[i, j, k] = min_val
|
|
86
|
+
elif v > max_val:
|
|
87
|
+
grid[i, j, k] = max_val
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class VoxelGridNumba(VoxelGrid):
|
|
91
|
+
"""Voxel grid with Numba-compiled mutation kernels."""
|
|
92
|
+
|
|
93
|
+
backend_name = "numba"
|
|
94
|
+
|
|
95
|
+
def set_sphere(self, center, radius, value=1):
|
|
96
|
+
center_idx = self._center_index(center)
|
|
97
|
+
offsets = self._sphere_offsets(radius)
|
|
98
|
+
_set_sphere_offsets(self.grid, center_idx, offsets, value)
|
|
99
|
+
|
|
100
|
+
def add_sphere(self, center, radius, value=1):
|
|
101
|
+
center_idx = self._center_index(center)
|
|
102
|
+
offsets = self._sphere_offsets(radius)
|
|
103
|
+
_add_sphere_offsets(self.grid, center_idx, offsets, value)
|
|
104
|
+
|
|
105
|
+
def mul_sphere(self, center, radius, factor=2):
|
|
106
|
+
center_idx = self._center_index(center)
|
|
107
|
+
offsets = self._sphere_offsets(radius)
|
|
108
|
+
_mul_sphere_offsets(self.grid, center_idx, offsets, factor)
|
|
109
|
+
|
|
110
|
+
def div_sphere(self, center, radius, factor=2):
|
|
111
|
+
center_idx = self._center_index(center)
|
|
112
|
+
offsets = self._sphere_offsets(radius)
|
|
113
|
+
_div_sphere_offsets(self.grid, center_idx, offsets, factor)
|
|
114
|
+
|
|
115
|
+
def add_spheres(self, centers, radii, value=1):
|
|
116
|
+
centers, radii = self._validate_spheres(centers, radii)
|
|
117
|
+
center_indices = self.positions_to_indices(centers)
|
|
118
|
+
for radius in np.unique(radii):
|
|
119
|
+
offsets = _cached_sphere_offsets(float(radius), tuple(self.gpts), tuple(map(tuple, self.cell)))
|
|
120
|
+
subset = center_indices[radii == radius]
|
|
121
|
+
_add_many_sphere_offsets(self.grid, subset, offsets, value)
|
|
122
|
+
|
|
123
|
+
def set_spheres(self, centers, radii, value=1):
|
|
124
|
+
centers, radii = self._validate_spheres(centers, radii)
|
|
125
|
+
center_indices = self.positions_to_indices(centers)
|
|
126
|
+
for radius in np.unique(radii):
|
|
127
|
+
offsets = _cached_sphere_offsets(float(radius), tuple(self.gpts), tuple(map(tuple, self.cell)))
|
|
128
|
+
subset = center_indices[radii == radius]
|
|
129
|
+
_set_many_sphere_offsets(self.grid, subset, offsets, value)
|
|
130
|
+
|
|
131
|
+
def clamp_grid(self, min_val=0.0, max_val=1.0):
|
|
132
|
+
_clamp_grid(self.grid, min_val, max_val)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
__all__ = ["VoxelGridNumba"]
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
|
|
6
|
+
from .voxelgrid import VoxelGrid, _cached_sphere_offsets
|
|
7
|
+
|
|
8
|
+
_default_cache = Path.home() / ".cache"
|
|
9
|
+
if not os.access(_default_cache, os.W_OK):
|
|
10
|
+
os.environ.setdefault("XDG_CACHE_HOME", "/tmp/.cache")
|
|
11
|
+
os.environ.setdefault("TI_CACHE_HOME", "/tmp/taichi-cache")
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
import taichi as ti
|
|
15
|
+
except ImportError as exc: # pragma: no cover - depends on optional dependency
|
|
16
|
+
raise ImportError(
|
|
17
|
+
"VoxelGridTaichi requires Taichi. Install it with `pip install AtomVoxelizer[taichi]`."
|
|
18
|
+
) from exc
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _init_taichi_cpu_once():
|
|
22
|
+
try:
|
|
23
|
+
runtime = ti.lang.impl.get_runtime()
|
|
24
|
+
if runtime.prog is not None:
|
|
25
|
+
return
|
|
26
|
+
except Exception:
|
|
27
|
+
pass
|
|
28
|
+
ti.init(arch=ti.cpu, offline_cache=False)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
_init_taichi_cpu_once()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@ti.kernel
|
|
35
|
+
def _set_sphere_offsets(
|
|
36
|
+
grid: ti.template(),
|
|
37
|
+
center_idx: ti.types.ndarray(dtype=ti.i32, ndim=1),
|
|
38
|
+
offsets: ti.types.ndarray(dtype=ti.i32, ndim=2),
|
|
39
|
+
value: ti.f32,
|
|
40
|
+
):
|
|
41
|
+
for n in range(offsets.shape[0]):
|
|
42
|
+
x = (center_idx[0] + offsets[n, 0]) % grid.shape[0]
|
|
43
|
+
y = (center_idx[1] + offsets[n, 1]) % grid.shape[1]
|
|
44
|
+
z = (center_idx[2] + offsets[n, 2]) % grid.shape[2]
|
|
45
|
+
grid[x, y, z] = value
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@ti.kernel
|
|
49
|
+
def _add_sphere_offsets(
|
|
50
|
+
grid: ti.template(),
|
|
51
|
+
center_idx: ti.types.ndarray(dtype=ti.i32, ndim=1),
|
|
52
|
+
offsets: ti.types.ndarray(dtype=ti.i32, ndim=2),
|
|
53
|
+
value: ti.f32,
|
|
54
|
+
):
|
|
55
|
+
for n in range(offsets.shape[0]):
|
|
56
|
+
x = (center_idx[0] + offsets[n, 0]) % grid.shape[0]
|
|
57
|
+
y = (center_idx[1] + offsets[n, 1]) % grid.shape[1]
|
|
58
|
+
z = (center_idx[2] + offsets[n, 2]) % grid.shape[2]
|
|
59
|
+
grid[x, y, z] += value
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@ti.kernel
|
|
63
|
+
def _mul_sphere_offsets(
|
|
64
|
+
grid: ti.template(),
|
|
65
|
+
center_idx: ti.types.ndarray(dtype=ti.i32, ndim=1),
|
|
66
|
+
offsets: ti.types.ndarray(dtype=ti.i32, ndim=2),
|
|
67
|
+
factor: ti.f32,
|
|
68
|
+
):
|
|
69
|
+
for n in range(offsets.shape[0]):
|
|
70
|
+
x = (center_idx[0] + offsets[n, 0]) % grid.shape[0]
|
|
71
|
+
y = (center_idx[1] + offsets[n, 1]) % grid.shape[1]
|
|
72
|
+
z = (center_idx[2] + offsets[n, 2]) % grid.shape[2]
|
|
73
|
+
grid[x, y, z] *= factor
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@ti.kernel
|
|
77
|
+
def _div_sphere_offsets(
|
|
78
|
+
grid: ti.template(),
|
|
79
|
+
center_idx: ti.types.ndarray(dtype=ti.i32, ndim=1),
|
|
80
|
+
offsets: ti.types.ndarray(dtype=ti.i32, ndim=2),
|
|
81
|
+
divisor: ti.f32,
|
|
82
|
+
):
|
|
83
|
+
for n in range(offsets.shape[0]):
|
|
84
|
+
x = (center_idx[0] + offsets[n, 0]) % grid.shape[0]
|
|
85
|
+
y = (center_idx[1] + offsets[n, 1]) % grid.shape[1]
|
|
86
|
+
z = (center_idx[2] + offsets[n, 2]) % grid.shape[2]
|
|
87
|
+
grid[x, y, z] /= divisor
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@ti.kernel
|
|
91
|
+
def _clamp_grid(grid: ti.template(), min_val: ti.f32, max_val: ti.f32):
|
|
92
|
+
for i, j, k in ti.ndrange(grid.shape[0], grid.shape[1], grid.shape[2]):
|
|
93
|
+
value = grid[i, j, k]
|
|
94
|
+
if value < min_val:
|
|
95
|
+
grid[i, j, k] = min_val
|
|
96
|
+
elif value > max_val:
|
|
97
|
+
grid[i, j, k] = max_val
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class VoxelGridTaichi(VoxelGrid):
|
|
101
|
+
"""Voxel grid with Taichi CPU kernels for mutating sphere operations."""
|
|
102
|
+
|
|
103
|
+
backend_name = "taichi-cpu"
|
|
104
|
+
|
|
105
|
+
def __init__(self, cell, resolution=None, gpts=None):
|
|
106
|
+
super().__init__(cell=cell, resolution=resolution, gpts=gpts)
|
|
107
|
+
self.grid = ti.field(dtype=ti.f32, shape=tuple(int(x) for x in self.gpts))
|
|
108
|
+
|
|
109
|
+
def to_numpy(self):
|
|
110
|
+
"""Return the voxel values as a NumPy array."""
|
|
111
|
+
return self.grid.to_numpy()
|
|
112
|
+
|
|
113
|
+
def set_sphere(self, center, radius, value=1):
|
|
114
|
+
center_idx = self._center_index(center)
|
|
115
|
+
offsets = self._sphere_offsets(radius)
|
|
116
|
+
_set_sphere_offsets(self.grid, center_idx, offsets, float(value))
|
|
117
|
+
|
|
118
|
+
def add_sphere(self, center, radius, value=1):
|
|
119
|
+
center_idx = self._center_index(center)
|
|
120
|
+
offsets = self._sphere_offsets(radius)
|
|
121
|
+
_add_sphere_offsets(self.grid, center_idx, offsets, float(value))
|
|
122
|
+
|
|
123
|
+
def mul_sphere(self, center, radius, factor=2):
|
|
124
|
+
center_idx = self._center_index(center)
|
|
125
|
+
offsets = self._sphere_offsets(radius)
|
|
126
|
+
_mul_sphere_offsets(self.grid, center_idx, offsets, float(factor))
|
|
127
|
+
|
|
128
|
+
def div_sphere(self, center, radius, factor=2):
|
|
129
|
+
center_idx = self._center_index(center)
|
|
130
|
+
offsets = self._sphere_offsets(radius)
|
|
131
|
+
_div_sphere_offsets(self.grid, center_idx, offsets, float(factor))
|
|
132
|
+
|
|
133
|
+
def add_spheres(self, centers, radii, value=1):
|
|
134
|
+
centers, radii = self._validate_spheres(centers, radii)
|
|
135
|
+
center_indices = self.positions_to_indices(centers)
|
|
136
|
+
for radius in np.unique(radii):
|
|
137
|
+
offsets = _cached_sphere_offsets(float(radius), tuple(self.gpts), tuple(map(tuple, self.cell)))
|
|
138
|
+
for center_idx in center_indices[radii == radius]:
|
|
139
|
+
_add_sphere_offsets(self.grid, center_idx, offsets, float(value))
|
|
140
|
+
|
|
141
|
+
def set_spheres(self, centers, radii, value=1):
|
|
142
|
+
centers, radii = self._validate_spheres(centers, radii)
|
|
143
|
+
center_indices = self.positions_to_indices(centers)
|
|
144
|
+
for radius in np.unique(radii):
|
|
145
|
+
offsets = _cached_sphere_offsets(float(radius), tuple(self.gpts), tuple(map(tuple, self.cell)))
|
|
146
|
+
for center_idx in center_indices[radii == radius]:
|
|
147
|
+
_set_sphere_offsets(self.grid, center_idx, offsets, float(value))
|
|
148
|
+
|
|
149
|
+
def clamp_grid(self, min_val=0.0, max_val=1.0):
|
|
150
|
+
_clamp_grid(self.grid, float(min_val), float(max_val))
|
|
151
|
+
|
|
152
|
+
def synchronize(self):
|
|
153
|
+
"""Synchronize Taichi kernels."""
|
|
154
|
+
ti.sync()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
__all__ = ["VoxelGridTaichi"]
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from functools import lru_cache
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@lru_cache(maxsize=50)
|
|
9
|
+
def _cached_sphere_mask(radius, gpts, cell):
|
|
10
|
+
nx, ny, nz = gpts
|
|
11
|
+
ix, iy, iz = np.meshgrid(
|
|
12
|
+
np.arange(nx),
|
|
13
|
+
np.arange(ny),
|
|
14
|
+
np.arange(nz),
|
|
15
|
+
indexing="ij",
|
|
16
|
+
)
|
|
17
|
+
frac_coords = (np.stack([ix, iy, iz], axis=-1) + 0.5) / gpts
|
|
18
|
+
center_frac = np.array([0.5, 0.5, 0.5])
|
|
19
|
+
disp_frac = frac_coords - center_frac
|
|
20
|
+
disp_frac -= np.round(disp_frac)
|
|
21
|
+
disp_mic = disp_frac @ cell
|
|
22
|
+
dist2 = np.sum(disp_mic**2, axis=-1)
|
|
23
|
+
return dist2 <= radius**2
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@lru_cache(maxsize=200)
|
|
27
|
+
def _cached_sphere_offsets(radius, gpts, cell):
|
|
28
|
+
gpts_arr = np.array(gpts, dtype=np.int32)
|
|
29
|
+
cell_arr = np.array(cell, dtype=np.float64)
|
|
30
|
+
lengths = np.linalg.norm(cell_arr, axis=1)
|
|
31
|
+
max_offsets = np.ceil(radius / lengths * gpts_arr).astype(np.int32)
|
|
32
|
+
|
|
33
|
+
offsets = []
|
|
34
|
+
for dx in range(-max_offsets[0], max_offsets[0] + 1):
|
|
35
|
+
for dy in range(-max_offsets[1], max_offsets[1] + 1):
|
|
36
|
+
for dz in range(-max_offsets[2], max_offsets[2] + 1):
|
|
37
|
+
disp_frac = np.array([dx, dy, dz], dtype=np.float64) / gpts_arr
|
|
38
|
+
disp = disp_frac @ cell_arr
|
|
39
|
+
if np.dot(disp, disp) <= radius**2:
|
|
40
|
+
offsets.append((dx, dy, dz))
|
|
41
|
+
|
|
42
|
+
return np.array(offsets, dtype=np.int32)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class VoxelGrid:
|
|
46
|
+
"""Periodic voxel grid implemented with NumPy only."""
|
|
47
|
+
|
|
48
|
+
backend_name = "numpy"
|
|
49
|
+
|
|
50
|
+
def __init__(self, cell, resolution=None, gpts=None):
|
|
51
|
+
self.cell = np.array(cell, dtype=np.float64)
|
|
52
|
+
self.cell_inv = np.linalg.inv(self.cell)
|
|
53
|
+
|
|
54
|
+
if resolution is None and gpts is None:
|
|
55
|
+
raise ValueError("Either resolution or gpts must be specified")
|
|
56
|
+
if resolution is not None and gpts is not None:
|
|
57
|
+
raise ValueError("Only one of resolution or gpts can be specified")
|
|
58
|
+
|
|
59
|
+
lengths = np.linalg.norm(self.cell, axis=1)
|
|
60
|
+
if resolution is not None:
|
|
61
|
+
self.gpts = np.ceil(lengths / resolution).astype(int)
|
|
62
|
+
else:
|
|
63
|
+
self.gpts = np.array(gpts, dtype=int)
|
|
64
|
+
self.resolution = lengths / self.gpts
|
|
65
|
+
|
|
66
|
+
self.grid = np.zeros(tuple(self.gpts), dtype=np.float32)
|
|
67
|
+
|
|
68
|
+
def to_numpy(self):
|
|
69
|
+
"""Return the voxel values as a NumPy array."""
|
|
70
|
+
return self.grid
|
|
71
|
+
|
|
72
|
+
def position_to_index(self, r):
|
|
73
|
+
"""Convert real-space position to voxel index using periodic wrapping."""
|
|
74
|
+
frac = np.asarray(r, dtype=np.float64) @ self.cell_inv
|
|
75
|
+
frac_wrapped = np.clip(frac % 1.0, 0.0, np.nextafter(1.0, 0.0))
|
|
76
|
+
idx = np.floor(frac_wrapped * self.gpts).astype(int)
|
|
77
|
+
return tuple(idx)
|
|
78
|
+
|
|
79
|
+
def index_to_position(self, i, j, k):
|
|
80
|
+
"""Convert a grid index to the real-space voxel center."""
|
|
81
|
+
frac = (np.array([i, j, k]) + 0.5) / self.gpts
|
|
82
|
+
return frac @ self.cell
|
|
83
|
+
|
|
84
|
+
def _center_index(self, center):
|
|
85
|
+
center_frac = np.asarray(center, dtype=np.float64) @ self.cell_inv % 1.0
|
|
86
|
+
return np.floor(center_frac * self.gpts).astype(np.int32)
|
|
87
|
+
|
|
88
|
+
def _offset_indices(self, center_idx, offsets):
|
|
89
|
+
indices = (offsets + center_idx) % self.gpts
|
|
90
|
+
return tuple(indices[:, axis] for axis in range(3))
|
|
91
|
+
|
|
92
|
+
def _sphere_offsets(self, radius):
|
|
93
|
+
return _cached_sphere_offsets(float(radius), tuple(self.gpts), tuple(map(tuple, self.cell)))
|
|
94
|
+
|
|
95
|
+
def _sphere_indices(self, center, radius):
|
|
96
|
+
return self._offset_indices(self._center_index(center), self._sphere_offsets(radius))
|
|
97
|
+
|
|
98
|
+
def set_sphere(self, center, radius, value=1):
|
|
99
|
+
self.grid[self._sphere_indices(center, radius)] = value
|
|
100
|
+
|
|
101
|
+
def add_sphere(self, center, radius, value=1):
|
|
102
|
+
np.add.at(self.grid, self._sphere_indices(center, radius), value)
|
|
103
|
+
|
|
104
|
+
def mul_sphere(self, center, radius, factor=2):
|
|
105
|
+
self.grid[self._sphere_indices(center, radius)] *= factor
|
|
106
|
+
|
|
107
|
+
def div_sphere(self, center, radius, factor=2):
|
|
108
|
+
self.grid[self._sphere_indices(center, radius)] /= factor
|
|
109
|
+
|
|
110
|
+
def positions_to_indices(self, positions):
|
|
111
|
+
positions = np.asarray(positions, dtype=np.float64)
|
|
112
|
+
frac = positions @ self.cell_inv
|
|
113
|
+
frac_wrapped = np.clip(frac % 1.0, 0.0, np.nextafter(1.0, 0.0))
|
|
114
|
+
return np.floor(frac_wrapped * self.gpts).astype(np.int32)
|
|
115
|
+
|
|
116
|
+
def _validate_spheres(self, centers, radii):
|
|
117
|
+
centers = np.asarray(centers, dtype=np.float64)
|
|
118
|
+
radii = np.asarray(radii, dtype=np.float64)
|
|
119
|
+
if centers.ndim != 2 or centers.shape[1] != 3:
|
|
120
|
+
raise ValueError("centers must have shape (N, 3)")
|
|
121
|
+
if radii.ndim != 1 or radii.shape[0] != centers.shape[0]:
|
|
122
|
+
raise ValueError("radii must have shape (N,)")
|
|
123
|
+
return centers, radii
|
|
124
|
+
|
|
125
|
+
def add_spheres(self, centers, radii, value=1):
|
|
126
|
+
centers, radii = self._validate_spheres(centers, radii)
|
|
127
|
+
for center, radius in zip(centers, radii):
|
|
128
|
+
self.add_sphere(center, radius, value=value)
|
|
129
|
+
|
|
130
|
+
def set_spheres(self, centers, radii, value=1):
|
|
131
|
+
centers, radii = self._validate_spheres(centers, radii)
|
|
132
|
+
for center, radius in zip(centers, radii):
|
|
133
|
+
self.set_sphere(center, radius, value=value)
|
|
134
|
+
|
|
135
|
+
def clamp_grid(self, min_val=0.0, max_val=1.0):
|
|
136
|
+
np.clip(self.grid, min_val, max_val, out=self.grid)
|
|
137
|
+
|
|
138
|
+
def sample_voxels_in_range(self, min_val=0.0, max_val=1.0, min_dist=0.0, return_indices=False, seed=None):
|
|
139
|
+
"""
|
|
140
|
+
Yield voxel positions or indices whose values lie in [min_val, max_val].
|
|
141
|
+
|
|
142
|
+
When returning real-space positions, ``min_dist`` enforces a minimum
|
|
143
|
+
Euclidean separation in Angstrom between yielded samples.
|
|
144
|
+
"""
|
|
145
|
+
rng = np.random.default_rng(seed)
|
|
146
|
+
grid = self.to_numpy()
|
|
147
|
+
mask = (grid >= min_val) & (grid <= max_val)
|
|
148
|
+
candidates = np.argwhere(mask)
|
|
149
|
+
|
|
150
|
+
if candidates.shape[0] == 0:
|
|
151
|
+
raise ValueError("No voxels in specified value range.")
|
|
152
|
+
if return_indices and min_dist > 0:
|
|
153
|
+
raise ValueError("min_dist only supported when return_indices=False")
|
|
154
|
+
|
|
155
|
+
positions = candidates if return_indices else np.array([self.index_to_position(*idx) for idx in candidates])
|
|
156
|
+
selected = []
|
|
157
|
+
indices = rng.permutation(len(positions))
|
|
158
|
+
min_dist2 = min_dist**2
|
|
159
|
+
|
|
160
|
+
for i in indices:
|
|
161
|
+
pos = positions[i]
|
|
162
|
+
if min_dist > 0 and selected:
|
|
163
|
+
d2 = np.sum((np.array(selected) - pos) ** 2, axis=1)
|
|
164
|
+
if np.any(d2 < min_dist2):
|
|
165
|
+
continue
|
|
166
|
+
selected.append(pos)
|
|
167
|
+
yield tuple(candidates[i]) if return_indices else pos
|
|
168
|
+
|
|
169
|
+
def plot_3D(self, threshold=0.1, s=5, draw_cell=True):
|
|
170
|
+
"""Plot voxels with values above ``threshold`` in real space."""
|
|
171
|
+
import matplotlib.pyplot as plt
|
|
172
|
+
|
|
173
|
+
nx, ny, nz = self.gpts
|
|
174
|
+
ix, iy, iz = np.meshgrid(
|
|
175
|
+
np.arange(nx) + 0.5,
|
|
176
|
+
np.arange(ny) + 0.5,
|
|
177
|
+
np.arange(nz) + 0.5,
|
|
178
|
+
indexing="ij",
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
frac_coords = np.stack([ix / nx, iy / ny, iz / nz], axis=-1)
|
|
182
|
+
real_coords = frac_coords @ self.cell
|
|
183
|
+
grid = self.to_numpy()
|
|
184
|
+
mask = grid > threshold
|
|
185
|
+
xyz = real_coords[mask]
|
|
186
|
+
values = grid[mask]
|
|
187
|
+
|
|
188
|
+
fig = plt.figure()
|
|
189
|
+
ax = fig.add_subplot(projection="3d")
|
|
190
|
+
p = ax.scatter(xyz[:, 0], xyz[:, 1], xyz[:, 2], c=values, cmap="viridis", s=s)
|
|
191
|
+
fig.colorbar(p, ax=ax, label="Voxel value")
|
|
192
|
+
|
|
193
|
+
if draw_cell:
|
|
194
|
+
corners_frac = np.array(
|
|
195
|
+
[
|
|
196
|
+
[0, 0, 0],
|
|
197
|
+
[1, 0, 0],
|
|
198
|
+
[0, 1, 0],
|
|
199
|
+
[0, 0, 1],
|
|
200
|
+
[1, 1, 0],
|
|
201
|
+
[1, 0, 1],
|
|
202
|
+
[0, 1, 1],
|
|
203
|
+
[1, 1, 1],
|
|
204
|
+
]
|
|
205
|
+
)
|
|
206
|
+
corners = corners_frac @ self.cell
|
|
207
|
+
edges = [
|
|
208
|
+
(0, 1),
|
|
209
|
+
(0, 2),
|
|
210
|
+
(0, 3),
|
|
211
|
+
(1, 4),
|
|
212
|
+
(1, 5),
|
|
213
|
+
(2, 4),
|
|
214
|
+
(2, 6),
|
|
215
|
+
(3, 5),
|
|
216
|
+
(3, 6),
|
|
217
|
+
(4, 7),
|
|
218
|
+
(5, 7),
|
|
219
|
+
(6, 7),
|
|
220
|
+
]
|
|
221
|
+
for i, j in edges:
|
|
222
|
+
ax.plot(
|
|
223
|
+
[corners[i, 0], corners[j, 0]],
|
|
224
|
+
[corners[i, 1], corners[j, 1]],
|
|
225
|
+
[corners[i, 2], corners[j, 2]],
|
|
226
|
+
color="black",
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
all_coords = np.concatenate([xyz, corners]) if draw_cell else xyz
|
|
230
|
+
xlim = [all_coords[:, 0].min(), all_coords[:, 0].max()]
|
|
231
|
+
ylim = [all_coords[:, 1].min(), all_coords[:, 1].max()]
|
|
232
|
+
zlim = [all_coords[:, 2].min(), all_coords[:, 2].max()]
|
|
233
|
+
max_range = max(xlim[1] - xlim[0], ylim[1] - ylim[0], zlim[1] - zlim[0]) / 2.0
|
|
234
|
+
mid_x, mid_y, mid_z = np.mean(xlim), np.mean(ylim), np.mean(zlim)
|
|
235
|
+
|
|
236
|
+
ax.set_xlim(mid_x - max_range, mid_x + max_range)
|
|
237
|
+
ax.set_ylim(mid_y - max_range, mid_y + max_range)
|
|
238
|
+
ax.set_zlim(mid_z - max_range, mid_z + max_range)
|
|
239
|
+
ax.set_xlabel("x")
|
|
240
|
+
ax.set_ylabel("y")
|
|
241
|
+
ax.set_zlabel("z")
|
|
242
|
+
plt.tight_layout()
|
|
243
|
+
plt.show()
|
|
244
|
+
|
|
245
|
+
def plot_2D(self, axis="z", index=None, position=None, threshold=0.1, draw_cell=True, real_space=True):
|
|
246
|
+
"""Plot a 2D slice of the voxel grid along ``axis``."""
|
|
247
|
+
import matplotlib.pyplot as plt
|
|
248
|
+
|
|
249
|
+
ax_map = {"x": 0, "y": 1, "z": 2}
|
|
250
|
+
if axis not in ax_map:
|
|
251
|
+
raise ValueError("Axis must be 'x', 'y', or 'z'")
|
|
252
|
+
ax_idx = ax_map[axis]
|
|
253
|
+
|
|
254
|
+
if index is not None and position is not None:
|
|
255
|
+
raise ValueError("Specify either `index` or `position`, not both")
|
|
256
|
+
if position is not None:
|
|
257
|
+
index = self.position_to_index(np.eye(3)[ax_idx] * position)[ax_idx]
|
|
258
|
+
if index is None:
|
|
259
|
+
index = self.gpts[ax_idx] // 2
|
|
260
|
+
|
|
261
|
+
shape = self.grid.shape
|
|
262
|
+
if not (0 <= index < shape[ax_idx]):
|
|
263
|
+
raise IndexError(f"{axis}-index {index} out of bounds (0 to {shape[ax_idx] - 1})")
|
|
264
|
+
|
|
265
|
+
axes = [0, 1, 2]
|
|
266
|
+
axes.remove(ax_idx)
|
|
267
|
+
ax1, ax2 = axes
|
|
268
|
+
|
|
269
|
+
slicers = [slice(None)] * 3
|
|
270
|
+
slicers[ax_idx] = index
|
|
271
|
+
slice_grid = self.to_numpy()[tuple(slicers)]
|
|
272
|
+
|
|
273
|
+
n1, n2 = self.gpts[ax1], self.gpts[ax2]
|
|
274
|
+
if real_space:
|
|
275
|
+
i1 = (np.arange(n1) + 0.5) / n1
|
|
276
|
+
i2 = (np.arange(n2) + 0.5) / n2
|
|
277
|
+
coords = np.meshgrid(i1, i2, indexing="ij")
|
|
278
|
+
frac_coords = np.stack(coords, axis=-1)
|
|
279
|
+
xy = frac_coords @ self.cell[[ax1, ax2], :]
|
|
280
|
+
xvals, yvals = xy[..., 0], xy[..., 1]
|
|
281
|
+
else:
|
|
282
|
+
xvals, yvals = np.meshgrid(np.arange(n1), np.arange(n2), indexing="ij")
|
|
283
|
+
|
|
284
|
+
mask = slice_grid > threshold
|
|
285
|
+
fig, ax = plt.subplots()
|
|
286
|
+
sc = ax.scatter(xvals[mask], yvals[mask], c=slice_grid[mask], cmap="viridis", s=10)
|
|
287
|
+
fig.colorbar(sc, ax=ax, label="Voxel value")
|
|
288
|
+
|
|
289
|
+
if draw_cell and real_space:
|
|
290
|
+
corners_frac = np.array([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]])
|
|
291
|
+
corners_real = corners_frac @ self.cell[[ax1, ax2], :]
|
|
292
|
+
ax.plot(corners_real[:, 0], corners_real[:, 1], "k--", lw=1)
|
|
293
|
+
|
|
294
|
+
ax.set_xlabel(f'{["x", "y", "z"][ax1]}' + (" [Angstrom]" if real_space else " (voxel)"))
|
|
295
|
+
ax.set_ylabel(f'{["x", "y", "z"][ax2]}' + (" [Angstrom]" if real_space else " (voxel)"))
|
|
296
|
+
ax.set_title(f"{axis.upper()} Slice at index {index}")
|
|
297
|
+
ax.set_aspect("equal")
|
|
298
|
+
plt.tight_layout()
|
|
299
|
+
plt.show()
|
|
300
|
+
|
|
301
|
+
def __repr__(self):
|
|
302
|
+
return f"VoxelGrid\n{self.cell} Cell\n{self.resolution} Resolution\n{self.gpts} gpts"
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
VoxelGridNumPy = VoxelGrid
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
__all__ = ["VoxelGrid", "VoxelGridNumPy", "_cached_sphere_mask", "_cached_sphere_offsets"]
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: AtomVoxelizer
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Periodic atom-centered voxel grids for atomistic structures.
|
|
5
|
+
Author: AtomVoxelizer contributors
|
|
6
|
+
Project-URL: Homepage, https://gitlab.com/tgmaxson/atomvoxelizer
|
|
7
|
+
Project-URL: Documentation, https://atomvoxelizer.readthedocs.io/
|
|
8
|
+
Project-URL: Repository, https://gitlab.com/tgmaxson/atomvoxelizer
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Science/Research
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering :: Chemistry
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering :: Physics
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
Requires-Dist: matplotlib
|
|
23
|
+
Requires-Dist: numpy
|
|
24
|
+
Provides-Extra: numba
|
|
25
|
+
Requires-Dist: numba; extra == "numba"
|
|
26
|
+
Provides-Extra: cupy
|
|
27
|
+
Requires-Dist: cupy; extra == "cupy"
|
|
28
|
+
Requires-Dist: numba; extra == "cupy"
|
|
29
|
+
Provides-Extra: taichi
|
|
30
|
+
Requires-Dist: taichi; extra == "taichi"
|
|
31
|
+
Provides-Extra: analysis
|
|
32
|
+
Requires-Dist: scikit-image; extra == "analysis"
|
|
33
|
+
Provides-Extra: examples
|
|
34
|
+
Requires-Dist: ase; extra == "examples"
|
|
35
|
+
Requires-Dist: requests; extra == "examples"
|
|
36
|
+
Provides-Extra: docs
|
|
37
|
+
Requires-Dist: sphinx; extra == "docs"
|
|
38
|
+
Provides-Extra: dev
|
|
39
|
+
Requires-Dist: ase; extra == "dev"
|
|
40
|
+
Requires-Dist: numba; extra == "dev"
|
|
41
|
+
Requires-Dist: pytest; extra == "dev"
|
|
42
|
+
Requires-Dist: requests; extra == "dev"
|
|
43
|
+
Requires-Dist: scikit-image; extra == "dev"
|
|
44
|
+
Requires-Dist: sphinx; extra == "dev"
|
|
45
|
+
Requires-Dist: taichi; extra == "dev"
|
|
46
|
+
Provides-Extra: bench
|
|
47
|
+
Requires-Dist: ase; extra == "bench"
|
|
48
|
+
Requires-Dist: numba; extra == "bench"
|
|
49
|
+
Requires-Dist: taichi; extra == "bench"
|
|
50
|
+
Provides-Extra: publish
|
|
51
|
+
Requires-Dist: build; extra == "publish"
|
|
52
|
+
Requires-Dist: twine; extra == "publish"
|
|
53
|
+
|
|
54
|
+
# AtomVoxelizer
|
|
55
|
+
|
|
56
|
+
AtomVoxelizer builds periodic atom-centered voxel grids for atomistic structures.
|
|
57
|
+
The core `VoxelGrid` class stores a 3D NumPy grid over a periodic cell and provides
|
|
58
|
+
helpers for adding, setting, scaling, sampling, and plotting spherical regions.
|
|
59
|
+
|
|
60
|
+
## Installation
|
|
61
|
+
|
|
62
|
+
Install from this repository:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
pip install .
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Install optional acceleration backends with extras:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
pip install ".[numba]"
|
|
72
|
+
pip install ".[taichi]"
|
|
73
|
+
pip install ".[cupy]"
|
|
74
|
+
pip install ".[analysis]"
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`VoxelGrid` is always the NumPy backend. Optional acceleration backends are
|
|
78
|
+
explicit: `VoxelGridNumba`, `VoxelGridTaichi`, and `VoxelGridCuPy`.
|
|
79
|
+
`VoxelGridAnalysis` provides connected-volume and marching-cubes surface-area
|
|
80
|
+
analysis when the `analysis` extra is installed.
|
|
81
|
+
|
|
82
|
+
For development, examples, tests, and documentation:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
pip install -e ".[dev,examples]"
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Basic Usage
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
import numpy as np
|
|
92
|
+
|
|
93
|
+
from atomvoxelizer import VoxelGrid
|
|
94
|
+
|
|
95
|
+
cell = np.eye(3) * 10.0
|
|
96
|
+
grid = VoxelGrid(cell=cell, resolution=0.25)
|
|
97
|
+
|
|
98
|
+
grid.add_sphere(center=np.array([5.0, 5.0, 5.0]), radius=1.0, value=1.0)
|
|
99
|
+
grid.set_sphere(center=np.array([2.0, 2.0, 2.0]), radius=0.5, value=-1.0)
|
|
100
|
+
grid.clamp_grid(min_val=-1.0, max_val=1.0)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Zeolite Example
|
|
104
|
+
|
|
105
|
+
The zeolite example and CIF files live in `examples/`.
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
pip install -e ".[examples]"
|
|
109
|
+
python examples/zeolite_voxel.py BEA
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
The script reads a framework CIF, builds voxel grids at several resolutions, plots
|
|
113
|
+
middle XZ slices, benchmarks supercell scaling, and opens a 3D scatter plot.
|
|
114
|
+
|
|
115
|
+
The analysis example estimates pore volume and internal surface area:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
pip install -e ".[examples,analysis]"
|
|
119
|
+
python examples/zeolite_analysis.py BEA --resolution 0.25
|
|
120
|
+
python examples/zeolite_analysis.py BEA --convergence 1.0 0.75 0.5 --plot bea_convergence.png
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Tests and Benchmarks
|
|
124
|
+
|
|
125
|
+
Run the correctness tests with:
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
pytest
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Run the backend benchmark with:
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
python benchmarks/benchmark_backends.py --backends numpy numba taichi cupy
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Run the built-in structure benchmarks for a zeolite and a roughly 1000 atom Wulff
|
|
138
|
+
construction with:
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
python benchmarks/benchmark_structures.py
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Backends whose optional dependencies are not installed are reported as missing.
|
|
145
|
+
|
|
146
|
+
## Documentation
|
|
147
|
+
|
|
148
|
+
Documentation is scaffolded with Sphinx for Read the Docs.
|
|
149
|
+
|
|
150
|
+
Build it locally with:
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
pip install -e ".[docs]"
|
|
154
|
+
sphinx-build -b html docs/source docs/build/html
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Read the Docs can use `.readthedocs.yaml` directly.
|
|
158
|
+
|
|
159
|
+
## Publishing
|
|
160
|
+
|
|
161
|
+
Build and check PyPI artifacts with:
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
pip install -e ".[publish]"
|
|
165
|
+
python -m build
|
|
166
|
+
twine check dist/*
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Upload to TestPyPI first, then PyPI:
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
twine upload --repository testpypi dist/*
|
|
173
|
+
twine upload dist/*
|
|
174
|
+
```
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
atomvoxelizer/__init__.py,sha256=SA3jmOk191wtX3E_Zs3TbH9A56xzJ-VVStKgBE33eb4,741
|
|
2
|
+
atomvoxelizer/analysis.py,sha256=3NNXJvzPiZ8K52TZ89IPLahC3_PPb7KBMtuFnvPr2DE,7436
|
|
3
|
+
atomvoxelizer/cupy_backend.py,sha256=t95ZZmtIcsGT_A-2x9hlxdk_gX9ziU_t5bfwxzvhGl4,3404
|
|
4
|
+
atomvoxelizer/numba_backend.py,sha256=7jSrwcZOpzR6TnmdmLmeRC83KutlH_y_RZ4CoFhNLpQ,4680
|
|
5
|
+
atomvoxelizer/taichi_backend.py,sha256=NgnGkFAEcMdSx--EHkpo8QBd2ZxeF6ll_oD_FUSvleU,5449
|
|
6
|
+
atomvoxelizer/voxelgrid.py,sha256=HSSUAVDWLkXQ2rEhwgbZEFI4w1tdA8Zw9H6iy-Krb3c,11721
|
|
7
|
+
atomvoxelizer-0.1.0.dist-info/METADATA,sha256=M00nvGjj5ml1_R-o50AUbVJurArZb9szdZEeEwwYzs4,4760
|
|
8
|
+
atomvoxelizer-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
9
|
+
atomvoxelizer-0.1.0.dist-info/top_level.txt,sha256=KiH4w3N7wUa8hwDdDLNwfgHHj8BK1uE5LSzb63tzA7E,14
|
|
10
|
+
atomvoxelizer-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
atomvoxelizer
|