from __future__ import annotations
from functools import lru_cache
import numpy as np
@lru_cache(maxsize=50)
def _cached_sphere_mask(radius, gpts, cell):
nx, ny, nz = gpts
ix, iy, iz = np.meshgrid(
np.arange(nx),
np.arange(ny),
np.arange(nz),
indexing="ij",
)
frac_coords = (np.stack([ix, iy, iz], axis=-1) + 0.5) / gpts
center_frac = np.array([0.5, 0.5, 0.5])
disp_frac = frac_coords - center_frac
disp_frac -= np.round(disp_frac)
disp_mic = disp_frac @ cell
dist2 = np.sum(disp_mic**2, axis=-1)
return dist2 <= radius**2
@lru_cache(maxsize=200)
def _cached_sphere_offsets(radius, gpts, cell):
gpts_arr = np.array(gpts, dtype=np.int32)
cell_arr = np.array(cell, dtype=np.float64)
lengths = np.linalg.norm(cell_arr, axis=1)
max_offsets = np.ceil(radius / lengths * gpts_arr).astype(np.int32)
offsets = []
for dx in range(-max_offsets[0], max_offsets[0] + 1):
for dy in range(-max_offsets[1], max_offsets[1] + 1):
for dz in range(-max_offsets[2], max_offsets[2] + 1):
disp_frac = np.array([dx, dy, dz], dtype=np.float64) / gpts_arr
disp = disp_frac @ cell_arr
if np.dot(disp, disp) <= radius**2:
offsets.append((dx, dy, dz))
return np.array(offsets, dtype=np.int32)
@lru_cache(maxsize=200)
def _cached_sphere_offsets_and_distances(radius, gpts, cell):
gpts_arr = np.array(gpts, dtype=np.int32)
cell_arr = np.array(cell, dtype=np.float64)
offsets = _cached_sphere_offsets(radius, gpts, cell)
distances = np.empty(offsets.shape[0], dtype=np.float32)
for index, offset in enumerate(offsets):
disp_frac = offset.astype(np.float64) / gpts_arr
disp = disp_frac @ cell_arr
distances[index] = np.sqrt(np.dot(disp, disp))
return offsets, distances
[docs]
class VoxelGrid:
"""Periodic voxel grid implemented with NumPy only."""
backend_name = "numpy"
def __init__(self, cell, resolution=None, gpts=None, dtype=np.float32):
self.cell = np.array(cell, dtype=np.float64)
self.cell_inv = np.linalg.inv(self.cell)
self.dtype = np.dtype(dtype)
if self.dtype.kind not in "iufc":
raise TypeError("dtype must be a numeric NumPy dtype")
if resolution is None and gpts is None:
raise ValueError("Either resolution or gpts must be specified")
if resolution is not None and gpts is not None:
raise ValueError("Only one of resolution or gpts can be specified")
lengths = np.linalg.norm(self.cell, axis=1)
if resolution is not None:
self.gpts = np.ceil(lengths / resolution).astype(int)
else:
self.gpts = np.array(gpts, dtype=int)
self.resolution = lengths / self.gpts
self.grid = np.zeros(tuple(self.gpts), dtype=self.dtype)
[docs]
def to_numpy(self):
"""Return the voxel values as a NumPy array."""
return self.grid
[docs]
def position_to_index(self, r):
"""Convert real-space position to voxel index using periodic wrapping."""
frac = np.asarray(r, dtype=np.float64) @ self.cell_inv
frac_wrapped = np.clip(frac % 1.0, 0.0, np.nextafter(1.0, 0.0))
idx = np.floor(frac_wrapped * self.gpts).astype(int)
return tuple(idx)
[docs]
def index_to_position(self, i, j, k):
"""Convert a grid index to the real-space voxel center."""
frac = (np.array([i, j, k]) + 0.5) / self.gpts
return frac @ self.cell
def _center_index(self, center):
center_frac = np.asarray(center, dtype=np.float64) @ self.cell_inv % 1.0
return np.floor(center_frac * self.gpts).astype(np.int32)
def _offset_indices(self, center_idx, offsets):
indices = (offsets + center_idx) % self.gpts
return tuple(indices[:, axis] for axis in range(3))
def _sphere_offsets(self, radius):
return _cached_sphere_offsets(float(radius), tuple(self.gpts), tuple(map(tuple, self.cell)))
def _sphere_offsets_and_distances(self, radius):
return _cached_sphere_offsets_and_distances(float(radius), tuple(self.gpts), tuple(map(tuple, self.cell)))
def _sphere_indices(self, center, radius):
return self._offset_indices(self._center_index(center), self._sphere_offsets(radius))
@staticmethod
def _validate_mask(mask):
if mask not in {"constant", "distance"}:
raise ValueError("mask must be 'constant' or 'distance'")
def _check_ordered_grid(self, operation):
if np.issubdtype(self.dtype, np.complexfloating):
raise TypeError(f"{operation} is not supported for complex grid dtypes")
def _sphere_indices_and_values(self, center, radius, value, mask):
self._validate_mask(mask)
center_idx = self._center_index(center)
if mask == "constant":
offsets = self._sphere_offsets(radius)
values = value
else:
offsets, values = self._sphere_offsets_and_distances(radius)
values = values * value
return self._offset_indices(center_idx, offsets), values
[docs]
def set_sphere(self, center, radius, value=1, mask="constant"):
indices, values = self._sphere_indices_and_values(center, radius, value, mask)
self.grid[indices] = values
[docs]
def add_sphere(self, center, radius, value=1, mask="constant"):
indices, values = self._sphere_indices_and_values(center, radius, value, mask)
np.add.at(self.grid, indices, values)
[docs]
def mul_sphere(self, center, radius, factor=2, mask="constant"):
indices, values = self._sphere_indices_and_values(center, radius, factor, mask)
self.grid[indices] *= values
[docs]
def div_sphere(self, center, radius, factor=2, mask="constant"):
indices, values = self._sphere_indices_and_values(center, radius, factor, mask)
self.grid[indices] /= values
[docs]
def min_sphere(self, center, radius, value=1, mask="distance"):
self._check_ordered_grid("min_sphere")
indices, values = self._sphere_indices_and_values(center, radius, value, mask)
np.minimum.at(self.grid, indices, values)
[docs]
def positions_to_indices(self, positions):
positions = np.asarray(positions, dtype=np.float64)
frac = positions @ self.cell_inv
frac_wrapped = np.clip(frac % 1.0, 0.0, np.nextafter(1.0, 0.0))
return np.floor(frac_wrapped * self.gpts).astype(np.int32)
def _validate_spheres(self, centers, radii):
centers = np.asarray(centers, dtype=np.float64)
radii = np.asarray(radii, dtype=np.float64)
if centers.ndim != 2 or centers.shape[1] != 3:
raise ValueError("centers must have shape (N, 3)")
if radii.ndim != 1 or radii.shape[0] != centers.shape[0]:
raise ValueError("radii must have shape (N,)")
return centers, radii
[docs]
def add_spheres(self, centers, radii, value=1, mask="constant"):
centers, radii = self._validate_spheres(centers, radii)
for center, radius in zip(centers, radii):
self.add_sphere(center, radius, value=value, mask=mask)
[docs]
def set_spheres(self, centers, radii, value=1, mask="constant"):
centers, radii = self._validate_spheres(centers, radii)
for center, radius in zip(centers, radii):
self.set_sphere(center, radius, value=value, mask=mask)
[docs]
def mul_spheres(self, centers, radii, factor=2, mask="constant"):
centers, radii = self._validate_spheres(centers, radii)
for center, radius in zip(centers, radii):
self.mul_sphere(center, radius, factor=factor, mask=mask)
[docs]
def div_spheres(self, centers, radii, factor=2, mask="constant"):
centers, radii = self._validate_spheres(centers, radii)
for center, radius in zip(centers, radii):
self.div_sphere(center, radius, factor=factor, mask=mask)
[docs]
def min_spheres(self, centers, radii, value=1, mask="distance"):
self._check_ordered_grid("min_spheres")
centers, radii = self._validate_spheres(centers, radii)
for center, radius in zip(centers, radii):
self.min_sphere(center, radius, value=value, mask=mask)
[docs]
def clamp_grid(self, min_val=0.0, max_val=1.0):
self._check_ordered_grid("clamp_grid")
np.clip(self.grid, min_val, max_val, out=self.grid)
[docs]
def sample_voxels_in_range(self, min_val=0.0, max_val=1.0, min_dist=0.0, return_indices=False, seed=None):
"""
Yield voxel positions or indices whose values lie in [min_val, max_val].
When returning real-space positions, ``min_dist`` enforces a minimum
Euclidean separation in Angstrom between yielded samples.
"""
self._check_ordered_grid("sample_voxels_in_range")
rng = np.random.default_rng(seed)
grid = self.to_numpy()
mask = (grid >= min_val) & (grid <= max_val)
candidates = np.argwhere(mask)
if candidates.shape[0] == 0:
raise ValueError("No voxels in specified value range.")
if return_indices and min_dist > 0:
raise ValueError("min_dist only supported when return_indices=False")
positions = candidates if return_indices else np.array([self.index_to_position(*idx) for idx in candidates])
selected = []
indices = rng.permutation(len(positions))
min_dist2 = min_dist**2
for i in indices:
pos = positions[i]
if min_dist > 0 and selected:
d2 = np.sum((np.array(selected) - pos) ** 2, axis=1)
if np.any(d2 < min_dist2):
continue
selected.append(pos)
yield tuple(candidates[i]) if return_indices else pos
[docs]
def plot_3D(self, threshold=0.1, s=5, draw_cell=True):
"""Plot voxels with values above ``threshold`` in real space."""
self._check_ordered_grid("plot_3D")
import matplotlib.pyplot as plt
nx, ny, nz = self.gpts
ix, iy, iz = np.meshgrid(
np.arange(nx) + 0.5,
np.arange(ny) + 0.5,
np.arange(nz) + 0.5,
indexing="ij",
)
frac_coords = np.stack([ix / nx, iy / ny, iz / nz], axis=-1)
real_coords = frac_coords @ self.cell
grid = self.to_numpy()
mask = grid > threshold
xyz = real_coords[mask]
values = grid[mask]
fig = plt.figure()
ax = fig.add_subplot(projection="3d")
p = ax.scatter(xyz[:, 0], xyz[:, 1], xyz[:, 2], c=values, cmap="viridis", s=s)
fig.colorbar(p, ax=ax, label="Voxel value")
if draw_cell:
corners_frac = np.array(
[
[0, 0, 0],
[1, 0, 0],
[0, 1, 0],
[0, 0, 1],
[1, 1, 0],
[1, 0, 1],
[0, 1, 1],
[1, 1, 1],
]
)
corners = corners_frac @ self.cell
edges = [
(0, 1),
(0, 2),
(0, 3),
(1, 4),
(1, 5),
(2, 4),
(2, 6),
(3, 5),
(3, 6),
(4, 7),
(5, 7),
(6, 7),
]
for i, j in edges:
ax.plot(
[corners[i, 0], corners[j, 0]],
[corners[i, 1], corners[j, 1]],
[corners[i, 2], corners[j, 2]],
color="black",
)
all_coords = np.concatenate([xyz, corners]) if draw_cell else xyz
xlim = [all_coords[:, 0].min(), all_coords[:, 0].max()]
ylim = [all_coords[:, 1].min(), all_coords[:, 1].max()]
zlim = [all_coords[:, 2].min(), all_coords[:, 2].max()]
max_range = max(xlim[1] - xlim[0], ylim[1] - ylim[0], zlim[1] - zlim[0]) / 2.0
mid_x, mid_y, mid_z = np.mean(xlim), np.mean(ylim), np.mean(zlim)
ax.set_xlim(mid_x - max_range, mid_x + max_range)
ax.set_ylim(mid_y - max_range, mid_y + max_range)
ax.set_zlim(mid_z - max_range, mid_z + max_range)
ax.set_xlabel("x")
ax.set_ylabel("y")
ax.set_zlabel("z")
plt.tight_layout()
plt.show()
[docs]
def plot_2D(self, axis="z", index=None, position=None, threshold=0.1, draw_cell=True, real_space=True):
"""Plot a 2D slice of the voxel grid along ``axis``."""
import matplotlib.pyplot as plt
ax_map = {"x": 0, "y": 1, "z": 2}
if axis not in ax_map:
raise ValueError("Axis must be 'x', 'y', or 'z'")
ax_idx = ax_map[axis]
if index is not None and position is not None:
raise ValueError("Specify either `index` or `position`, not both")
if position is not None:
index = self.position_to_index(np.eye(3)[ax_idx] * position)[ax_idx]
if index is None:
index = self.gpts[ax_idx] // 2
shape = self.grid.shape
if not (0 <= index < shape[ax_idx]):
raise IndexError(f"{axis}-index {index} out of bounds (0 to {shape[ax_idx] - 1})")
axes = [0, 1, 2]
axes.remove(ax_idx)
ax1, ax2 = axes
slicers = [slice(None)] * 3
slicers[ax_idx] = index
slice_grid = self.to_numpy()[tuple(slicers)]
n1, n2 = self.gpts[ax1], self.gpts[ax2]
if real_space:
i1 = (np.arange(n1) + 0.5) / n1
i2 = (np.arange(n2) + 0.5) / n2
coords = np.meshgrid(i1, i2, indexing="ij")
frac_coords = np.stack(coords, axis=-1)
xy = frac_coords @ self.cell[[ax1, ax2], :]
xvals, yvals = xy[..., 0], xy[..., 1]
else:
xvals, yvals = np.meshgrid(np.arange(n1), np.arange(n2), indexing="ij")
mask = slice_grid > threshold
fig, ax = plt.subplots()
sc = ax.scatter(xvals[mask], yvals[mask], c=slice_grid[mask], cmap="viridis", s=10)
fig.colorbar(sc, ax=ax, label="Voxel value")
if draw_cell and real_space:
corners_frac = np.array([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]])
corners_real = corners_frac @ self.cell[[ax1, ax2], :]
ax.plot(corners_real[:, 0], corners_real[:, 1], "k--", lw=1)
ax.set_xlabel(f'{["x", "y", "z"][ax1]}' + (" [Angstrom]" if real_space else " (voxel)"))
ax.set_ylabel(f'{["x", "y", "z"][ax2]}' + (" [Angstrom]" if real_space else " (voxel)"))
ax.set_title(f"{axis.upper()} Slice at index {index}")
ax.set_aspect("equal")
plt.tight_layout()
plt.show()
def __repr__(self):
return f"VoxelGrid\n{self.cell} Cell\n{self.resolution} Resolution\n{self.gpts} gpts"
VoxelGridNumPy = VoxelGrid
__all__ = [
"VoxelGrid",
"VoxelGridNumPy",
"_cached_sphere_mask",
"_cached_sphere_offsets",
"_cached_sphere_offsets_and_distances",
]