Source code for atomvoxelizer.vectorgrid

from __future__ import annotations

from functools import lru_cache

import numpy as np

from .voxelgrid import VoxelGrid, _cached_sphere_offsets


@lru_cache(maxsize=200)
def _cached_sphere_offsets_and_vectors(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)
    vectors = np.zeros((offsets.shape[0], 3), dtype=np.float32)

    for index, offset in enumerate(offsets):
        disp_frac = offset.astype(np.float64) / gpts_arr
        disp = disp_frac @ cell_arr
        norm = np.linalg.norm(disp)
        if norm > 0.0:
            vectors[index] = disp / norm

    return offsets, vectors


[docs] class FieldVoxelGrid(VoxelGrid): """Periodic voxel grid where each voxel stores an arbitrary-shaped value. This NumPy-only prototype mirrors the geometric indexing behavior of :class:`atomvoxelizer.VoxelGrid`, but stores values with shape ``(*gpts, *value_shape)``. Examples: * ``value_shape=()`` stores scalar values. * ``value_shape=(1,)`` stores scalar values as length-1 vectors. * ``value_shape=(3,)`` stores real-space vectors and enables the ``mask="normal"`` sphere mask. * ``value_shape=(3, 3)`` stores a matrix at each voxel. """ backend_name = "numpy-field" def __init__(self, cell, resolution=None, gpts=None, dtype=np.float32, value_shape=(3,), components=None): dtype = np.dtype(dtype) if dtype.kind not in "f": raise TypeError("FieldVoxelGrid dtype must be a floating NumPy dtype") if components is not None: value_shape = (int(components),) self.value_shape = self._normalize_value_shape(value_shape) if any(dim < 1 for dim in self.value_shape): raise ValueError("value_shape dimensions must be at least 1") self.components = self.value_shape[0] if len(self.value_shape) == 1 else None super().__init__(cell=cell, resolution=resolution, gpts=gpts, dtype=dtype) self.grid = np.zeros((*tuple(self.gpts), *self.value_shape), dtype=self.dtype) @staticmethod def _normalize_value_shape(value_shape): if value_shape is None: return () if isinstance(value_shape, int): return (int(value_shape),) return tuple(int(dim) for dim in value_shape) def _offset_indices(self, center_idx, offsets): indices = (offsets + center_idx) % self.gpts return tuple(indices[:, axis] for axis in range(3)) @staticmethod def _validate_field_mask(mask): if mask not in {"constant", "normal"}: raise ValueError("mask must be 'constant' or 'normal'") def _default_constant_value(self): if self.value_shape == (): return self.dtype.type(1) return np.ones(self.value_shape, dtype=self.dtype) def _validate_value(self, value): values = np.asarray(value, dtype=self.dtype) if self.value_shape == (): if values.shape != (): raise ValueError("value must be scalar for value_shape=()") return values if self.value_shape == (1,) and values.shape == (): return values.reshape(1) if values.shape != self.value_shape: raise ValueError(f"value must have shape {self.value_shape}") return values def _sphere_indices_and_values(self, center, radius, value, mask): self._validate_field_mask(mask) center_idx = self._center_index(center) if mask == "constant": offsets = self._sphere_offsets(radius) values = self._validate_value(value) else: if self.value_shape != (3,): raise ValueError('normal mask requires value_shape=(3,)') offsets, normal_vectors = _cached_sphere_offsets_and_vectors( float(radius), tuple(self.gpts), tuple(map(tuple, self.cell)) ) values = normal_vectors.astype(self.dtype, copy=False) * self.dtype.type(value) return self._offset_indices(center_idx, offsets), values
[docs] def set_sphere(self, center, radius, value=None, mask="constant"): if value is None: value = self._default_constant_value() if mask == "constant" else 1.0 indices, values = self._sphere_indices_and_values(center, radius, value, mask) self.grid[indices] = values
[docs] def add_sphere(self, center, radius, value=None, mask="constant"): if value is None: value = self._default_constant_value() if mask == "constant" else 1.0 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=None, mask="constant"): if factor is None: factor = self._default_constant_value() if mask == "constant" else 1.0 indices, values = self._sphere_indices_and_values(center, radius, factor, mask) self.grid[indices] *= values
[docs] def div_sphere(self, center, radius, factor=None, mask="constant"): if factor is None: factor = self._default_constant_value() if mask == "constant" else 1.0 indices, values = self._sphere_indices_and_values(center, radius, factor, mask) self.grid[indices] /= values
[docs] def set_spheres(self, centers, radii, value=None, 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 add_spheres(self, centers, radii, value=None, 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 mul_spheres(self, centers, radii, factor=None, 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=None, 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_sphere(self, center, radius, value=1, mask="constant"): raise NotImplementedError("min_sphere is not implemented for FieldVoxelGrid")
[docs] def min_spheres(self, centers, radii, value=1, mask="constant"): raise NotImplementedError("min_spheres is not implemented for FieldVoxelGrid")
[docs] def clamp_grid(self, min_val=0.0, max_val=1.0): raise NotImplementedError("clamp_grid is not implemented for FieldVoxelGrid")
[docs] def sample_voxels_in_range(self, min_val=0.0, max_val=1.0, min_dist=0.0, return_indices=False, seed=None): raise NotImplementedError("sample_voxels_in_range is not implemented for FieldVoxelGrid")
[docs] def value_norms(self): """Return the Euclidean/Frobenius norm of each voxel value.""" if self.value_shape == (): return np.abs(self.grid) axes = tuple(range(3, self.grid.ndim)) return np.linalg.norm(self.grid, axis=axes)
[docs] def normalize_values(self, inplace=True): """Normalize nonzero voxel values and leave zero values unchanged.""" target = self.grid if inplace else self.grid.copy() norms = self._norms_for(target, self.value_shape) nonzero = norms > 0.0 if self.value_shape == (): target[nonzero] /= norms[nonzero] else: target[nonzero] /= norms[nonzero][(...,) + (None,) * len(self.value_shape)] if inplace: return self return target
@staticmethod def _norms_for(values, value_shape): if value_shape == (): return np.abs(values) axes = tuple(range(3, values.ndim)) return np.linalg.norm(values, axis=axes)
[docs] def vector_norms(self): """Return per-voxel value norms. Kept as a convenience alias for the first vector-field prototype. """ return self.value_norms()
[docs] def normalize_vectors(self, inplace=True): """Normalize nonzero voxel values. Kept as a convenience alias for the first vector-field prototype. """ return self.normalize_values(inplace=inplace)
[docs] def scalar_values(self): """Return scalar values for ``value_shape=()`` or ``value_shape=(1,)`` fields.""" if self.value_shape == (): return self.grid if self.value_shape == (1,): return self.grid[..., 0] raise ValueError("scalar_values requires value_shape=() or value_shape=(1,)")
def _check_vector_field(self, operation): if self.value_shape != (3,): raise ValueError(f"{operation} requires value_shape=(3,)") @staticmethod def _normalize_selected(vectors): if vectors.size == 0: return vectors normalized = vectors.copy() norms = np.linalg.norm(normalized, axis=-1) nonzero = norms > 0.0 normalized[nonzero] /= norms[nonzero, None] return normalized def _voxel_center_positions(self): nx, ny, nz = self.gpts ix, iy, iz = np.meshgrid( np.arange(nx), np.arange(ny), np.arange(nz), indexing="ij", ) frac = (np.stack([ix, iy, iz], axis=-1) + 0.5) / self.gpts return frac @ self.cell def _sampled_vector_mask(self, norms, stride, min_norm): if stride < 1: raise ValueError("stride must be at least 1") selected = norms > min_norm sampled = np.zeros_like(selected, dtype=bool) sampled[(slice(None, None, stride),) * selected.ndim] = True return selected & sampled def _slice_index(self, axis, index=None, position=None): 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 if not (0 <= index < self.gpts[ax_idx]): raise IndexError(f"{axis}-index {index} out of bounds (0 to {self.gpts[ax_idx] - 1})") return ax_idx, int(index)
[docs] def quiver_slice_data(self, axis="z", index=None, position=None, stride=1, min_norm=0.0, normalize=False): """Return 2D quiver data for a slice through a three-component vector field.""" self._check_vector_field("quiver_slice_data") ax_idx, index = self._slice_index(axis, index=index, position=position) axes = [0, 1, 2] axes.remove(ax_idx) ax1, ax2 = axes slicers = [slice(None)] * 3 slicers[ax_idx] = index vectors = self.grid[tuple(slicers)] positions = self._voxel_center_positions()[tuple(slicers)] norms = np.linalg.norm(vectors, axis=-1) selected = self._sampled_vector_mask(norms, stride, min_norm) selected_vectors = vectors[selected] if normalize: selected_vectors = self._normalize_selected(selected_vectors) return { "x": positions[..., ax1][selected], "y": positions[..., ax2][selected], "u": selected_vectors[:, ax1] if selected_vectors.size else np.array([], dtype=self.dtype), "v": selected_vectors[:, ax2] if selected_vectors.size else np.array([], dtype=self.dtype), "norm": norms[selected], "axis": axis, "index": index, "axes": (ax1, ax2), }
[docs] def plot_quiver_slice( self, axis="z", index=None, position=None, stride=1, min_norm=0.0, normalize=False, ax=None, scale=None, ): """Plot a 2D quiver slice through a three-component vector field.""" import matplotlib.pyplot as plt data = self.quiver_slice_data( axis=axis, index=index, position=position, stride=stride, min_norm=min_norm, normalize=normalize, ) if ax is None: _fig, ax = plt.subplots() ax.quiver(data["x"], data["y"], data["u"], data["v"], data["norm"], angles="xy", scale_units="xy", scale=scale) ax.set_aspect("equal", adjustable="box") ax.set_xlabel("xyz"[data["axes"][0]]) ax.set_ylabel("xyz"[data["axes"][1]]) ax.set_title(f"{axis} slice {data['index']}") return ax
[docs] def quiver_3d_data(self, stride=1, min_norm=0.0, normalize=False): """Return 3D quiver data for a three-component vector field.""" self._check_vector_field("quiver_3d_data") positions = self._voxel_center_positions() vectors = self.grid norms = np.linalg.norm(vectors, axis=-1) selected = self._sampled_vector_mask(norms, stride, min_norm) selected_vectors = vectors[selected] if normalize: selected_vectors = self._normalize_selected(selected_vectors) selected_positions = positions[selected] return { "x": selected_positions[:, 0] if selected_positions.size else np.array([], dtype=self.dtype), "y": selected_positions[:, 1] if selected_positions.size else np.array([], dtype=self.dtype), "z": selected_positions[:, 2] if selected_positions.size else np.array([], dtype=self.dtype), "u": selected_vectors[:, 0] if selected_vectors.size else np.array([], dtype=self.dtype), "v": selected_vectors[:, 1] if selected_vectors.size else np.array([], dtype=self.dtype), "w": selected_vectors[:, 2] if selected_vectors.size else np.array([], dtype=self.dtype), "norm": norms[selected], }
[docs] def plot_quiver_3D(self, stride=1, min_norm=0.0, normalize=False, ax=None, length=1.0): """Plot a sampled 3D quiver view of a three-component vector field.""" import matplotlib.pyplot as plt data = self.quiver_3d_data(stride=stride, min_norm=min_norm, normalize=normalize) if ax is None: fig = plt.figure() ax = fig.add_subplot(projection="3d") ax.quiver(data["x"], data["y"], data["z"], data["u"], data["v"], data["w"], length=length, normalize=False) ax.set_xlabel("x") ax.set_ylabel("y") ax.set_zlabel("z") return ax
[docs] def plot_2D(self, *args, **kwargs): raise NotImplementedError("plot_2D is not implemented for FieldVoxelGrid; use plot_quiver_slice")
[docs] def plot_3D(self, *args, **kwargs): raise NotImplementedError("plot_3D is not implemented for FieldVoxelGrid; use plot_quiver_3D")
[docs] class VectorVoxelGrid(FieldVoxelGrid): """Convenience field grid with a three-component vector value by default.""" def __init__(self, cell, resolution=None, gpts=None, dtype=np.float32, components=3, value_shape=None): if value_shape is None: value_shape = (int(components),) super().__init__(cell=cell, resolution=resolution, gpts=gpts, dtype=dtype, value_shape=value_shape)
FieldVoxelGridNumPy = FieldVoxelGrid VectorVoxelGridNumPy = VectorVoxelGrid __all__ = [ "FieldVoxelGrid", "FieldVoxelGridNumPy", "VectorVoxelGrid", "VectorVoxelGridNumPy", "_cached_sphere_offsets_and_vectors", ]