from __future__ import annotations
from dataclasses import dataclass
import numpy as np
@dataclass(frozen=True)
class VoxelRegion:
"""Summary of one connected voxel region."""
label: int
voxel_count: int
volume: float
surface_area: float
[docs]
@dataclass(frozen=True)
class ProbeAccessibleResult:
"""Probe-center accessible volume and surface summary."""
probe_radius: float
accessible_voxel_count: int
accessible_volume: float
accessible_surface_area: float
regions: list[VoxelRegion]
accessible_mask: np.ndarray
[docs]
class VoxelGridAnalysis:
"""Analyze connected voxel volumes and their surfaces."""
def __init__(self, voxel_grid):
self.voxel_grid = voxel_grid
@property
def grid(self):
return self.voxel_grid.to_numpy()
@property
def cell(self):
return self.voxel_grid.cell
@property
def gpts(self):
return self.voxel_grid.gpts
@property
def voxel_volume(self):
return abs(float(np.linalg.det(self.cell))) / float(np.prod(self.gpts))
[docs]
def mask(self, min_value=None, max_value=None, threshold=None, above=True):
"""Build a boolean mask from voxel values."""
if threshold is not None and (min_value is not None or max_value is not None):
raise ValueError("Specify either threshold or min_value/max_value, not both")
grid = self.grid
if threshold is not None:
return grid > threshold if above else grid < threshold
selected = np.ones(grid.shape, dtype=bool)
if min_value is not None:
selected &= grid >= min_value
if max_value is not None:
selected &= grid <= max_value
return selected
[docs]
def connected_components(self, selected, connectivity=1, periodic=True):
"""Label connected components in a boolean mask."""
try:
from skimage.measure import label
except ImportError as exc: # pragma: no cover - depends on optional dependency
raise ImportError(
"VoxelGridAnalysis requires scikit-image. Install it with "
"`pip install AtomVoxelizer[analysis]`."
) from exc
selected = np.asarray(selected, dtype=bool)
labels = label(selected, connectivity=connectivity)
if periodic:
labels = self._merge_periodic_labels(labels, selected)
return labels, int(labels.max())
[docs]
def region_volume(self, selected):
"""Return the volume represented by a boolean mask."""
return int(np.count_nonzero(selected)) * self.voxel_volume
[docs]
def surface_area(self, selected, periodic=True):
"""Estimate the surface area of a boolean region with marching cubes."""
try:
from skimage.measure import marching_cubes
except ImportError as exc: # pragma: no cover - depends on optional dependency
raise ImportError(
"VoxelGridAnalysis requires scikit-image. Install it with "
"`pip install AtomVoxelizer[analysis]`."
) from exc
selected = np.asarray(selected, dtype=bool)
if not np.any(selected):
return 0.0
if periodic and np.all(selected):
return 0.0
if periodic:
values = np.tile(selected.astype(np.float32), (3, 3, 3))
vertices, faces, _normals, _values = marching_cubes(values, level=0.5)
central_offset = self.gpts
centroids = vertices[faces].mean(axis=1)
in_central_cell = np.all((centroids >= central_offset) & (centroids < 2 * central_offset), axis=1)
faces = faces[in_central_cell]
vertices = vertices - central_offset
else:
values = np.pad(selected.astype(np.float32), 1, mode="constant", constant_values=0.0)
vertices, faces, _normals, _values = marching_cubes(values, level=0.5)
vertices = vertices - 1.0
if faces.size == 0:
return 0.0
real_vertices = self._index_vertices_to_real(vertices)
return self._mesh_surface_area(real_vertices, faces)
[docs]
def surface_area_voxel_faces(self, selected, periodic=True):
"""Estimate surface area by counting exposed voxel faces.
This is faster than marching cubes and avoids tiled periodic volumes. It
is a grid-face estimate rather than a smoothed triangular surface, so it
is most useful for fast convergence scans or large grids.
"""
selected = np.asarray(selected, dtype=bool)
if not np.any(selected):
return 0.0
if periodic and np.all(selected):
return 0.0
voxel_vectors = self.cell / self.gpts[:, None]
face_areas = np.array(
[
np.linalg.norm(np.cross(voxel_vectors[1], voxel_vectors[2])),
np.linalg.norm(np.cross(voxel_vectors[0], voxel_vectors[2])),
np.linalg.norm(np.cross(voxel_vectors[0], voxel_vectors[1])),
],
dtype=float,
)
area = 0.0
for axis, face_area in enumerate(face_areas):
if periodic:
exposed = selected != np.roll(selected, -1, axis=axis)
area += float(np.count_nonzero(exposed)) * face_area
else:
inner_a = [slice(None)] * selected.ndim
inner_b = [slice(None)] * selected.ndim
inner_a[axis] = slice(None, -1)
inner_b[axis] = slice(1, None)
exposed = selected[tuple(inner_a)] != selected[tuple(inner_b)]
area += float(np.count_nonzero(exposed)) * face_area
first = np.take(selected, 0, axis=axis)
last = np.take(selected, -1, axis=axis)
area += float(np.count_nonzero(first) + np.count_nonzero(last)) * face_area
return area
[docs]
def mesh_at_value(self, level, periodic=True, clip_periodic=True):
"""Return a marching-cubes mesh for a scalar voxel value.
``level`` is interpreted in the units stored in the voxel grid. For a
distance-mask grid this traces the surface at a fixed distance from the
nearest atom. Vertices are returned in real-space coordinates and faces
index into that vertex array. Periodic meshes are clipped to the primary
cell by default so boundary-crossing triangles are cut at the cell edge
instead of being wrapped across the cell.
"""
try:
from skimage.measure import marching_cubes
except ImportError as exc: # pragma: no cover - depends on optional dependency
raise ImportError(
"VoxelGridAnalysis requires scikit-image. Install it with "
"`pip install scikit-image`."
) from exc
level = float(level)
grid = self._finite_grid_for_level(level)
finite = grid[np.isfinite(grid)]
if finite.size == 0 or level < finite.min() or level > finite.max():
raise ValueError("level must lie within the finite voxel value range")
if periodic:
values = np.tile(grid.astype(np.float32), (3, 3, 3))
vertices, faces, _normals, _values = marching_cubes(values, level=level)
central_offset = self.gpts
centroids = vertices[faces].mean(axis=1)
in_central_cell = np.all((centroids >= central_offset) & (centroids < 2 * central_offset), axis=1)
faces = faces[in_central_cell]
vertices = vertices - central_offset
if clip_periodic:
vertices, faces = self._clip_mesh_to_index_cell(vertices, faces)
else:
vertices, faces, _normals, _values = marching_cubes(grid.astype(np.float32), level=level)
if faces.size == 0:
return np.empty((0, 3), dtype=float), np.empty((0, 3), dtype=int)
used = np.unique(faces)
remap = np.full(vertices.shape[0], -1, dtype=int)
remap[used] = np.arange(used.shape[0])
real_vertices = self._index_vertices_to_real(vertices[used])
return real_vertices, remap[faces]
[docs]
def surface_area_at_value(self, level, periodic=True, clip_periodic=True):
"""Estimate the area of a scalar isosurface at ``level``."""
vertices, faces = self.mesh_at_value(level=level, periodic=periodic, clip_periodic=clip_periodic)
if faces.size == 0:
return 0.0
return self._mesh_surface_area(vertices, faces)
[docs]
def analyze_regions(
self,
min_value=None,
max_value=None,
threshold=None,
above=True,
connectivity=1,
periodic=True,
surface_method="marching-cubes",
):
"""Return volume and marching-cubes area for each connected region."""
selected = self.mask(min_value=min_value, max_value=max_value, threshold=threshold, above=above)
return self.analyze_mask(
selected,
connectivity=connectivity,
periodic=periodic,
surface_method=surface_method,
)
[docs]
def analyze_mask(
self,
selected,
connectivity=1,
periodic=True,
surface_method="marching-cubes",
):
"""Return volume and surface area for connected regions in a boolean mask."""
if surface_method not in {"marching-cubes", "voxel-faces"}:
raise ValueError("surface_method must be 'marching-cubes' or 'voxel-faces'")
selected = np.asarray(selected, dtype=bool)
labels, label_count = self.connected_components(selected, connectivity=connectivity, periodic=periodic)
regions = []
for label_id in range(1, label_count + 1):
region_mask = labels == label_id
voxel_count = int(np.count_nonzero(region_mask))
if surface_method == "marching-cubes":
surface_area = self.surface_area(region_mask, periodic=periodic)
else:
surface_area = self.surface_area_voxel_faces(region_mask, periodic=periodic)
regions.append(
VoxelRegion(
label=label_id,
voxel_count=voxel_count,
volume=voxel_count * self.voxel_volume,
surface_area=surface_area,
)
)
return regions
[docs]
def probe_accessible_mask(self, positions, radii, probe_radius, write_grid=False):
"""Return voxels accessible to the center of a spherical probe.
``positions`` must have shape ``(N, 3)`` and ``radii`` must contain one
atom radius per position. A voxel is accessible when a probe center at
that voxel does not overlap any atom, so atoms are excluded with radius
``radii + probe_radius``.
The input voxel grid is used as the geometry template. By default, the
existing grid values are not modified. Set ``write_grid=True`` to store
the binary accessible mask in ``self.voxel_grid.grid`` as 1 for
accessible voxels and 0 for excluded voxels.
"""
positions, radii = self._validate_probe_inputs(positions, radii, probe_radius)
from .voxelgrid import VoxelGrid
work_grid = VoxelGrid(self.cell, gpts=self.gpts, dtype=np.uint8)
work_grid.grid.fill(1)
work_grid.set_spheres(positions, radii + float(probe_radius), value=0)
accessible = work_grid.grid.astype(bool, copy=True)
if write_grid:
self.voxel_grid.grid[...] = accessible.astype(self.voxel_grid.dtype, copy=False)
return accessible
[docs]
def analyze_probe_accessibility(
self,
positions,
radii,
probe_radius,
connectivity=1,
periodic=True,
surface_method="voxel-faces",
write_grid=False,
):
"""Analyze probe-center accessible volume and surface area.
This is a probe-center analysis: atom exclusion radii are inflated by
``probe_radius`` and accessible voxels are positions where the probe
center fits. Volumes therefore describe the region available to the
probe center. Surface areas describe the boundary of that accessible
center region.
"""
accessible = self.probe_accessible_mask(positions, radii, probe_radius, write_grid=write_grid)
regions = self.analyze_mask(
accessible,
connectivity=connectivity,
periodic=periodic,
surface_method=surface_method,
)
accessible_voxel_count = int(np.count_nonzero(accessible))
return ProbeAccessibleResult(
probe_radius=float(probe_radius),
accessible_voxel_count=accessible_voxel_count,
accessible_volume=accessible_voxel_count * self.voxel_volume,
accessible_surface_area=sum(region.surface_area for region in regions),
regions=regions,
accessible_mask=accessible,
)
[docs]
def probe_accessible_surface_area(
self,
positions,
radii,
probe_radius,
samples_per_atom=1000,
surface_radius_scale=1.0,
):
"""Estimate probe-accessible surface area by sampling inflated atom surfaces.
Points are placed deterministically on each sphere with a Fibonacci
sphere rule. A sampled point contributes to the area when it does not
overlap the inflated sphere around any other atom under periodic
boundary conditions.
``surface_radius_scale`` defaults to 1.0 for a hard-sphere contact
surface. PoreBlazer uses 1.122 for its nitrogen accessible surface area
calculation.
"""
positions, radii = self._validate_probe_inputs(positions, radii, probe_radius)
samples_per_atom = int(samples_per_atom)
surface_radius_scale = float(surface_radius_scale)
if samples_per_atom < 1:
raise ValueError("samples_per_atom must be at least 1")
if surface_radius_scale <= 0.0:
raise ValueError("surface_radius_scale must be positive")
directions = self._fibonacci_sphere(samples_per_atom)
surface_radii = surface_radius_scale * (radii + float(probe_radius))
total_area = 0.0
for atom_index, (position, surface_radius) in enumerate(zip(positions, surface_radii)):
points = position + directions * surface_radius
accessible = np.ones(samples_per_atom, dtype=bool)
for other_index, (other_position, other_radius) in enumerate(zip(positions, surface_radii)):
if other_index == atom_index:
continue
disp_frac = (points - other_position) @ self.voxel_grid.cell_inv
disp_frac -= np.round(disp_frac)
disp = disp_frac @ self.cell
dist2 = np.einsum("ij,ij->i", disp, disp)
accessible &= dist2 >= other_radius * other_radius
total_area += 4.0 * np.pi * surface_radius * surface_radius * float(np.mean(accessible))
return float(total_area)
[docs]
@staticmethod
def volume_angstrom3_to_cm3_per_g(volume_angstrom3, mass_amu):
"""Convert a cell/supercell volume from Angstrom^3 to cm^3/g."""
if mass_amu <= 0:
raise ValueError("mass_amu must be positive")
mass_g = float(mass_amu) * 1.66053906660e-24
return float(volume_angstrom3) * 1.0e-24 / mass_g
[docs]
@staticmethod
def area_angstrom2_to_m2_per_g(area_angstrom2, mass_amu):
"""Convert a cell/supercell area from Angstrom^2 to m^2/g."""
if mass_amu <= 0:
raise ValueError("mass_amu must be positive")
mass_g = float(mass_amu) * 1.66053906660e-24
return float(area_angstrom2) * 1.0e-20 / mass_g
@staticmethod
def _validate_probe_inputs(positions, radii, probe_radius):
positions = np.asarray(positions, dtype=np.float64)
radii = np.asarray(radii, dtype=np.float64)
probe_radius = float(probe_radius)
if positions.ndim != 2 or positions.shape[1] != 3:
raise ValueError("positions must have shape (N, 3)")
if radii.ndim != 1 or radii.shape[0] != positions.shape[0]:
raise ValueError("radii must have shape (N,)")
if np.any(radii < 0.0):
raise ValueError("radii must be non-negative")
if probe_radius < 0.0:
raise ValueError("probe_radius must be non-negative")
return positions, radii
@staticmethod
def _fibonacci_sphere(count):
indices = np.arange(count, dtype=float) + 0.5
z = 1.0 - 2.0 * indices / float(count)
theta = np.pi * (1.0 + np.sqrt(5.0)) * indices
radius = np.sqrt(np.maximum(0.0, 1.0 - z * z))
return np.column_stack((radius * np.cos(theta), radius * np.sin(theta), z))
def _index_vertices_to_real(self, vertices):
frac = vertices / self.gpts
return frac @ self.cell
def _finite_grid_for_level(self, level):
grid = np.asarray(self.grid, dtype=np.float32)
if np.all(np.isfinite(grid)):
return grid
finite = grid[np.isfinite(grid)]
if finite.size == 0:
return grid
high = max(float(finite.max()), float(level)) + 1.0
low = min(float(finite.min()), float(level)) - 1.0
clean = grid.copy()
clean[np.isposinf(clean)] = high
clean[np.isneginf(clean)] = low
return clean
def _clip_mesh_to_index_cell(self, vertices, faces):
clipped_vertices = []
clipped_faces = []
bounds = [(0.0, float(n)) for n in self.gpts]
for face in faces:
polygon = [vertices[int(vertex_index)].astype(float) for vertex_index in face]
for axis, (low, high) in enumerate(bounds):
polygon = self._clip_polygon_axis(polygon, axis, low, keep_greater=True)
polygon = self._clip_polygon_axis(polygon, axis, high, keep_greater=False)
if len(polygon) < 3:
break
if len(polygon) < 3:
continue
start = len(clipped_vertices)
clipped_vertices.extend(polygon)
for index in range(1, len(polygon) - 1):
clipped_faces.append((start, start + index, start + index + 1))
if not clipped_faces:
return np.empty((0, 3), dtype=float), np.empty((0, 3), dtype=int)
return np.asarray(clipped_vertices, dtype=float), np.asarray(clipped_faces, dtype=int)
@staticmethod
def _clip_polygon_axis(polygon, axis, boundary, keep_greater):
if not polygon:
return []
clipped = []
previous = polygon[-1]
previous_inside = previous[axis] >= boundary if keep_greater else previous[axis] <= boundary
for current in polygon:
current_inside = current[axis] >= boundary if keep_greater else current[axis] <= boundary
if current_inside != previous_inside:
delta = current[axis] - previous[axis]
if delta != 0.0:
t = (boundary - previous[axis]) / delta
clipped.append(previous + t * (current - previous))
if current_inside:
clipped.append(current)
previous = current
previous_inside = current_inside
return clipped
@staticmethod
def _merge_periodic_labels(labels, selected):
label_count = int(labels.max())
if label_count == 0:
return labels
parent = np.arange(label_count + 1)
def find(label_id):
while parent[label_id] != label_id:
parent[label_id] = parent[parent[label_id]]
label_id = parent[label_id]
return label_id
def union(a, b):
if a == 0 or b == 0:
return
root_a = find(int(a))
root_b = find(int(b))
if root_a != root_b:
parent[root_b] = root_a
for axis in range(labels.ndim):
first_labels = np.take(labels, 0, axis=axis)
last_labels = np.take(labels, -1, axis=axis)
first_selected = np.take(selected, 0, axis=axis)
last_selected = np.take(selected, -1, axis=axis)
for a, b in zip(first_labels[first_selected & last_selected], last_labels[first_selected & last_selected]):
union(a, b)
root_to_new_label = {}
next_label = 1
merged = np.zeros_like(labels)
for label_id in range(1, label_count + 1):
root = find(label_id)
if root not in root_to_new_label:
root_to_new_label[root] = next_label
next_label += 1
merged[labels == label_id] = root_to_new_label[root]
return merged
@staticmethod
def _mesh_surface_area(vertices, faces):
triangles = vertices[faces]
cross = np.cross(triangles[:, 1] - triangles[:, 0], triangles[:, 2] - triangles[:, 0])
return float(0.5 * np.linalg.norm(cross, axis=1).sum())
[docs]
@staticmethod
def mesh_surface_area(vertices, faces):
"""Return the area of a triangular mesh."""
return VoxelGridAnalysis._mesh_surface_area(np.asarray(vertices), np.asarray(faces, dtype=int))
__all__ = ["ProbeAccessibleResult", "VoxelGridAnalysis", "VoxelRegion"]