diff --git a/dpgen2/exploration/task/caly_task_group.py b/dpgen2/exploration/task/caly_task_group.py index 14594f38..8661bf31 100644 --- a/dpgen2/exploration/task/caly_task_group.py +++ b/dpgen2/exploration/task/caly_task_group.py @@ -115,6 +115,18 @@ def set_params( self.numb_of_species = numb_of_species self.numb_of_atoms = numb_of_atoms + if numb_of_species != len(name_of_atoms): + raise ValueError( + f"numb_of_species={numb_of_species} does not match " + f"name_of_atoms={name_of_atoms!r}" + ) + for atoms in name_of_atoms: + for atom in atoms if isinstance(atoms, list) else [atoms]: + if atom not in atomic_number_map or atom == "X": + raise ValueError( + f"unknown element {atom!r} in name_of_atoms={name_of_atoms!r}" + ) + if isinstance(name_of_atoms, list) and all( [isinstance(i, list) for i in name_of_atoms] ): @@ -144,12 +156,32 @@ def set_params( self.atomic_number = [atomic_symbols.index(i) for i in self.name_of_atoms] else: self.name_of_atoms = name_of_atoms - self.atomic_number = atomic_number + self.atomic_number = ( + [atomic_number_map[name] for name in self.name_of_atoms] + if atomic_number is None + else atomic_number + ) - if isinstance(distance_of_ions, dict): + if distance_of_ions is None or isinstance(distance_of_ions, dict): + # Generate a complete distance matrix from the maintained + # covalent-radius table when the optional matrix is omitted. updated_table = copy.deepcopy(covalent_radii) - for key, value in distance_of_ions.items(): + overrides = distance_of_ions if isinstance(distance_of_ions, dict) else {} + for key, value in overrides.items(): + if key not in atomic_number_map or key == "X": + raise ValueError( + f"unknown element {key!r} in distance_of_ions overrides" + ) updated_table[atomic_number_map[key]] = value + for atom in self.name_of_atoms: + if ( + covalent_radii[atomic_number_map[atom]] == UNKN + and atom not in overrides + ): + raise ValueError( + f"no known covalent radius for {atom!r}; provide an explicit " + "distance_of_ions matrix or a radius override for this element" + ) temp_distance_mtx = np.zeros((numb_of_species, numb_of_species)) for i in range(numb_of_species): diff --git a/dpgen2/exploration/task/make_task_group_from_config.py b/dpgen2/exploration/task/make_task_group_from_config.py index d3b4ce52..2736348d 100644 --- a/dpgen2/exploration/task/make_task_group_from_config.py +++ b/dpgen2/exploration/task/make_task_group_from_config.py @@ -382,7 +382,7 @@ def caly_task_grp_args(): "atomic_number", list, optional=True, - doc="atomic number of each element.", + doc="Atomic number of each element. Inferred from name_of_atoms when omitted.", ), Argument( "numb_of_atoms", @@ -394,7 +394,13 @@ def caly_task_grp_args(): "distance_of_ions", [list, dict], optional=True, - doc="the distance matrix between different elements.", + doc=( + "Minimum interatomic distance matrix in angstrom. When omitted, " + "each entry is 0.7 times the sum of the two Cordero covalent radii, " + "rounded to two decimal places. A dict overrides radii by element " + "before computing the matrix. Elements without a known radius " + "require an explicit matrix or a radius override." + ), ), Argument( "pop_size", diff --git a/tests/exploration/test_caly_defaults.py b/tests/exploration/test_caly_defaults.py new file mode 100644 index 00000000..f9e092ef --- /dev/null +++ b/tests/exploration/test_caly_defaults.py @@ -0,0 +1,102 @@ +"""Numerical defaults and explicit values in CALYPSO task generation.""" + +import unittest + +import numpy as np + +from dpgen2.exploration.task import ( + make_calypso_task_group_from_config, +) + + +class TestCalyDefaults(unittest.TestCase): + def setUp(self): + self.config = { + "name_of_atoms": ["Li", "La"], + "numb_of_atoms": [10, 10], + "numb_of_species": 2, + } + + def test_infers_optional_atomic_numbers_and_distances(self): + """Pin physical values, including the serialized CALYPSO input.""" + group = make_calypso_task_group_from_config(self.config) + self.assertEqual(group.atomic_number, [3, 57]) + np.testing.assert_allclose(group.distance_of_ions, [[1.79, 2.34], [2.34, 2.90]]) + tasks = group.make_task() + self.assertEqual(len(tasks), 1) + contents = tasks[0].files()["input.dat"] + self.assertIn("AtomicNumber = 3 57", contents) + self.assertIn("@DistanceOfIon\n1.79 2.34\n2.34 2.9\n@End", contents) + + def test_radius_override_changes_only_the_requested_element(self): + group = make_calypso_task_group_from_config( + dict(self.config, distance_of_ions={"Li": 2.0}) + ) + self.assertEqual(group.atomic_number, [3, 57]) + np.testing.assert_allclose(group.distance_of_ions, [[2.8, 2.85], [2.85, 2.9]]) + self.assertEqual(len(group.make_task()), 1) + # Overrides must not mutate the shared default-radius table. + defaults = make_calypso_task_group_from_config(self.config) + np.testing.assert_allclose( + defaults.distance_of_ions, [[1.79, 2.34], [2.34, 2.9]] + ) + + def test_each_optional_field_can_be_omitted_independently(self): + matrix = [[2.0, 3.0], [3.0, 4.0]] + group = make_calypso_task_group_from_config( + dict(self.config, distance_of_ions=matrix) + ) + self.assertEqual(group.atomic_number, [3, 57]) + np.testing.assert_allclose(group.distance_of_ions, matrix) + self.assertEqual(len(group.make_task()), 1) + group = make_calypso_task_group_from_config( + dict(self.config, atomic_number=[3, 57]) + ) + self.assertEqual(group.atomic_number, [3, 57]) + np.testing.assert_allclose(group.distance_of_ions, [[1.79, 2.34], [2.34, 2.9]]) + self.assertEqual(len(group.make_task()), 1) + + def test_explicit_lists_are_preserved(self): + matrix = [[2.0, 3.0], [3.0, 4.0]] + group = make_calypso_task_group_from_config( + dict(self.config, atomic_number=[3, 57], distance_of_ions=matrix) + ) + self.assertEqual(group.atomic_number, [3, 57]) + np.testing.assert_allclose(group.distance_of_ions, matrix) + + def test_species_count_reports_both_inputs(self): + with self.assertRaisesRegex(ValueError, "numb_of_species=3.*name_of_atoms"): + make_calypso_task_group_from_config(dict(self.config, numb_of_species=3)) + + def test_unknown_element_reports_the_configuration_key(self): + for names in (["Li", "Xx"], ["Li", "X"], [["Li", "Xx"], ["La"]]): + with self.subTest(names=names): + with self.assertRaisesRegex( + ValueError, "unknown element.*name_of_atoms" + ): + make_calypso_task_group_from_config( + dict(self.config, name_of_atoms=names) + ) + with self.assertRaisesRegex(ValueError, "unknown element.*distance_of_ions"): + make_calypso_task_group_from_config( + dict(self.config, distance_of_ions={"Xx": 2.0}) + ) + + def test_unknown_radius_requires_an_explicit_value(self): + config = {"name_of_atoms": ["Cf"], "numb_of_atoms": [1], "numb_of_species": 1} + for distance in (None, {}): + with self.subTest(distance=distance): + with self.assertRaisesRegex(ValueError, "no known covalent radius.*Cf"): + make_calypso_task_group_from_config( + config + if distance is None + else dict(config, distance_of_ions=distance) + ) + for distance, expected in (({"Cf": 2.0}, [[2.8]]), ([[3.0]], [[3.0]])): + with self.subTest(distance=distance): + group = make_calypso_task_group_from_config( + dict(config, distance_of_ions=distance) + ) + self.assertEqual(group.atomic_number, [98]) + np.testing.assert_allclose(group.distance_of_ions, expected) + self.assertEqual(len(group.make_task()), 1)