Source code for nvalchemiops.torch.neighbors.neighbor_utils

# SPDX-FileCopyrightText: Copyright (c) 2025 - 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""PyTorch utilities for neighbor list construction.

This module contains PyTorch-specific helper functions for neighbor list operations.
"""

from __future__ import annotations

import torch
import warp as wp

from nvalchemiops.neighbors.neighbor_utils import (
    NeighborOverflowError,
    estimate_max_neighbors,
)
from nvalchemiops.neighbors.neighbor_utils import (
    compute_naive_num_shifts as wp_compute_naive_num_shifts,
)
from nvalchemiops.torch.types import get_wp_dtype, get_wp_mat_dtype

__all__ = [
    "compute_naive_num_shifts",
    "get_neighbor_list_from_neighbor_matrix",
    "prepare_batch_idx_ptr",
    "allocate_cell_list",
    "estimate_max_neighbors",
    "synthesize_cell_for_batch",
    "synthesize_cell_for_ss",
    "NeighborOverflowError",
]


def _raise_if_compiling_host_only(name: str, replacement: str) -> None:
    """Raise a clear error when a host-only helper is traced by Dynamo."""
    if torch.compiler.is_compiling() or torch._dynamo.is_compiling():
        raise RuntimeError(
            f"{name} is a host-only neighbor-list helper and cannot run inside "
            f"torch.compile. {replacement}"
        )


def _validate_pair_params_present(
    pair_fn: object,
    pair_params: torch.Tensor | None,
) -> None:
    """Validate the torch pair-function parameter contract."""
    if pair_fn is not None and pair_params is None:
        raise ValueError("pair_params is required when pair_fn is provided")


def _validate_segmented_coo_structure(
    *,
    device: torch.device,
    num_systems: int,
    neighbor_list: torch.Tensor,
    neighbor_list_shifts: torch.Tensor,
    pair_offsets: torch.Tensor,
    pair_counts: torch.Tensor,
    rebuild_flags: torch.Tensor | None,
    tile_offsets: torch.Tensor | None = None,
    tile_counts: torch.Tensor | None = None,
    num_tiles: torch.Tensor | None = None,
    tile_row_group: torch.Tensor | None = None,
    tile_col_group: torch.Tensor | None = None,
    tile_system: torch.Tensor | None = None,
) -> int:
    """Validate segmented COO shapes, dtypes, devices, and capacities."""
    tensors = {
        "neighbor_list": neighbor_list,
        "neighbor_list_shifts": neighbor_list_shifts,
        "pair_offsets": pair_offsets,
        "pair_counts": pair_counts,
    }
    if rebuild_flags is not None:
        tensors["rebuild_flags"] = rebuild_flags
    optional_tensors = {
        "tile_offsets": tile_offsets,
        "tile_counts": tile_counts,
        "num_tiles": num_tiles,
        "tile_row_group": tile_row_group,
        "tile_col_group": tile_col_group,
        "tile_system": tile_system,
    }
    tensors.update(
        {name: value for name, value in optional_tensors.items() if value is not None}
    )
    for name, value in tensors.items():
        if value.device != device:
            raise ValueError(f"{name} must match positions.device")

    if (
        neighbor_list.dtype != torch.int32
        or neighbor_list.ndim != 2
        or neighbor_list.shape[0] != 2
    ):
        raise ValueError("neighbor_list must have shape (2, capacity) and dtype int32")
    capacity = int(neighbor_list.shape[1])
    if neighbor_list_shifts.dtype != torch.int32 or neighbor_list_shifts.shape != (
        capacity,
        3,
    ):
        raise ValueError(
            "neighbor_list_shifts must have shape (neighbor_list capacity, 3) "
            "and dtype int32"
        )
    if pair_offsets.dtype != torch.int32 or pair_offsets.shape != (num_systems + 1,):
        raise ValueError(
            "pair_offsets must have shape (num_systems + 1,) and dtype int32"
        )
    if pair_counts.dtype != torch.int32 or pair_counts.shape != (num_systems,):
        raise ValueError("pair_counts must have shape (num_systems,) and dtype int32")
    if rebuild_flags is not None and (
        rebuild_flags.dtype != torch.bool or rebuild_flags.shape != (num_systems,)
    ):
        raise ValueError("rebuild_flags must have shape (num_systems,) and dtype bool")

    tile_values = (tile_offsets, tile_counts, num_tiles, tile_row_group, tile_col_group)
    if any(value is not None for value in tile_values):
        if any(value is None for value in tile_values):
            raise ValueError("segmented COO tile state must be supplied completely")
        if (
            tile_offsets.dtype != torch.int32
            or tile_offsets.shape != (num_systems + 1,)
            or tile_counts.dtype != torch.int32
            or tile_counts.shape != (num_systems,)
            or num_tiles.dtype != torch.int32
            or num_tiles.shape != (1,)
        ):
            raise ValueError(
                "segmented COO tile metadata has an invalid shape or dtype"
            )
        if (
            tile_row_group.dtype != torch.int32
            or tile_col_group.dtype != torch.int32
            or tile_row_group.ndim != 1
            or tile_col_group.ndim != 1
            or tile_row_group.shape != tile_col_group.shape
        ):
            raise ValueError(
                "tile row and column buffers must be matching 1D int32 arrays"
            )
        if tile_system is not None and (
            tile_system.dtype != torch.int32
            or tile_system.ndim != 1
            or tile_system.shape != tile_row_group.shape
        ):
            raise ValueError("tile_system must match tile row and column buffer shapes")

    return capacity


def _validate_segmented_coo_values(
    *,
    neighbor_list: torch.Tensor,
    pair_offsets: torch.Tensor,
    pair_counts: torch.Tensor,
    tile_offsets: torch.Tensor | None = None,
    tile_counts: torch.Tensor | None = None,
    tile_row_group: torch.Tensor | None = None,
) -> None:
    """Eagerly validate segmented COO metadata values before mutation."""
    capacity = int(neighbor_list.shape[1])
    pair_offsets_host = pair_offsets.cpu()
    pair_counts_host = pair_counts.cpu()
    if int(pair_offsets_host[0]) != 0:
        raise ValueError("pair_offsets must start at zero")
    if bool((pair_offsets_host[1:] < pair_offsets_host[:-1]).any()):
        raise ValueError("pair_offsets must be nondecreasing")
    if int(pair_offsets_host[-1]) != capacity:
        raise ValueError("pair_offsets final value must equal neighbor_list capacity")
    pair_capacities = pair_offsets_host[1:] - pair_offsets_host[:-1]
    if bool((pair_counts_host < 0).any()) or bool(
        (pair_counts_host > pair_capacities).any()
    ):
        raise ValueError("pair_counts must lie within their pair_offsets segments")

    if tile_offsets is not None:
        tile_offsets_host = tile_offsets.cpu()
        tile_counts_host = tile_counts.cpu()
        if int(tile_offsets_host[0]) != 0:
            raise ValueError("tile_offsets must start at zero")
        if bool((tile_offsets_host[1:] < tile_offsets_host[:-1]).any()):
            raise ValueError("tile_offsets must be nondecreasing")
        if int(tile_offsets_host[-1]) > int(tile_row_group.shape[0]):
            raise ValueError("tile_offsets exceed the physical tile-buffer capacity")
        tile_capacities = tile_offsets_host[1:] - tile_offsets_host[:-1]
        if bool((tile_counts_host < 0).any()) or bool(
            (tile_counts_host > tile_capacities).any()
        ):
            raise ValueError("tile_counts must lie within their tile_offsets segments")


def _validate_segmented_coo_state(
    *,
    device: torch.device,
    num_systems: int,
    neighbor_list: torch.Tensor,
    neighbor_list_shifts: torch.Tensor,
    pair_offsets: torch.Tensor,
    pair_counts: torch.Tensor,
    rebuild_flags: torch.Tensor | None,
    tile_offsets: torch.Tensor | None = None,
    tile_counts: torch.Tensor | None = None,
    num_tiles: torch.Tensor | None = None,
    tile_row_group: torch.Tensor | None = None,
    tile_col_group: torch.Tensor | None = None,
    tile_system: torch.Tensor | None = None,
) -> int:
    """Validate fixed-capacity segmented COO state before a kernel launch."""
    capacity = _validate_segmented_coo_structure(
        device=device,
        num_systems=num_systems,
        neighbor_list=neighbor_list,
        neighbor_list_shifts=neighbor_list_shifts,
        pair_offsets=pair_offsets,
        pair_counts=pair_counts,
        rebuild_flags=rebuild_flags,
        tile_offsets=tile_offsets,
        tile_counts=tile_counts,
        num_tiles=num_tiles,
        tile_row_group=tile_row_group,
        tile_col_group=tile_col_group,
        tile_system=tile_system,
    )
    if not torch.compiler.is_compiling():
        _validate_segmented_coo_values(
            neighbor_list=neighbor_list,
            pair_offsets=pair_offsets,
            pair_counts=pair_counts,
            tile_offsets=tile_offsets,
            tile_counts=tile_counts,
            tile_row_group=tile_row_group,
        )
    return capacity


def _normalize_compiled_single_segment_coo_count(
    *,
    pair_offsets: torch.Tensor,
    pair_counts: torch.Tensor,
    physical_capacity: int,
) -> None:
    """Fail closed for malformed compiled single-segment COO metadata.

    This compiled-path helper uses only device-side int32 operations. A false
    rebuild flag returns before the Warp query validates metadata, while an
    overflowed attempted count is not final until every query block completes.
    It therefore validates the exact fixed interval and clamps the final count
    here. This is deliberately not a generic batched normalizer.
    """
    offsets_valid = (pair_offsets[0] == 0) & (pair_offsets[1] == physical_capacity)
    clamped_count = torch.clamp(pair_counts, min=0, max=physical_capacity)
    normalized_counts = torch.where(
        offsets_valid,
        clamped_count,
        torch.zeros_like(pair_counts),
    )
    pair_counts.copy_(normalized_counts)


def compute_naive_num_shifts(
    cell: torch.Tensor,
    cutoff: float,
    pbc: torch.Tensor,
) -> tuple[torch.Tensor, torch.Tensor, int]:
    """Compute periodic image shifts needed for neighbor searching.

    Parameters
    ----------
    cell : torch.Tensor, shape (num_systems, 3, 3)
        Cell matrices defining lattice vectors in Cartesian coordinates.
        Each 3x3 matrix represents one system's periodic cell.
    cutoff : float
        Cutoff distance for neighbor searching in Cartesian units.
        Must be positive and typically less than half the minimum cell dimension.
    pbc : torch.Tensor, shape (num_systems, 3), dtype=bool
        Periodic boundary condition flags for each dimension.
        True enables periodicity in that direction.

    Returns
    -------
    shift_range : torch.Tensor, shape (num_systems, 3), dtype=int32
        Maximum shift indices in each dimension for each system.
    num_shifts : torch.Tensor, shape (num_systems,), dtype=int32
        Number of periodic shifts for each system.
    max_shifts : int
        Maximum per-system shift count across all systems.

    Raises
    ------
    ValueError
        If any per-system shift count exceeds int32 range.

    See Also
    --------
    nvalchemiops.neighbors.neighbor_utils.compute_naive_num_shifts : Core warp launcher
    """
    _raise_if_compiling_host_only(
        "compute_naive_num_shifts",
        "Call it before compiling and pass shift_range_per_dimension, "
        "num_shifts_per_system, and max_shifts_per_system to the compiled "
        "neighbor-list call.",
    )
    num_systems = cell.shape[0]
    device = cell.device

    num_shifts_i32 = torch.empty(num_systems, dtype=torch.int32, device=device)
    shift_range = torch.empty((num_systems, 3), dtype=torch.int32, device=device)

    wp_dtype = get_wp_dtype(cell.dtype)
    wp_mat_dtype = get_wp_mat_dtype(cell.dtype)
    wp_device = wp.device_from_torch(device)

    wp_cell = wp.from_torch(cell, dtype=wp_mat_dtype, requires_grad=False)
    wp_pbc = wp.from_torch(pbc, dtype=wp.bool, requires_grad=False)
    wp_num_shifts = wp.from_torch(num_shifts_i32, dtype=wp.int32, requires_grad=False)
    wp_shift_range = wp.from_torch(shift_range, dtype=wp.vec3i, requires_grad=False)

    wp_compute_naive_num_shifts(
        cell=wp_cell,
        cutoff=cutoff,
        pbc=wp_pbc,
        num_shifts=wp_num_shifts,
        shift_range=wp_shift_range,
        wp_dtype=wp_dtype,
        device=str(wp_device),
    )

    s = shift_range.to(torch.int64)
    k1 = 2 * s[:, 1] + 1
    k2 = 2 * s[:, 2] + 1
    num_shifts_i64 = s[:, 0] * k1 * k2 + s[:, 1] * k2 + s[:, 2] + 1

    max_shifts_i64 = num_shifts_i64.max().item() if num_systems > 0 else 0
    if max_shifts_i64 > 2**31 - 1:
        raise ValueError(
            f"Per-system shift count ({max_shifts_i64}) exceeds int32 max "
            f"(2^31 - 1). Reduce the cutoff, increase cell size, or use a "
            f"cell-list method for very small cells."
        )

    num_shifts = num_shifts_i64.to(torch.int32)
    return shift_range, num_shifts, int(max_shifts_i64)


def get_neighbor_list_from_neighbor_matrix(
    neighbor_matrix: torch.Tensor,
    num_neighbors: torch.Tensor,
    neighbor_shift_matrix: torch.Tensor | None = None,
    fill_value: int = -1,
) -> (
    tuple[torch.Tensor, torch.Tensor] | tuple[torch.Tensor, torch.Tensor, torch.Tensor]
):
    """Convert neighbor matrix format to neighbor list format.

    Parameters
    ----------
    neighbor_matrix : torch.Tensor, shape (total_atoms, max_neighbors), dtype=int32
        The neighbor matrix with neighbor atom indices.
    num_neighbors : torch.Tensor, shape (total_atoms,), dtype=int32
        The number of neighbors for each atom.
    neighbor_shift_matrix : torch.Tensor | None, shape (total_atoms, max_neighbors, 3), dtype=int32
        Optional neighbor shift matrix with periodic shift vectors.
    fill_value : int, default=-1
        The fill value used in the neighbor matrix to indicate empty slots.
        This is used to create a mask from the neighbor matrix.

    Returns
    -------
    neighbor_list : torch.Tensor, shape (2, num_pairs), dtype=int32
        The neighbor list in COO format [source_atoms, target_atoms].
    neighbor_ptr : torch.Tensor, shape (total_atoms + 1,), dtype=int32
        CSR-style pointer array where neighbor_ptr[i]:neighbor_ptr[i+1] gives the range of
        neighbors for atom i in the flattened neighbor list.
    neighbor_list_shifts : torch.Tensor, shape (num_pairs, 3), dtype=int32
        The neighbor shift vectors (only returned if neighbor_shift_matrix is not None).

    Raises
    ------
    ValueError
        If the max number of neighbors is larger than the neighbor matrix width.

    Notes
    -----
    This is a pure PyTorch utility function with no warp dependencies. It converts
    from the fixed-width matrix format to the variable-width list format by masking
    out fill values and flattening the result.

    See Also
    --------
    nvalchemiops.torch.neighbors.naive_neighbor_list : Uses this for format conversion
    nvalchemiops.torch.neighbors.cell_list : Uses this for format conversion
    """
    # Handle empty case
    if num_neighbors.shape[0] == 0:
        neighbor_list = torch.zeros(
            2, 0, dtype=neighbor_matrix.dtype, device=neighbor_matrix.device
        )
        neighbor_ptr = torch.zeros(1, dtype=torch.int32, device=neighbor_matrix.device)
        if neighbor_shift_matrix is not None:
            neighbor_shift_list = torch.empty(
                0,
                3,
                dtype=neighbor_shift_matrix.dtype,
                device=neighbor_shift_matrix.device,
            )
            return neighbor_list, neighbor_ptr, neighbor_shift_list
        else:
            return neighbor_list, neighbor_ptr

    # Validate that the neighbor matrix is large enough
    max_found = num_neighbors.max()
    if max_found > neighbor_matrix.shape[1]:
        raise NeighborOverflowError(
            neighbor_matrix.shape[1],
            max_found.item() if hasattr(max_found, "item") else int(max_found),
        )

    # Create mask and extract neighbor pairs
    mask = neighbor_matrix != fill_value
    dtype = neighbor_matrix.dtype
    i_idx = torch.where(mask)[0].to(dtype)
    j_idx = neighbor_matrix[mask].to(dtype)
    neighbor_list = torch.stack([i_idx, j_idx], dim=0)

    # Create CSR-style pointer array
    neighbor_ptr = torch.zeros(
        num_neighbors.shape[0] + 1, dtype=torch.int32, device=neighbor_matrix.device
    )
    torch.cumsum(num_neighbors, dim=0, out=neighbor_ptr[1:])

    if neighbor_shift_matrix is not None:
        neighbor_list_shifts = neighbor_shift_matrix[mask]
        return neighbor_list, neighbor_ptr, neighbor_list_shifts
    else:
        return neighbor_list, neighbor_ptr


def coo_pack_pair_geometry(
    active_mask: torch.Tensor,
    distances: torch.Tensor | None = None,
    vectors: torch.Tensor | None = None,
) -> tuple[torch.Tensor | None, torch.Tensor | None]:
    """Repack matrix-layout per-pair geometry into COO order.

    ``active_mask`` is ``neighbor_matrix != fill_value``.  Flattening it in
    row-major order yields the active-slot indices in the same order
    :func:`get_neighbor_list_from_neighbor_matrix` uses, so the gathered
    distances ``(num_pairs,)`` and vectors ``(num_pairs, 3)`` index-align with
    the returned neighbor list.  ``index_select`` keeps the autograd link.

    Parameters
    ----------
    active_mask : torch.Tensor, shape (total_atoms, max_neighbors), dtype=bool
        Mask of active neighbor-matrix slots.
    distances : torch.Tensor | None, shape (total_atoms, max_neighbors)
        Per-pair distances in matrix layout, or ``None``.
    vectors : torch.Tensor | None, shape (total_atoms, max_neighbors, 3)
        Per-pair displacement vectors in matrix layout, or ``None``.

    Returns
    -------
    tuple of (torch.Tensor | None, torch.Tensor | None)
        ``(distances, vectors)`` in COO layout, each unchanged if it was
        ``None``.
    """
    flat_active = active_mask.reshape(-1).nonzero(as_tuple=True)[0]
    if distances is not None:
        distances = distances.reshape(-1).index_select(0, flat_active)
    if vectors is not None:
        vectors = vectors.reshape(-1, vectors.shape[-1]).index_select(0, flat_active)
    return distances, vectors


[docs] @torch.compile def prepare_batch_idx_ptr( batch_idx: torch.Tensor | None, batch_ptr: torch.Tensor | None, num_atoms: int, device: torch.device, ) -> tuple[torch.Tensor, torch.Tensor]: """Prepare batch index and pointer tensors from either representation. Utility function to ensure both batch_idx and batch_ptr are available, computing one from the other if needed. Parameters ---------- batch_idx : torch.Tensor | None, shape (total_atoms,), dtype=int32 Tensor indicating the batch index for each atom. batch_ptr : torch.Tensor | None, shape (num_systems + 1,), dtype=int32 Tensor indicating the start index of each batch in the atom list. num_atoms : int Total number of atoms across all systems. device : torch.device Device on which to create tensors if needed. Returns ------- batch_idx : torch.Tensor, shape (total_atoms,), dtype=int32 Prepared batch index tensor. batch_ptr : torch.Tensor, shape (num_systems + 1,), dtype=int32 Prepared batch pointer tensor. Raises ------ ValueError If both batch_idx and batch_ptr are None. RuntimeError If batch_idx length does not match num_atoms (only checked in eager mode). Notes ----- This is a pure PyTorch utility function with no warp dependencies. It provides convenience for batch operations by converting between dense (batch_idx) and sparse (batch_ptr) batch representations. The batch_idx size validation is only performed in eager mode to avoid graph breaks during torch.compile tracing. During compiled execution, mismatched sizes will result in undefined behavior. See Also -------- nvalchemiops.torch.neighbors.batch_naive_neighbor_list : Uses this for batch setup nvalchemiops.torch.neighbors.batch_cell_list : Uses this for batch setup """ if batch_idx is None and batch_ptr is None: raise ValueError("Either batch_idx or batch_ptr must be provided.") if batch_ptr is not None and batch_ptr.shape[0] < 2: raise ValueError("batch_ptr must have length at least 2") # Validate batch_idx size in eager mode only to avoid graph breaks if not torch.compiler.is_compiling(): if batch_idx is not None and batch_idx.shape[0] != num_atoms: raise RuntimeError( f"batch_idx length ({batch_idx.shape[0]}) does not match " f"num_atoms ({num_atoms}). batch_idx must have one entry per atom." ) if batch_idx is None: num_systems = batch_ptr.shape[0] - 1 num_atoms_per_system = batch_ptr[1:] - batch_ptr[:-1] batch_idx = torch.repeat_interleave( torch.arange(num_systems, dtype=torch.int32, device=device), num_atoms_per_system, ) elif batch_ptr is None: num_systems = batch_idx.max() + 1 num_atoms_per_system = torch.bincount(batch_idx, minlength=num_systems) batch_ptr = torch.zeros(num_systems + 1, dtype=torch.int32, device=device) torch.cumsum(num_atoms_per_system, dim=0, out=batch_ptr[1:]) return batch_idx, batch_ptr
def synthesize_cell_for_ss( positions: torch.Tensor, cutoff: float, padding_fraction: float = 0.1, ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: """Build an orthorhombic non-PBC cell around ``positions``. Used by ``neighbor_list(method=..., cell=None)`` so callers without a real simulation cell still get a tight bounding box. The returned positions are shifted so the minimum corner is at the origin; the cell diagonal is the extent plus ``padding_fraction * cutoff`` so atoms never sit on the boundary. Parameters ---------- positions : (N, 3) float Atomic coordinates (any frame). cutoff : float Neighbor cutoff; only used to size the boundary padding. padding_fraction : float, default 0.1 Padding around the bounding box, expressed as a fraction of ``cutoff``. Returns ------- positions : (N, 3) float Same dtype/device as input; shifted so ``min == 0``. cell : (1, 3, 3) float Orthorhombic cell whose diagonal is the (padded) extent. pbc : (3,) bool ``[False, False, False]`` — synthesized cells are non-periodic. """ pbc = torch.zeros(3, dtype=torch.bool, device=positions.device) if positions.shape[0] == 0: cell = torch.eye(3, dtype=positions.dtype, device=positions.device).reshape( 1, 3, 3 ) return positions, cell, pbc pos_min = positions.min(dim=0).values positions = positions - pos_min pos_max = positions.max(dim=0).values cell_lengths = pos_max + padding_fraction * cutoff cell = torch.diag(cell_lengths).reshape(1, 3, 3) return positions, cell, pbc def synthesize_cell_for_batch( positions: torch.Tensor, batch_idx: torch.Tensor, batch_ptr: torch.Tensor, cutoff: float, padding_fraction: float = 0.1, ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: """Per-system bounding-box cells around ``positions`` for batched inputs. Companion to :func:`synthesize_cell_for_ss` for the batched entry points (``batch_cell_list``, ``batch_cluster_tile_neighbor_list``). For each system, computes the tight ``(min, max)`` bbox via ``scatter_reduce`` and synthesizes an orthorhombic non-PBC cell with ``padding_fraction * cutoff`` of slack on each side. Positions are shifted so each system's minimum corner is at the origin. Parameters ---------- positions : (total_atoms, 3) float Concatenated atomic coordinates. batch_idx : (total_atoms,) int32 System index per atom. batch_ptr : (num_systems + 1,) int32 CSR offsets — used only to derive ``num_systems``. Must have length at least 2. cutoff : float Neighbor cutoff. padding_fraction : float, default 0.1 Padding around each system's bbox. Returns ------- positions : (total_atoms, 3) float Same dtype/device as input; shifted per-system so each system's min corner is at the origin. cell : (num_systems, 3, 3) float Per-system orthorhombic cells. pbc : (num_systems, 3) bool All False — synthesized cells are non-periodic. """ if batch_ptr.shape[0] < 2: raise ValueError("batch_ptr must have length at least 2") num_systems = int(batch_ptr.shape[0]) - 1 if positions.shape[0] == 0: cell = ( torch.eye(3, dtype=positions.dtype, device=positions.device) .reshape(1, 3, 3) .expand(num_systems, -1, -1) .contiguous() ) pbc = torch.zeros((num_systems, 3), dtype=torch.bool, device=positions.device) return positions, cell, pbc expanded_idx = batch_idx.unsqueeze(1).expand_as(positions) pos_min = torch.full( (num_systems, 3), float("inf"), dtype=positions.dtype, device=positions.device, ) pos_min.scatter_reduce_(0, expanded_idx, positions, reduce="amin") pos_max = torch.full( (num_systems, 3), float("-inf"), dtype=positions.dtype, device=positions.device, ) pos_max.scatter_reduce_(0, expanded_idx, positions, reduce="amax") # TODO: switch to segment_ops once #17 is merged positions = positions - torch.index_select(pos_min, 0, batch_idx) cell_lengths = pos_max - pos_min + padding_fraction * cutoff cell = torch.diag_embed(cell_lengths) pbc = torch.zeros( (num_systems, 3), dtype=torch.bool, device=positions.device, ) return positions, cell, pbc
[docs] def allocate_cell_list( total_atoms: int, max_total_cells: int, neighbor_search_radius: torch.Tensor, device: torch.device, ) -> tuple[ torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, ]: """Allocate memory tensors for cell list data structures. Parameters ---------- total_atoms : int Total number of atoms across all systems. max_total_cells : int Maximum number of cells to allocate. neighbor_search_radius : torch.Tensor, shape (3,) or (num_systems, 3), dtype=int32 Radius of neighboring cells to search in each dimension. device : torch.device Device on which to create tensors. Returns ------- cells_per_dimension : torch.Tensor, shape (3,) or (num_systems, 3), dtype=int32 Number of cells in x, y, z directions (to be filled by build_cell_list). neighbor_search_radius : torch.Tensor, shape (3,) or (num_systems, 3), dtype=int32 Radius of neighboring cells to search (passed through for convenience). atom_periodic_shifts : torch.Tensor, shape (total_atoms, 3), dtype=int32 Periodic boundary crossings for each atom (to be filled by build_cell_list). atom_to_cell_mapping : torch.Tensor, shape (total_atoms, 3), dtype=int32 3D cell coordinates for each atom (to be filled by build_cell_list). atoms_per_cell_count : torch.Tensor, shape (max_total_cells,), dtype=int32 Number of atoms in each cell (to be filled by build_cell_list). cell_atom_start_indices : torch.Tensor, shape (max_total_cells,), dtype=int32 Starting index in cell_atom_list for each cell (to be filled by build_cell_list). cell_atom_list : torch.Tensor, shape (total_atoms,), dtype=int32 Flattened list of atom indices organized by cell (to be filled by build_cell_list). Notes ----- This is a pure PyTorch utility function with no warp dependencies. It pre-allocates all tensors needed for cell list construction, supporting both single-system and batched operations based on the shape of neighbor_search_radius. See Also -------- nvalchemiops.neighbors.cell_list.build_cell_list : Warp launcher that uses these tensors nvalchemiops.torch.neighbors.cell_list.build_cell_list : High-level PyTorch wrapper nvalchemiops.torch.neighbors.batch_cell_list.batch_build_cell_list : Batched version """ if max_total_cells < 0: raise ValueError( f"allocate_cell_list: max_total_cells={max_total_cells} < 0 " "(cell-count overflow or bad estimate)." ) # Detect number of systems from neighbor_search_radius shape is_batched = neighbor_search_radius.ndim == 2 num_systems = neighbor_search_radius.shape[0] if is_batched else 1 cells_per_dimension = torch.zeros( (3,) if not is_batched else (num_systems, 3), dtype=torch.int32, device=device, ) atom_periodic_shifts = torch.zeros( (total_atoms, 3), dtype=torch.int32, device=device ) atom_to_cell_mapping = torch.zeros( (total_atoms, 3), dtype=torch.int32, device=device ) atoms_per_cell_count = torch.zeros( (max_total_cells,), dtype=torch.int32, device=device ) cell_atom_start_indices = torch.zeros( (max_total_cells,), dtype=torch.int32, device=device ) cell_atom_list = torch.zeros((total_atoms,), dtype=torch.int32, device=device) return ( cells_per_dimension, neighbor_search_radius, atom_periodic_shifts, atom_to_cell_mapping, atoms_per_cell_count, cell_atom_start_indices, cell_atom_list, )