Source code for nvalchemiops.torch.interactions.electrostatics.parameters

# 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.

"""
Parameter Estimation for Ewald and PME Methods (PyTorch)
========================================================

This module provides functions to automatically estimate optimal parameters
for Ewald summation and Particle Mesh Ewald (PME) calculations using PyTorch.
"""

import math
from dataclasses import dataclass

import torch

from nvalchemiops.interactions.electrostatics._pme_mesh import (
    _DEFAULT_PME_FFT_PADDING_FRACTION,
    _DEFAULT_PME_SPLINE_ORDER,
    _round_pme_mesh_dimensions,
)


[docs] @dataclass class EwaldParameters: """Container for Ewald summation parameters. All values are tensors of shape (B,), for single system calculations, the shape is (1,). Attributes ---------- alpha : torch.Tensor, shape (B,) Ewald splitting parameter (inverse length units). real_space_cutoff : torch.Tensor, shape (B,) Real-space cutoff distance. reciprocal_space_cutoff : torch.Tensor, shape (B,) Reciprocal-space cutoff (:math:`|k|` in inverse length units). """ alpha: torch.Tensor real_space_cutoff: torch.Tensor reciprocal_space_cutoff: torch.Tensor
[docs] @dataclass class PMEParameters: """Container for PME parameters. Attributes ---------- alpha : torch.Tensor, shape (B,) Ewald splitting parameter. mesh_dimensions : tuple[int, int, int], shape (3,) Mesh dimensions (nx, ny, nz). mesh_spacing : torch.Tensor, shape (B, 3) Actual mesh spacing in each direction. real_space_cutoff : torch.Tensor, shape (B,) Real-space cutoff distance. """ alpha: torch.Tensor mesh_dimensions: tuple[int, int, int] mesh_spacing: torch.Tensor real_space_cutoff: torch.Tensor
def _count_atoms_per_system( positions: torch.Tensor, num_systems: int, batch_idx: torch.Tensor | None = None ) -> torch.Tensor: """Count number of atoms per system.""" if batch_idx is None: return torch.tensor( [positions.shape[0]], dtype=torch.int32, device=positions.device ) counts = torch.zeros(num_systems, dtype=torch.int32, device=batch_idx.device) ones = torch.ones_like(batch_idx) return counts.scatter_add_(0, batch_idx, ones)
[docs] def estimate_ewald_parameters( positions: torch.Tensor, cell: torch.Tensor, batch_idx: torch.Tensor | None = None, accuracy: float = 1e-6, ) -> EwaldParameters: """Estimate optimal Ewald summation parameters for a given accuracy. Uses the Kolafa-Perram formula to balance real-space and reciprocal-space contributions for optimal efficiency at the target accuracy. Parameters ---------- positions : torch.Tensor, shape (N, 3) Atomic coordinates. cell : torch.Tensor, shape (3, 3) or (B, 3, 3) Unit cell matrix. batch_idx : torch.Tensor, shape (N,), dtype=int32, optional System index for each atom. If None, single-system mode. accuracy : float, default=1e-6 Target accuracy (relative error tolerance). Returns ------- EwaldParameters Dataclass containing alpha, real_space_cutoff, reciprocal_space_cutoff as ``torch.Tensor`` objects. """ if cell.ndim == 2: cell = cell.unsqueeze(0) num_systems = cell.shape[0] # Compute volume per system: (B,) volume = torch.abs(torch.linalg.det(cell)).squeeze(-1) # Get number of atoms per system: (B,) num_atoms = _count_atoms_per_system(positions, num_systems, batch_idx).to( positions.dtype ) # Intermediate parameter eta: (B,) eta = (volume**2 / num_atoms) ** (1.0 / 6.0) / math.sqrt(2.0 * math.pi) # Error factor from log(accuracy) error_factor = math.sqrt(-2.0 * math.log(accuracy)) # Real-space cutoff: (B,) real_space_cutoff = error_factor * eta # Reciprocal-space cutoff: (B,) reciprocal_space_cutoff = error_factor / eta # Splitting parameter alpha: (B,) alpha = 1.0 / (math.sqrt(2.0) * eta) return EwaldParameters( alpha=alpha, real_space_cutoff=real_space_cutoff, reciprocal_space_cutoff=reciprocal_space_cutoff, )
[docs] def estimate_pme_mesh_dimensions( cell: torch.Tensor, alpha: torch.Tensor, accuracy: float = 1e-6, mesh_safety_factor: float = 1.0, *, spline_order: int = _DEFAULT_PME_SPLINE_ORDER, fft_padding_fraction: float = _DEFAULT_PME_FFT_PADDING_FRACTION, ) -> tuple[int, int, int]: r"""Return accuracy-based FFT mesh dimensions with spline support. The mesh size along each axis is chosen as .. math:: K_i = \left\lceil \text{mesh\_safety\_factor} \cdot \frac{2\alpha L_i}{3\varepsilon^{1/5}} \right\rceil and rounded upward to a mesh with prime factors 2, 3, 5, and 7. For the default order five, snap to powers of two first, then smooth multiples of four, within the configured budget of extra total mesh points. Other spline orders retain upstream power-of-two accuracy sizing. Each dimension covers the continuous target and assignment support. ``accuracy`` controls the historical fifth-root sizing rule. The estimate retains its relative sizing convention; independent force checks determine the measured error for a particular charge distribution. Parameters ---------- cell : torch.Tensor, shape (3, 3) or (B, 3, 3) Unit cell matrix. alpha : torch.Tensor, shape (B,) Ewald splitting parameter. accuracy : float, default=1e-6 Dimensionless input to the legacy mesh-size heuristic. mesh_safety_factor : float, default=1.0 Caller-requested multiplier on the continuous size estimate. The default leaves the estimate unchanged. spline_order : int, default=5 B-spline assignment order. The mesh covers its assignment support. fft_padding_fraction : float, default=0.25 Maximum fraction of extra total mesh points for snapping to powers of two, then smooth multiples of four. Zero selects the smallest 2/3/5/7-smooth mesh. Explicit other orders keep dyadic accuracy sizing. Returns ------- tuple[int, int, int] Maximum mesh dimensions (nx, ny, nz) across all systems in batch. Notes ----- Measured example on an NVIDIA H100 NVL with Torch: for 131,072 NH3 atoms, snapping from 486 cubed to 512 cubed increased mesh points by 16.92% and reduced the energy, forces, and charge-gradient call from 13.96 ms to 11.17 ms. Both runs used order five, float64, a 9 Angstrom cutoff, and accuracy 1e-6. Times are medians of five groups of ten calls after three warmups; parameter setup and neighbor construction were excluded. """ if cell.ndim == 2: cell = cell.unsqueeze(0) cell_lengths = torch.norm(cell, dim=2) # (B, 3) # Preserve the public estimator's existing relative sizing convention. accuracy_factor = 3.0 * (accuracy**0.2) n = ( mesh_safety_factor * 2.0 * alpha[:, None] * cell_lengths / accuracy_factor ) # (B, 3) max_n = torch.max(n, dim=0).values # (3,) return _round_pme_mesh_dimensions( max_n.detach().cpu().numpy(), spline_order=spline_order, power_of_two=(spline_order != _DEFAULT_PME_SPLINE_ORDER), fft_padding_fraction=fft_padding_fraction, )
[docs] def estimate_pme_parameters( positions: torch.Tensor, cell: torch.Tensor, batch_idx: torch.Tensor | None = None, accuracy: float = 1e-6, real_space_cutoff: float | None = None, mesh_safety_factor: float = 1.0, *, spline_order: int = _DEFAULT_PME_SPLINE_ORDER, fft_padding_fraction: float = _DEFAULT_PME_FFT_PADDING_FRACTION, ) -> PMEParameters: r"""Estimate PME splitting parameters and FFT-friendly mesh dimensions. Uses the closed-form Essmann/Kolafa-Perram derivation: a single length scale :math:`\eta = (V^2 / N)^{1/6} / \sqrt{2\pi}` determines both ``rc`` and ``alpha``. Callers who want to pin a specific cutoff (e.g. tied to neighbor-list update frequency in MD) should pass ``real_space_cutoff``. Parameters ---------- positions : torch.Tensor, shape (N, 3) Atomic coordinates. cell : torch.Tensor, shape (3, 3) or (B, 3, 3) Unit cell matrix. batch_idx : torch.Tensor, shape (N,), dtype=int32, optional System index for each atom. accuracy : float, default=1e-6 Target accuracy. real_space_cutoff : float, optional Caller-supplied cutoff. When given, ``alpha`` is derived from it via :math:`\alpha = \sqrt{-\log\varepsilon} / r_c`; otherwise ``rc`` and ``alpha`` come from ``eta``. mesh_safety_factor : float, default=1.0 Caller-requested multiplier on the continuous mesh-size estimate. spline_order : int, default=5 B-spline assignment order used for mesh sizing. fft_padding_fraction : float, default=0.25 Maximum fraction of extra total mesh points for snapping to powers of two, then smooth multiples of four. Zero selects the smallest 2/3/5/7-smooth mesh. Explicit other orders keep dyadic accuracy sizing. Returns ------- PMEParameters Dataclass containing alpha, mesh dimensions, spacing, and cutoffs. Notes ----- ``accuracy`` retains the public estimator's relative sizing convention. Independent force checks determine the measured error for a charge distribution. """ if cell.ndim == 2: cell = cell.unsqueeze(0) num_systems = cell.shape[0] volume = torch.abs(torch.linalg.det(cell)) num_atoms = _count_atoms_per_system(positions, num_systems, batch_idx).to( positions.dtype ) cell_lengths = torch.norm(cell, dim=2) # (B, 3) # For batched inputs we share a single rc / α across the batch (one # neighbor cutoff for all systems). Use median-system properties. if real_space_cutoff is None: if num_systems == 1: n_repr = float(num_atoms[0].item()) v_repr = float(volume[0].item()) else: n_repr = float(num_atoms.median().item()) v_repr = float(volume.median().item()) eta = (v_repr**2 / n_repr) ** (1.0 / 6.0) / math.sqrt(2.0 * math.pi) rc_value = math.sqrt(-2.0 * math.log(accuracy)) * eta alpha_value = 1.0 / (math.sqrt(2.0) * eta) else: rc_value = float(real_space_cutoff) alpha_value = math.sqrt(-math.log(accuracy)) / rc_value alpha = torch.full( (num_systems,), alpha_value, dtype=positions.dtype, device=positions.device, ) rc_tensor = torch.full( (num_systems,), rc_value, dtype=positions.dtype, device=positions.device, ) mesh_dims = estimate_pme_mesh_dimensions( cell, alpha, accuracy, mesh_safety_factor=mesh_safety_factor, spline_order=spline_order, fft_padding_fraction=fft_padding_fraction, ) mesh_dims_tensor = torch.tensor( mesh_dims, dtype=cell_lengths.dtype, device=cell_lengths.device ) mesh_spacing = cell_lengths / mesh_dims_tensor # (B, 3) return PMEParameters( alpha=alpha, mesh_dimensions=mesh_dims, mesh_spacing=mesh_spacing, real_space_cutoff=rc_tensor, )
[docs] @dataclass class MultipoleEwaldParameters: """Container for GTO-Ewald multipole parameters. Like :class:`EwaldParameters` but with the GTO basis width ``sigma`` propagated through. The Kolafa-Perram balance for the multipole case has the same ``rcut`` / ``kcut`` formulas as the monopole case, but ``alpha`` differs because the effective Ewald split width is ``sigma_c = sqrt(sigma**2 + 1/(4 alpha**2))`` rather than ``1/(alpha sqrt(2))``. Attributes ---------- alpha : torch.Tensor, shape (B,) Ewald splitting parameter (inverse length units). sigma : torch.Tensor, shape (B,) GTO basis width (passed through; physics). real_space_cutoff : torch.Tensor, shape (B,) Real-space cutoff distance. reciprocal_space_cutoff : torch.Tensor, shape (B,) Reciprocal-space cutoff (``|k|`` in inverse length units). """ alpha: torch.Tensor sigma: torch.Tensor real_space_cutoff: torch.Tensor reciprocal_space_cutoff: torch.Tensor
[docs] @dataclass class MultipolePMEParameters: """Container for GTO-Ewald multipole PME parameters. Attributes ---------- alpha : torch.Tensor, shape (B,) Ewald splitting parameter. sigma : torch.Tensor, shape (B,) GTO basis width (passed through; physics). mesh_dimensions : tuple[int, int, int] Mesh dimensions ``(nx, ny, nz)`` (max across batch). mesh_spacing : torch.Tensor, shape (B, 3) Actual mesh spacing per direction. real_space_cutoff : torch.Tensor, shape (B,) Real-space cutoff distance. """ alpha: torch.Tensor sigma: torch.Tensor mesh_dimensions: tuple[int, int, int] mesh_spacing: torch.Tensor real_space_cutoff: torch.Tensor
def _kp_eta(volume: torch.Tensor, num_atoms: torch.Tensor) -> torch.Tensor: r"""Kolafa-Perram cost-balance length scale :math:`\eta = (V^2/N)^{1/6} / \sqrt{2\pi}`. Common to both the monopole and multipole estimators — captures the geometric balance between real-space-pair count and reciprocal-space k-vector count at fixed accuracy. """ return (volume**2 / num_atoms) ** (1.0 / 6.0) / math.sqrt(2.0 * math.pi) def _prepare_sigma( sigma: float | torch.Tensor, num_systems: int, dtype: torch.dtype, device: torch.device, ) -> torch.Tensor: """Broadcast a scalar or per-system ``sigma`` into shape ``(B,)``.""" if isinstance(sigma, torch.Tensor): sig = sigma.to(dtype=dtype, device=device) if sig.ndim == 0: sig = sig.expand(num_systems).clone() elif sig.shape != (num_systems,): raise ValueError( f"sigma must be scalar or shape ({num_systems},); " f"got {tuple(sig.shape)}" ) return sig return torch.full((num_systems,), float(sigma), dtype=dtype, device=device)
[docs] def estimate_multipole_ewald_parameters( positions: torch.Tensor, cell: torch.Tensor, sigma: float | torch.Tensor, batch_idx: torch.Tensor | None = None, accuracy: float = 1e-6, cost_ratio: float = 1.0, ) -> MultipoleEwaldParameters: """Estimate GTO-Ewald multipole parameters at a given target accuracy. Mirrors :func:`estimate_ewald_parameters` semantics ("relative energy-error" accuracy via the Kolafa-Perram envelope), adjusted for the multipole case where the effective Ewald-split width is ``sigma_c = sqrt(sigma**2 + 1/(4 alpha**2))`` rather than ``1/(alpha sqrt(2))``. Derivation ---------- Both the real-space tail (``erfc(r/(2 sigma_c))``) and the reciprocal-space envelope (``exp(-k**2 sigma_c**2)``) decay with the same effective width ``sigma_c * sqrt(2)``. Substituting that for the monopole's ``eta = 1/(alpha * sqrt(2))`` in Kolafa-Perram gives ``rcut = error_factor * eta``, ``kcut = error_factor / eta``, and ``alpha = 1 / (sqrt(2) * sqrt(eta**2 - 2 * sigma**2))``. The ``sigma -> 0`` limit recovers the monopole formula. Cost-ratio correction --------------------- The textbook Kolafa-Perram balance assumes the per-real-space-pair cost equals the per-k-vector cost. On real hardware (and especially for the lmax=1 multipole tile kernels) those costs differ — measured ``C_r / C_k`` is in the 20-40x range for our cluster-pair tile kernels at fp64. The cost-balanced optimum scales as ``eta_eff = eta_KP / cost_ratio**(1/6)``: a 30x cost ratio shrinks rcut by ~1.76x (and grows kcut by the same factor), which can cut the real-space pair count by ~5x at the same target accuracy. The math: with cost ratio ``R = C_r / C_k``, the cost-balanced formula becomes ``eta_eff = (V^2 / (N * R))^(1/6) / sqrt(2 pi)``. Setting ``R = 1`` (default) reproduces the canonical KP estimator. Parameters ---------- positions : torch.Tensor, shape (N, 3) or (N_total, 3) Atomic coordinates. cell : torch.Tensor, shape (3, 3) or (B, 3, 3) Unit cell matrix (matches the multipole-Ewald convention). sigma : float or torch.Tensor GTO basis width — same value used by the multipole-Ewald kernel. Scalar or shape ``(B,)``. batch_idx : torch.Tensor, shape (N_total,), int32, optional System index per atom. ``None`` selects single-system mode. accuracy : float, default 1e-6 Target relative accuracy (matches monopole convention). cost_ratio : float, default 1.0 Empirical ``C_r / C_k`` ratio — per-pair real-space cost divided by per-k-vector reciprocal cost on the target hardware. ``1.0`` (default) reproduces canonical Kolafa-Perram. Higher values shift the optimum toward smaller rcut (fewer pairs) + larger kcut (more k-vectors), which wins when the per-pair cluster-pair tile kernel dominates. For the lmax=1 multipole kernels on a GB10-class GPU, ``cost_ratio`` ~ 30 is a reasonable starting point — measure on your own hardware via the per-pair / per-k timing probe (see ``docs/learnings/`` if archived). Setting below 1 is allowed but rarely useful (the formula is symmetric). Returns ------- MultipoleEwaldParameters ``(alpha, sigma, real_space_cutoff, reciprocal_space_cutoff)``. Raises ------ ValueError If any system has ``eta_eff <= sigma * sqrt(2)`` — meaning the cost-balanced Ewald split is degenerate at this size + sigma combination. Note: the validity threshold scales as ``cost_ratio**(-1/6)`` — large ``cost_ratio`` makes the split more likely to be invalid for small/dense systems. """ if cost_ratio <= 0.0: raise ValueError(f"cost_ratio must be positive, got {cost_ratio}") if cell.ndim == 2: cell = cell.unsqueeze(0) num_systems = cell.shape[0] dtype = positions.dtype device = positions.device volume = torch.abs(torch.linalg.det(cell)).squeeze(-1) num_atoms = _count_atoms_per_system(positions, num_systems, batch_idx).to(dtype) eta_kp = _kp_eta(volume, num_atoms) eta = eta_kp / (cost_ratio ** (1.0 / 6.0)) error_factor = math.sqrt(-2.0 * math.log(accuracy)) real_space_cutoff = error_factor * eta reciprocal_space_cutoff = error_factor / eta sigma_t = _prepare_sigma(sigma, num_systems, dtype, device) # alpha = 1 / (sqrt(2) * sqrt(eta**2 - 2 sigma**2)). The validity # check uses the cost-corrected eta — large cost_ratio shrinks eta, # which can push borderline systems into the invalid regime. discriminant = eta * eta - 2.0 * sigma_t * sigma_t if torch.any(discriminant <= 0.0): bad = (discriminant <= 0.0).nonzero(as_tuple=False).flatten().tolist() raise ValueError( "Multipole Ewald parameter estimation: GTO sigma is too large " f"relative to the system size for systems {bad} at cost_ratio=" f"{cost_ratio}. The cost-balanced eta={eta.tolist()} satisfies " f"eta**2 <= 2 sigma**2 (sigma={sigma_t.tolist()}). Either reduce " "sigma, increase the system, drop cost_ratio toward 1.0, or use " "direct k-space (multipole_electrostatic_energy)." ) alpha = 1.0 / (math.sqrt(2.0) * torch.sqrt(discriminant)) return MultipoleEwaldParameters( alpha=alpha, sigma=sigma_t, real_space_cutoff=real_space_cutoff, reciprocal_space_cutoff=reciprocal_space_cutoff, )
[docs] def estimate_multipole_pme_parameters( positions: torch.Tensor, cell: torch.Tensor, sigma: float | torch.Tensor, batch_idx: torch.Tensor | None = None, accuracy: float = 1e-6, cost_ratio: float = 1.0, *, spline_order: int = _DEFAULT_PME_SPLINE_ORDER, fft_padding_fraction: float = _DEFAULT_PME_FFT_PADDING_FRACTION, ) -> MultipolePMEParameters: """Estimate GTO-Ewald multipole PME parameters at a given target accuracy. Same Kolafa-Perram backbone as :func:`estimate_multipole_ewald_parameters`. Mesh dimensions follow the standard B-spline-error formula ``n_per_dim = 2 alpha_eff L / (3 accuracy**0.2)`` with ``alpha_eff = 1 / (sqrt(2) eta)`` — the monopole-equivalent alpha at the same eta. The ``cost_ratio`` knob has the same meaning as in :func:`estimate_multipole_ewald_parameters` (per-pair vs per-k cost asymmetry), and shifts the rcut/kcut/mesh balance the same way: ``eta_eff = eta_KP / cost_ratio**(1/6)``. A larger ``cost_ratio`` grows the FFT mesh and shrinks the real-space cutoff. Note that PME's true reciprocal cost is FFT (``M log M``) plus spread/gather (``N p**3``), which is not the same shape as the Ewald per-k-vector cost — so the optimal ``cost_ratio`` for PME may differ from the Ewald optimum even on the same hardware. Default ``1.0`` (canonical KP) is a safe starting point. Parameters ---------- positions : torch.Tensor, shape (N, 3) or (N_total, 3) Atomic coordinates. cell : torch.Tensor, shape (3, 3) or (B, 3, 3) Unit cell matrix (matches the multipole-PME convention). sigma : float or torch.Tensor GTO basis width — same value used by the multipole-PME kernel. Scalar or shape ``(B,)``. batch_idx : torch.Tensor, shape (N_total,), int32, optional System index per atom. ``None`` selects single-system mode. accuracy : float, default 1e-6 Target relative accuracy (matches monopole convention). Also drives mesh resolution via the B-spline error envelope. cost_ratio : float, default 1.0 Empirical ``C_r / C_k`` ratio — per-pair real-space cost divided by reciprocal-space cost on the target hardware. ``1.0`` (default) reproduces canonical Kolafa-Perram. Higher values shift the optimum toward smaller ``real_space_cutoff`` (fewer pairs) and a finer FFT mesh (larger ``mesh_dimensions``). For PME the true reciprocal cost is ``O(M log M)`` plus spread/gather, not the per-k-vector cost of direct Ewald summation, so the optimal ``cost_ratio`` may differ from :func:`estimate_multipole_ewald_parameters` even on the same hardware. Setting below 1 is allowed but rarely useful. spline_order : int, default=5 B-spline assignment order. The mesh covers its assignment support. fft_padding_fraction : float, default=0.25 Maximum fraction of extra total mesh points for snapping to powers of two, then smooth multiples of four. Zero selects the smallest 2/3/5/7-smooth mesh. Explicit other orders keep dyadic accuracy sizing. Returns ------- MultipolePMEParameters ``(alpha, sigma, mesh_dimensions, mesh_spacing, real_space_cutoff)``. """ if cost_ratio <= 0.0: raise ValueError(f"cost_ratio must be positive, got {cost_ratio}") if cell.ndim == 2: cell = cell.unsqueeze(0) num_systems = cell.shape[0] dtype = positions.dtype device = positions.device volume = torch.abs(torch.linalg.det(cell)).squeeze(-1) num_atoms = _count_atoms_per_system(positions, num_systems, batch_idx).to(dtype) eta_kp = _kp_eta(volume, num_atoms) eta = eta_kp / (cost_ratio ** (1.0 / 6.0)) error_factor = math.sqrt(-2.0 * math.log(accuracy)) real_space_cutoff = error_factor * eta sigma_t = _prepare_sigma(sigma, num_systems, dtype, device) discriminant = eta * eta - 2.0 * sigma_t * sigma_t if torch.any(discriminant <= 0.0): bad = (discriminant <= 0.0).nonzero(as_tuple=False).flatten().tolist() raise ValueError( "Multipole PME parameter estimation: GTO sigma too large for " f"systems {bad} at cost_ratio={cost_ratio} (eta**2 <= 2 sigma**2). " "Reduce sigma, drop cost_ratio toward 1.0, or use direct k-space." ) alpha = 1.0 / (math.sqrt(2.0) * torch.sqrt(discriminant)) # Mesh resolution from the reciprocal-space B-spline error envelope. # Width is set by eta; alpha_eff = 1/(sqrt(2) eta) (monopole-equivalent). alpha_eff = torch.full_like(eta, 1.0 / math.sqrt(2.0)) / eta mesh_dims = estimate_pme_mesh_dimensions( cell, alpha_eff, accuracy, spline_order=spline_order, fft_padding_fraction=fft_padding_fraction, ) cell_lengths = torch.norm(cell, dim=2) # (B, 3) mesh_dims_tensor = torch.tensor(mesh_dims, dtype=cell_lengths.dtype, device=device) mesh_spacing = cell_lengths / mesh_dims_tensor return MultipolePMEParameters( alpha=alpha, sigma=sigma_t, mesh_dimensions=mesh_dims, mesh_spacing=mesh_spacing, real_space_cutoff=real_space_cutoff, )
[docs] def mesh_spacing_to_dimensions( cell: torch.Tensor, mesh_spacing: float | torch.Tensor, *, spline_order: int = _DEFAULT_PME_SPLINE_ORDER, fft_padding_fraction: float = _DEFAULT_PME_FFT_PADDING_FRACTION, ) -> tuple[int, int, int]: """Convert mesh spacing to mesh dimensions. Parameters ---------- cell : torch.Tensor Unit cell matrix. mesh_spacing : int, float, or torch.Tensor Target mesh spacing. Scalars apply to every cell and direction. Tensor shapes ``(B,)`` and ``(B, 3)`` specify per-system isotropic and per-system anisotropic spacings, respectively. spline_order : int, default=5 B-spline assignment order. Each axis covers its assignment support. fft_padding_fraction : float, default=0.25 Maximum fraction of extra total mesh points for snapping to powers of two, then smooth multiples of four. Zero selects the smallest 2/3/5/7-smooth mesh. These spacing preferences apply to every spline order. Returns ------- tuple[int, int, int] Maximum dimensions across the batch, rounded upward to 2/3/5/7-smooth integers that cover the requested spacing and assignment support. """ if cell.ndim == 2: cell = cell.unsqueeze(0) cell_lengths = torch.norm(cell, dim=2) # (B, 3) if isinstance(mesh_spacing, (int, float)) or ( isinstance(mesh_spacing, torch.Tensor) and mesh_spacing.ndim == 0 ): mesh_dims = torch.ceil(cell_lengths / mesh_spacing) elif isinstance(mesh_spacing, torch.Tensor): if mesh_spacing.ndim == 1: if mesh_spacing.shape[0] != cell.shape[0]: raise ValueError( f"mesh_spacing shape {mesh_spacing.shape} incompatible with " f"cell batch size {cell.shape[0]}" ) mesh_dims = torch.ceil(cell_lengths / mesh_spacing[:, None]) else: if mesh_spacing.shape != cell_lengths.shape: raise ValueError( f"mesh_spacing shape {mesh_spacing.shape} incompatible with " f"cell_lengths shape {cell_lengths.shape}" ) mesh_dims = torch.ceil(cell_lengths / mesh_spacing) else: raise TypeError( f"mesh_spacing must be int, float, or torch.Tensor, got {type(mesh_spacing)}" ) max_mesh_dims = torch.max(mesh_dims, dim=0).values return _round_pme_mesh_dimensions( max_mesh_dims.detach().cpu().numpy(), spline_order=spline_order, fft_padding_fraction=fft_padding_fraction, )