diff --git a/deepmd/utils/pair_tab.py b/deepmd/utils/pair_tab.py index 503f721c98..1b86ff9d80 100644 --- a/deepmd/utils/pair_tab.py +++ b/deepmd/utils/pair_tab.py @@ -57,17 +57,49 @@ def reinit(self, filename: str, rcut: float | None = None) -> None: if filename is None: self.tab_info, self.tab_data = None, None return - self.vdata = np.loadtxt(filename, dtype=self.data_type) - self.rmin = self.vdata[0][0] - self.rmax = self.vdata[-1][0] - self.hh = self.vdata[1][0] - self.vdata[0][0] - ncol = self.vdata.shape[1] - 1 + vdata = np.loadtxt(filename, dtype=self.data_type) + rmin = vdata[0][0] + rmax = vdata[-1][0] + dx = np.diff(vdata[:, 0]) + if not np.all(dx > 0): + raise ValueError( + f"The distance grid in the pairwise table {filename} is not " + "strictly increasing. The tabulated potential must be provided " + "on a uniform grid with distances sorted in ascending order and " + "without duplicated rows. Please regrid the table." + ) + # validate against absolute node positions rather than per-interval + # spacing: consumers (the C++ kernel and _make_data) index by + # rmin + i * hh, so that is what must stay accurate, not each dx. + n = vdata.shape[0] + hh = (rmax - rmin) / (n - 1) + deviation = np.abs( + vdata[:, 0] - (rmin + hh * np.arange(n, dtype=self.data_type)) + ) + tol = 1e-2 * abs(hh) + if np.any(deviation > tol): + bad_row = int(np.argmax(deviation > tol)) + raise ValueError( + f"The distance grid in the pairwise table {filename} is not " + "evenly spaced. The tabulated potential must be provided on a " + f"uniform grid, but row {bad_row} (distance " + f"{vdata[bad_row, 0]}) does not match the constant step " + f"inferred from rmin and rmax ({hh}). Please regrid the " + "table to use a constant distance step." + ) + ncol = vdata.shape[1] - 1 n0 = (-1 + np.sqrt(1 + 8 * ncol)) * 0.5 - self.ntypes = int(n0 + 0.1) - assert self.ntypes * (self.ntypes + 1) // 2 == ncol, ( - f"number of volumes provided in {filename} does not match guessed number of types {self.ntypes}" + ntypes = int(n0 + 0.1) + assert ntypes * (ntypes + 1) // 2 == ncol, ( + f"number of volumes provided in {filename} does not match guessed number of types {ntypes}" ) + self.vdata = vdata + self.rmin = rmin + self.rmax = rmax + self.hh = hh + self.ntypes = ntypes + # check table data against rcut and update tab_file if needed, table upper boundary is used as rcut if not provided. self.rcut = rcut if rcut is not None else self.rmax self._check_table_upper_boundary() diff --git a/doc/model/pairtab.md b/doc/model/pairtab.md index 3cb6cf12f3..d1ff80056d 100644 --- a/doc/model/pairtab.md +++ b/doc/model/pairtab.md @@ -54,6 +54,10 @@ DeePMD-kit also supports combination with a pairwise potential {{ tensorflow_ico The table file should be a text file that can be read by {py:meth}`numpy.loadtxt`. The first column is the distance between two atoms, where upper range should be larger than the cutoff radius. +It must be strictly increasing and evenly spaced: every distance has to sit within one percent of a grid step of `rmin + i * hh`, +where `rmin` is the first distance and `hh` is the constant step inferred from the first and last distances. +A table that violates this raises a `ValueError` when the model is constructed, because the spline coefficients and both +evaluators index the table by that constant step and cannot represent a non-uniform grid. Other columns are two-body interaction energies for pairs of certain types, in the order of Type_0-Type_0, Type_0-Type_1, ..., Type_0-Type_N, Type_1-Type_1, ..., Type_1-Type_N, ..., and Type_N-Type_N. diff --git a/source/tests/common/dpmodel/test_pairtab_preprocess.py b/source/tests/common/dpmodel/test_pairtab_preprocess.py index 93a61bc1f6..88ea7f3c90 100644 --- a/source/tests/common/dpmodel/test_pairtab_preprocess.py +++ b/source/tests/common/dpmodel/test_pairtab_preprocess.py @@ -1,4 +1,7 @@ # SPDX-License-Identifier: LGPL-3.0-or-later +import copy +import os +import tempfile import unittest from unittest.mock import ( patch, @@ -275,5 +278,141 @@ def test_preprocess(self) -> None: ) +class TestPairTabGridSpacing(unittest.TestCase): + @patch("numpy.loadtxt") + def test_non_uniform_grid(self, mock_loadtxt) -> None: + mock_loadtxt.return_value = np.array( + [ + [0.00, 1.0], + [0.01, 0.8], + [0.02, 0.6], + [0.09, 0.3], + [0.16, 0.0], + ] + ) + with self.assertRaisesRegex(ValueError, "evenly spaced"): + PairTab(filename="dummy_path", rcut=0.16) + + @patch("numpy.loadtxt") + def test_duplicate_distances(self, mock_loadtxt) -> None: + mock_loadtxt.return_value = np.array( + [ + [0.01, 1.0], + [0.01, 0.8], + [0.01, 0.6], + [0.01, 0.0], + ] + ) + with self.assertRaisesRegex(ValueError, "strictly increasing"): + PairTab(filename="dummy_path", rcut=0.04) + + @patch("numpy.loadtxt") + def test_descending_grid(self, mock_loadtxt) -> None: + mock_loadtxt.return_value = np.array( + [ + [0.04, 1.0], + [0.03, 0.8], + [0.02, 0.6], + [0.01, 0.0], + ] + ) + with self.assertRaisesRegex(ValueError, "strictly increasing"): + PairTab(filename="dummy_path", rcut=0.04) + + @patch("numpy.loadtxt") + def test_non_uniform_fine_grid(self, mock_loadtxt) -> None: + mock_loadtxt.return_value = np.array( + [ + [0.0, 1.0], + [1e-10, 0.8], + [1.1e-9, 0.6], + [2.1e-9, 0.3], + [3.1e-9, 0.0], + ] + ) + with self.assertRaisesRegex(ValueError, "evenly spaced"): + PairTab(filename="dummy_path", rcut=3.1e-9) + + @patch("numpy.loadtxt") + def test_uniform_fine_grid(self, mock_loadtxt) -> None: + mock_loadtxt.return_value = np.array( + [ + [0.0, 1.0], + [1e-9, 0.8], + [2e-9, 0.6], + [3e-9, 0.3], + [4e-9, 0.0], + ] + ) + tab = PairTab(filename="dummy_path", rcut=4e-9) + np.testing.assert_allclose(tab.hh, 1e-9, rtol=1e-6) + + @patch("numpy.loadtxt") + def test_hh_from_node_positions_not_first_interval(self, mock_loadtxt) -> None: + rr = np.linspace(0.0, 1.0, 1001) + rr[1] += 9.9e-6 + mock_loadtxt.return_value = np.stack((rr, np.zeros_like(rr)), axis=1) + tab = PairTab(filename="dummy_path", rcut=1.0) + np.testing.assert_allclose(tab.hh, 1.0 / 1000, rtol=1e-6) + + @patch("numpy.loadtxt") + def test_failed_reinit_keeps_state(self, mock_loadtxt) -> None: + uniform = np.array( + [ + [0.00, 1.0], + [0.01, 0.8], + [0.02, 0.6], + [0.03, 0.3], + [0.04, 0.0], + ] + ) + mock_loadtxt.return_value = uniform + tab = PairTab(filename="dummy_path", rcut=0.04) + expected = copy.deepcopy(tab.serialize()) + + mock_loadtxt.return_value = np.array( + [ + [0.00, 1.0], + [0.01, 0.8], + [0.02, 0.6], + [0.09, 0.3], + [0.16, 0.0], + ] + ) + with self.assertRaisesRegex(ValueError, "evenly spaced"): + tab.reinit(filename="dummy_path", rcut=0.16) + + actual = tab.serialize() + for key in ("rmin", "rmax", "hh", "ntypes", "rcut", "nspline"): + self.assertEqual(actual[key], expected[key]) + for key in ("vdata", "tab_info", "tab_data"): + np.testing.assert_array_equal( + actual["@variables"][key], expected["@variables"][key] + ) + + @patch("numpy.loadtxt") + def test_uniform_grid(self, mock_loadtxt) -> None: + mock_loadtxt.return_value = np.array( + [ + [0.00, 1.0], + [0.01, 0.8], + [0.02, 0.6], + [0.03, 0.3], + [0.04, 0.0], + ] + ) + tab = PairTab(filename="dummy_path", rcut=0.04) + np.testing.assert_allclose(tab.hh, 0.01) + + def test_uniform_grid_rounded_text_precision(self) -> None: + rr = np.linspace(0.0, 6.0, 1000) + ee = np.exp(-rr) + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "table.txt") + np.savetxt(path, np.stack((rr, ee), axis=1), fmt="%.6f") + tab = PairTab(filename=path) + np.testing.assert_allclose(tab.hh, 6.0 / 999, rtol=1e-6) + + if __name__ == "__main__": unittest.main(warnings="ignore")