pyscf / pyscf/gpu4pyscf

PCM analytical IR dipole disagrees with finite-difference PCM dipoles

Open
#844 4 comments 1 reaction 1 assignee View on GitHub

@puzhichen is already working on this.

Since Aug 20, 2026.

feature request
Dominant language
Cuda
Stars
351
Forks
84
Avg merge
3d 2h
Merged PRs (30d)
35

Description

Using GPU4PySCF 1.8.0 with PySCF 2.13.1, the analytical dipole derivative used by gpu4pyscf.properties.ir.eval_ir_freq_intensity disagrees substantially with central finite differences of PCM dipole moments.

Minimal system:

H  0 0 0
O  0 0 1
H  0 1 1

Settings: B3LYP/STO-3G, IEF-PCM with ε = 2.3741.

The analytical and numerical Cartesian dipole derivatives were projected through exactly the same externally computed normal modes from the same analytical Hessian to avoid differences from mode ordering, normalization, or translation/rotation projection.

Frequency (cm⁻¹) Analytical Finite difference Relative difference
2068.5596 0.787536 0.594638 +32.44%
3974.7373 9.690058 11.701083 −17.19%
4109.9406 5.679714 8.989973 −36.82%

The finite-difference step was 0.005 Å. A gas-phase control agrees within 0.4%, which suggests to me that the discrepancy is specific to solvent-response contributions to the analytical dipole derivative (or similar).

Calculations run through Modal on an A10 GPU.

Here's some minimal scripts to show the setup, although these don't do the mode-projection work mentioned above so it's not quite a 1:1 comparison. (Disclaimer: Codex was used to write these scripts.)

"""Minimal analytical PCM IR reproducer for GPU4PySCF 1.8.0."""

import numpy as np
from gpu4pyscf.dft import rks
from gpu4pyscf.properties import ir
from pyscf import gto

ATOM = """
H  0.0  0.0  0.0
O  0.0  0.0  1.0
H  0.0  1.0  1.0
"""


def asnumpy(value):
    get = getattr(value, "get", None)
    return np.asarray(get() if callable(get) else value)


mol = gto.M(atom=ATOM, basis="sto-3g", unit="Angstrom", verbose=0)
mf = rks.RKS(mol, xc="B3LYP").density_fit().PCM()
mf.with_solvent.method = "IEF-PCM"
mf.with_solvent.eps = 2.3741  # toluene
mf.grids.atom_grid = (99, 590)
mf.level_shift = 0.1
mf.conv_tol = 1e-10
mf.conv_tol_cpscf = 1e-7
assert np.isfinite(mf.kernel()) and mf.converged

frequency, intensity = ir.eval_ir_freq_intensity(mf, mf.Hessian())
for freq, inten in zip(asnumpy(frequency), asnumpy(intensity), strict=True):
    print(f"{freq:12.6f} cm^-1  {inten:12.6f} km/mol")

"""Finite-difference PCM dipole reference for GPU4PySCF 1.8.0."""

import numpy as np
from gpu4pyscf.dft import rks
from pyscf import gto
from pyscf.hessian import thermo

SYMBOLS = ("H", "O", "H")
COORDS = np.array(
    [
        [0.0, 0.0, 0.0],
        [0.0, 0.0, 1.0],
        [0.0, 1.0, 1.0],
    ]
)
STEP_ANGSTROM = 0.005
IR_CONVERSION = 42.255  # (D / Angstrom / sqrt(amu))^2 -> km/mol


def asnumpy(value):
    get = getattr(value, "get", None)
    return np.asarray(get() if callable(get) else value)


def run_scf(coords):
    mol = gto.M(
        atom=list(zip(SYMBOLS, coords, strict=True)),
        basis="sto-3g",
        unit="Angstrom",
        verbose=0,
    )
    mf = rks.RKS(mol, xc="B3LYP").density_fit().PCM()
    mf.with_solvent.method = "IEF-PCM"
    mf.with_solvent.eps = 2.3741  # toluene
    mf.grids.atom_grid = (99, 590)
    mf.level_shift = 0.1
    mf.conv_tol = 1e-10
    mf.conv_tol_cpscf = 1e-7
    assert np.isfinite(mf.kernel()) and mf.converged
    return mf


reference = run_scf(COORDS)
raw_hessian = asnumpy(reference.Hessian().kernel())
analysis = thermo.harmonic_analysis(
    reference.mol,
    raw_hessian,
    imaginary_freq=False,
)

# d(mu_Debye) / d(x_Angstrom), axes (atom, coordinate, dipole component)
dipole_gradient = np.empty((len(SYMBOLS), 3, 3))
for atom in range(len(SYMBOLS)):
    for axis in range(3):
        plus = COORDS.copy()
        minus = COORDS.copy()
        plus[atom, axis] += STEP_ANGSTROM
        minus[atom, axis] -= STEP_ANGSTROM
        mu_plus = asnumpy(run_scf(plus).dip_moment(verbose=0))
        mu_minus = asnumpy(run_scf(minus).dip_moment(verbose=0))
        dipole_gradient[atom, axis] = (mu_plus - mu_minus) / (2 * STEP_ANGSTROM)

# PySCF norm_mode axes are (mode, atom, coordinate), mass-normalized.
dmu_dq = np.einsum("mac,acd->md", analysis["norm_mode"], dipole_gradient)
intensity = IR_CONVERSION * np.einsum("md,md->m", dmu_dq, dmu_dq)

for freq, inten in zip(analysis["freq_wavenumber"], intensity, strict=True):
    print(f"{freq:12.6f} cm^-1  {inten:12.6f} km/mol")

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.