diff --git a/deepmd/utils/pair_tab.py b/deepmd/utils/pair_tab.py index 503f721c98..e951cc7f30 100644 --- a/deepmd/utils/pair_tab.py +++ b/deepmd/utils/pair_tab.py @@ -57,17 +57,48 @@ 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] + hh = vdata[1][0] - vdata[0][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_ref = (rmax - rmin) / (n - 1) + deviation = np.abs(vdata[:, 0] - (rmin + hh_ref * np.arange(n))) + tol = 1e-2 * abs(hh_ref) + 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_ref}). 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/source/tests/common/dpmodel/test_pairtab_preprocess.py b/source/tests/common/dpmodel/test_pairtab_preprocess.py index 93a61bc1f6..e194a624cc 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,133 @@ 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) + self.assertAlmostEqual(tab.hh, 1e-9) + + @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) + self.assertAlmostEqual(tab.hh, rr[1] - rr[0], places=6) + + if __name__ == "__main__": unittest.main(warnings="ignore")