Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Laplacian method for Species class #131

Merged
merged 9 commits into from
Dec 21, 2024
59 changes: 54 additions & 5 deletions atomdb/datasets/numeric/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,17 @@

import atomdb

from atomdb.utils import MULTIPLICITIES
from atomdb.utils import MULTIPLICITIES, DEFAULT_DATAPATH

from atomdb.periodic import Element


def load_numerical_hf_data():
def load_numerical_hf_data(data_path=None):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not def load_numerical_hf_data(data_path=DEFAULT_DATAPATH): ?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even more, I think this function should not have a default value. The only place where it is used inside run (line 183), a value is passed to this argument. I think the function that could/should have a default value should be the run function (line 155).

Copy link
Collaborator Author

@gabrielasd gabrielasd Dec 16, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point!
I think I'll go along the lines of your first suggestion making the change to simply
def load_numerical_hf_data(data_path)

I wouldn't make the change to the run function to keep consistency across all compilation scripts, and since the default path gets defined as kwd argument in the load function from the species module:

AtomDB/atomdb/species.py

Lines 782 to 790 in 511a702

def load(
elem,
charge,
mult,
nexc=0,
dataset=DEFAULT_DATASET,
datapath=DEFAULT_DATAPATH,
remotepath=DEFAULT_REMOTE,
):

The way I see it, the run functions will hardly ever get used directly by the user, so having the default value of datapath only in load (and compile) seems OK to me.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks OK to me. If @marco-2023 and @gabrielasd are happy with it's fine to merge IMHO.

"""Load data from desnity.out file into a `SpeciesTable`."""
# set the data path
if data_path is None:
data_path = DEFAULT_DATAPATH
data_path = os.path.join(data_path, "numeric", "raw")

from io import StringIO

Expand All @@ -56,7 +60,7 @@ def helper_data():
return data[:, 0], data[:, 1], data[:, 2], data[:, 3]

species = {}
with open(os.path.join(os.path.dirname(__file__), "raw/density.out"), "r") as f:
with open(os.path.join(data_path, "density.out"), "r") as f:
line = f.readline()
while line:
if line.startswith(" 1st line is atomic no"):
Expand Down Expand Up @@ -99,6 +103,48 @@ def helper_data():
return species


def eval_radial_dd_density(gradient, laplacian, points, err='ignore', tol=1e-10):
"""Helper function to compute the radial second derivative of the density.

From a set of radial points :math:`r`, the gradient of the density, :math:`df/dr`, and the
Laplacian of the density, :math:`\nabla^2 f`, the radial second derivative of the density is
computed as:

.. math::
d/dr (df/dr) = \nabla^2 f - 2/r * df/dr

Parameters
----------
gradient : np.ndarray
Gradient of the density.
laplacian : np.ndarray
Laplacian of the density.
points : np.ndarray
Radial points where the density gradient and Laplacian are evaluated.
err : str, optional
Error handling for division by zero.
tol : float, optional
Tolerance for the points close to zero.

Returns
-------
d2dens : np.ndarray
Radial second derivative of the density.

Notes
-----
When the points are close to zero, the radial second derivative of the density tends to infinity.
In this case, this function returns zero.

"""
# Handle the case when the points are close to zero
with np.errstate(divide=err):
# Compute the radial second derivative of the density
d2dens = laplacian - 2 * gradient / points
d2dens = np.where(points < tol, 0.0, d2dens)
return d2dens


DOCSTRING = """Numeric Dataset

Load data from desnity.out file into a `SpeciesTable`.
Expand Down Expand Up @@ -134,7 +180,7 @@ def run(elem, charge, mult, nexc, dataset, datapath):
f"Expected multiplicity is {expected_mult}."
)

species_table = load_numerical_hf_data()
species_table = load_numerical_hf_data(data_path=datapath)
data = species_table[(atnum, nelec)]

#
Expand Down Expand Up @@ -165,6 +211,9 @@ def run(elem, charge, mult, nexc, dataset, datapath):
lapl_tot = data["laplacian"]
ked_tot = None

# Compute the second derivative of the density
dd_dens_tot = eval_radial_dd_density(d_dens_tot, lapl_tot, points)

# Return Species instance
fields = dict(
elem=elem,
Expand All @@ -183,7 +232,7 @@ def run(elem, charge, mult, nexc, dataset, datapath):
rs=points,
dens_tot=dens_tot,
d_dens_tot=d_dens_tot,
dd_dens_tot=lapl_tot,
dd_dens_tot=dd_dens_tot,
ked_tot=ked_tot,
)
return atomdb.Species(dataset, fields)
56 changes: 51 additions & 5 deletions atomdb/species.py
Original file line number Diff line number Diff line change
Expand Up @@ -658,7 +658,7 @@ def d_dens_func(self):
@spline
def dd_dens_func(self):
r"""
Return a cubic spline of the electronic density Laplacian.
Return a cubic spline of the second derivative of the electronic density.

Parameters
----------
Expand All @@ -675,14 +675,60 @@ def dd_dens_func(self):

Returns
-------
DensitySpline
A DensitySpline instance for the density and its derivatives.
Given a set of radial points, it can evaluate densities and
derivatives up to order 2.
Callable[[np.ndarray(N,), int] -> np.ndarray(N,)]
a callable function evaluating the second derivative of the density given a set of radial
points (1-D array).

"""
pass

def dd_dens_lapl_func(self, spin="t", index=None, log=False):
r"""
Return the function for the electronic density Laplacian.

.. math::

\nabla^2 \rho(\mathbf{r}) = \frac{d^2 \rho(r)}{dr^2} + \frac{2}{r} \frac{d \rho(r)}{dr}

Parameters
----------
spin : str, default="t"
Type of occupied spin orbitals.
Can be either "t" (for alpha + beta), "a" (for alpha),
"b" (for beta), or "m" (for alpha - beta).
index : sequence of int, optional
Sequence of integers representing the spin orbitals.
These are indexed from 0 to the number of basis functions.
By default, all orbitals of the given spin(s) are included.
log : bool, default=False
Whether the logarithm of the density is used for interpolation.

Returns
-------
Callable[np.ndarray(N,) -> np.ndarray(N,)]
a callable function evaluating the Laplacian of the density given a set of radial
points (1-D array).

Notes
-----
When this function is evaluated at a point close to zero, the Laplacian becomes undefined.
In this case, this function returns zero.

"""
# Obtain cubic spline functions for the first and second derivatives of the density
d_dens_sp_spline = self.d_dens_func(spin=spin, index=index, log=log)
dd_dens_spline = self.dd_dens_func(spin=spin, index=index, log=log)

# Define the Laplacian function
def densityspline_like_func(rs):
# Avoid division by zero and handle small values of r
with np.errstate(divide='ignore'):
laplacian = dd_dens_spline(rs) + 2 * d_dens_sp_spline(rs) / rs
laplacian = np.where(rs < 1e-10, 0.0, laplacian)
return laplacian

return densityspline_like_func

@spline
def ked_func(self):
r"""
Expand Down
Binary file modified atomdb/test/data/numeric/db/Be_000_001_000.msg
Binary file not shown.
Binary file modified atomdb/test/data/numeric/db/Cl_000_002_000.msg
Binary file not shown.
Binary file modified atomdb/test/data/numeric/db/H_-01_001_000.msg
Binary file not shown.
Binary file modified atomdb/test/data/numeric/db/H_000_002_000.msg
Binary file not shown.
Binary file modified atomdb/test/data/numeric/db/Ne_000_001_000.msg
Binary file not shown.
79 changes: 45 additions & 34 deletions atomdb/test/test_numeric.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,18 +64,13 @@ def test_numerical_hf_data_h():
assert np.allclose(sp._data.dens_tot[-2:], [0.0, 0.0], atol=1e-10)

# evaluate radial density gradient (first derivative of density spline)
dens = sp.dens_func(spin="t", log=True)
gradient = dens(sp._data.rs, deriv=1)
gradient = sp.d_dens_func(log=False)(sp._data.rs)

# load gradient reference values from numerical HF raw files
fname = "001_q000_m02_numeric_gradient.npy"
ref_grad = np.load(f"{TEST_DATAPATH}/numeric/db/{fname}")

# check interpolated gradient values against reference
# close to the nuclei the spline derivative does not describe the gradient well
assert np.allclose(gradient[:2], ref_grad[:2], atol=1e-3)
# away from the nuclei the spline derivative describes the gradient well
assert np.allclose(gradient[-2:], ref_grad[-2:], atol=1e-10)
# check interpolated gradient values against reference values from numerical HF raw files
# close to the nuclei
assert np.allclose(gradient[:2], [-0.636619761671399, -0.613581739284137], atol=1e-10)
# away from the nuclei
assert np.allclose(gradient[-2:], [0., 0.], atol=1e-10)


def test_numerical_hf_data_h_anion():
Expand Down Expand Up @@ -116,18 +111,12 @@ def test_numerical_hf_data_h_anion():
assert np.allclose(sp._data.dens_tot[-20:], 0.0, atol=1e-10)

# evaluate radial density gradient (first derivative of density spline)
dens = sp.dens_func(spin="t", log=True)
gradient = dens(sp._data.rs, deriv=1)

# load gradient reference values from numerical HF raw files
fname = "001_q-01_m01_numeric_gradient.npy"
ref_grad = np.load(f"{TEST_DATAPATH}/numeric/db/{fname}")
gradient = sp.d_dens_func(log=False)(sp._data.rs)

# check interpolated gradient values against reference
# close to the nuclei the spline derivative does not describe the gradient well
assert np.allclose(gradient[:2], ref_grad[:2], atol=1e-3)
# away from the nuclei the spline derivative describes the gradient well
assert np.allclose(gradient[-2:], ref_grad[-2:], atol=1e-10)
# check interpolated gradient values against reference values from numerical HF raw files
assert np.allclose(gradient[:2], [-0.618386750431843, -0.594311093621533], atol=1e-10)
assert np.allclose(gradient[20:22], [-0.543476018733641, -0.538979599233911], atol=1e-10)
assert np.allclose(gradient[-20:], [0.] * 20, atol=1e-10)


@pytest.mark.parametrize(
Expand Down Expand Up @@ -163,7 +152,7 @@ def test_numerical_hf_atomic_density(atom, mult, npoints, nelec):
assert all(sp._data.dens_tot >= 0.0)

# check the density integrates to the correct number of electrons
assert_almost_equal(4 * np.pi * np.trapz(grid**2 * dens, grid), nelec, decimal=2)
assert_almost_equal(4 * np.pi * np.trapezoid(grid**2 * dens, grid), nelec, decimal=2)

# get density spline and check its values
spline = sp.dens_func(spin="t", log=True)
Expand Down Expand Up @@ -195,26 +184,48 @@ def test_numerical_hf_density_gradient(atom, charge, mult):
assert np.allclose(gradient[indx_radii], ref_grad[indx_radii], atol=1e-3)


@pytest.mark.xfail(reason="High errors in spline derivative of order 2")
@pytest.mark.xfail(reason="High errors in spline derivative of order 2 at intermediate distances")
@pytest.mark.parametrize(
"atom, charge, mult", [("H", 0, 2), ("H", -1, 1), ("Be", 0, 1), ("Cl", 0, 3), ("Ne", 0, 1)]
"atom, charge, mult", [("H", 0, 2), ("H", -1, 1), ("Be", 0, 1), ("Cl", 0, 2), ("Ne", 0, 1)]
)
def test_numerical_hf_density_laplacian(atom, charge, mult):
def test_numerical_hf_dd_density(atom, charge, mult):
# load atomic and density data
sp = load(atom, charge, mult, dataset="numeric", datapath=TEST_DATAPATH)

# evaluate density and laplacian (second derivative of density spline) on the radial grid
grid = sp._data.rs
spline = sp.dens_func(spin="t", log=False)
lapl = spline(grid, deriv=2)
# evaluate the second derivative of the density on the radial grid
dd_dens = sp.dd_dens_func(log=False)(sp._data.rs)

# check shape of arrays
assert lapl.shape == grid.shape
assert dd_dens.shape == sp._data.rs.shape

# check interpolated density derivative values against reference values
# far away from the nuclei, the second derivative of the density is close to zero
assert np.allclose(dd_dens[-10:], [0.]*10, atol=1e-10)
# for r=0, the second derivative of the density is set to zero
assert np.allclose(dd_dens[0], [0.], atol=1e-10)

# WARNING: The values of the second order derivative of the density at intermediate r distances
# are not tested. Comparisong agains deriv=2 of the density spline:
# ref_dd_dens = sp.dens_func(log=True)(sp._data.rs, deriv=2)
# results in high errors, rendering this test case as unreliable.


@pytest.mark.parametrize(
"atom, charge, mult", [("H", 0, 2), ("H", -1, 1), ("Be", 0, 1), ("Cl", 0, 2), ("Ne", 0, 1)]
)
def test_numerical_hf_density_laplacian(atom, charge, mult):
# load atomic and density data
sp = load(atom, charge, mult, dataset="numeric", datapath=TEST_DATAPATH)

# evaluate the Laplacian of the density on the radial grid
laplacian_dens = sp.dd_dens_lapl_func(log=False)(sp._data.rs)

# load reference values from numerical HF raw files
id = f"{str(sp.atnum).zfill(3)}_q{str(charge).zfill(3)}_m{mult:02d}"
fname = f"{id}_numeric_laplacian.npy"
ref_lapl = np.load(f"{TEST_DATAPATH}/numeric/db/{fname}")

# interpolated laplacian values against reference
assert np.allclose(lapl, ref_lapl, atol=1e-6)

# check interpolated Laplacian of density values against reference values
assert np.allclose(laplacian_dens, ref_lapl, atol=1e-10)
# for r=0, the Laplacian function in not well defined and is set to zero
assert np.allclose(laplacian_dens[0], [0.], atol=1e-10)
Loading