# 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.
r"""
GTO Basis Normalization — Host-Side Scaffolding
===============================================
Binding-layer helpers for building the :math:`(l, \sigma)`-dependent
normalization scaling tables that the multipole Ewald/PME and atom-centered
feature kernels consume as Warp constant arrays.
The Warp ``@wp.func`` / ``@wp.kernel`` primitives for GTO basis evaluation
live in :mod:`nvalchemiops.math.gto`. This module is strictly CPU scaffolding
— it runs once at module-construction time to produce small constant tables
that are then shipped to the GPU.
"""
from __future__ import annotations
import math
from enum import IntEnum
import torch
import warp as wp
from nvalchemiops.math.gto import (
_eval_gto_density_kernel,
_eval_gto_fourier_kernel,
)
from nvalchemiops.torch._warp_op_helpers import scoped_warp_stream
[docs]
class NormMode(IntEnum):
r"""GTO basis normalization conventions used by the multipole pipeline.
The Fourier transform of a GTO basis function carries a per-:math:`(l, \sigma)`
scaling factor :math:`1 / C_l(\sigma, \text{mode})`. Three mode choices are
supported, corresponding to the three normalization denominators defined below:
``MULTIPOLES``
.. math::
C_l^{-1}(\sigma) = \sqrt{\frac{4\pi}{2l+1}} \cdot 2^{(2l+1)/2}
\cdot \Gamma\!\left(\frac{2l+3}{2}\right) \cdot \sigma^{2l+3}
A GTO with coefficient :math:`q` integrates to a charge distribution whose
:math:`(2l+1)`-component multipole moment is exactly :math:`q`. Use on the
density/source side of the Ewald/PME pipeline.
``RECEIVER``
.. math::
C_l^{-1}(\sigma) = 2^{(l+1)/2} \cdot \Gamma\!\left(\frac{l+3}{2}\right) \cdot \sigma^{l+3}
A GTO tested against a uniform potential gives a unit projection. Use on the
feature/projection side of LODE-style atom-centered electrostatic descriptors.
``NONE``
:math:`C_l^{-1}(\sigma) = 1`. Un-normalized basis; primarily for debugging
and cross-checks.
Notes
-----
The enum values are stable integers (0, 1, 2). Downstream Warp kernels consume
the scaling factors via ``wp.constant`` arrays built by :func:`inv_cl_table`;
kernels that branch on mode do so on the ``int`` underlying the enum.
"""
MULTIPOLES = 0
RECEIVER = 1
NONE = 2
def inv_cl(sigma: float, L: int, mode: NormMode) -> float:
r"""Scaling factor :math:`1 / C_l(\sigma, \text{mode})` for GTO basis Fourier coefficients.
Parameters
----------
sigma
Gaussian width parameter. Must be positive.
L
Angular momentum quantum number (``l`` in the math). Must be non-negative.
mode
Normalization convention. See :class:`NormMode`.
Returns
-------
float
The multiplier to apply when normalizing a GTO Fourier coefficient.
Raises
------
ValueError
If ``sigma <= 0`` or ``L < 0``.
"""
if sigma <= 0.0:
raise ValueError(f"sigma must be positive, got {sigma}")
if L < 0:
raise ValueError(f"L must be non-negative, got {L}")
mode = NormMode(mode)
if mode == NormMode.NONE:
return 1.0
if mode == NormMode.MULTIPOLES:
l_part = (
math.sqrt(4.0 * math.pi / (2 * L + 1))
* 2.0 ** ((2 * L + 1) / 2.0)
* math.gamma((2 * L + 3) / 2.0)
)
denom = l_part * sigma ** (2 * L + 3)
else: # RECEIVER
l_part = 2.0 ** ((L + 1) / 2.0) * math.gamma((L + 3) / 2.0)
denom = l_part * sigma ** (L + 3)
return 1.0 / denom
def inv_cl_table(sigma: float, max_L: int, mode: NormMode) -> list[float]:
r"""Precomputed table of :func:`inv_cl` for :math:`L = 0, 1, \ldots, \text{max\_L}`.
Parameters
----------
sigma
Gaussian width parameter. Must be positive.
max_L
Maximum angular momentum (inclusive). Must be non-negative.
mode
Normalization convention. See :class:`NormMode`.
Returns
-------
list[float]
Length-``max_L + 1`` list of scaling factors. Entry ``L`` holds
``inv_cl(sigma, L, mode)``.
"""
if max_L < 0:
raise ValueError(f"max_L must be non-negative, got {max_L}")
return [inv_cl(sigma, L, mode) for L in range(max_L + 1)]
# =============================================================================
# PyTorch launch wrappers
# =============================================================================
def eval_gto_density_pytorch(
positions,
sigma: float,
L_max: int = 2,
device=None,
):
"""Evaluate GTO densities from PyTorch tensors.
Parameters
----------
positions : torch.Tensor
Input positions [N, 3] as float64.
sigma : float
Gaussian width parameter.
L_max : int
Maximum angular momentum (0, 1, or 2). Default: 2.
device : torch.device, optional
Device for computation.
Returns
-------
torch.Tensor
GTO density values [N, num_components].
"""
if device is None:
device = positions.device
N = positions.shape[0]
num_components = {0: 1, 1: 4, 2: 9}[L_max]
with scoped_warp_stream(positions.device):
output = torch.zeros((N, num_components), dtype=torch.float64, device=device)
wp_device = wp.device_from_torch(device)
wp_positions = wp.from_torch(positions.contiguous(), dtype=wp.vec3d)
wp_output = wp.from_torch(output, dtype=wp.float64)
wp.launch(
kernel=_eval_gto_density_kernel,
dim=N,
inputs=[wp_positions, wp.float64(sigma), L_max],
outputs=[wp_output],
device=wp_device,
)
return output
def eval_gto_fourier_pytorch(
k_vectors,
sigma: float,
L_max: int = 2,
device=None,
):
"""Evaluate GTO Fourier transforms from PyTorch tensors.
Parameters
----------
k_vectors : torch.Tensor
Input wave vectors [K, 3] as float64.
sigma : float
Gaussian width parameter.
L_max : int
Maximum angular momentum (0, 1, or 2). Default: 2.
device : torch.device, optional
Device for computation.
Returns
-------
tuple[torch.Tensor, torch.Tensor]
(real_part, imag_part) each of shape [K, num_components].
"""
if device is None:
device = k_vectors.device
K = k_vectors.shape[0]
num_components = {0: 1, 1: 4, 2: 9}[L_max]
with scoped_warp_stream(k_vectors.device):
output_real = torch.zeros(
(K, num_components), dtype=torch.float64, device=device
)
output_imag = torch.zeros(
(K, num_components), dtype=torch.float64, device=device
)
wp_device = wp.device_from_torch(device)
wp_k = wp.from_torch(k_vectors.contiguous(), dtype=wp.vec3d)
wp_real = wp.from_torch(output_real, dtype=wp.float64)
wp_imag = wp.from_torch(output_imag, dtype=wp.float64)
wp.launch(
kernel=_eval_gto_fourier_kernel,
dim=K,
inputs=[wp_k, wp.float64(sigma), L_max],
outputs=[wp_real, wp_imag],
device=wp_device,
)
return output_real, output_imag