diff --git a/calphy/input.py b/calphy/input.py index 175e7e5..d20edef 100644 --- a/calphy/input.py +++ b/calphy/input.py @@ -92,6 +92,49 @@ def _to_float(val): return [float(x) for x in val] +def _extract_elements_from_pair_coeff(pair_coeff_string): + """ + Extract element symbols from pair_coeff string. + Returns None if pair_coeff doesn't contain element specifications. + + Parameters + ---------- + pair_coeff_string : str + The pair_coeff command string (e.g., "* * potential.eam.fs Cu Zr") + + Returns + ------- + list or None + List of element symbols in order, or None if no elements found + """ + if pair_coeff_string is None: + return None + + pcsplit = pair_coeff_string.strip().split() + elements = [] + + # Start collecting after we find element symbols + # Elements are typically after the potential filename + started = False + + for p in pcsplit: + # Check if this looks like an element symbol + # Element symbols are 1-2 characters, start with uppercase + if len(p) <= 2 and p[0].isupper(): + try: + # Verify it's a valid element using mendeleev + _ = mendeleev.element(p) + elements.append(p) + started = True + except: + # Not a valid element, might be done collecting + if started: + # We already started collecting elements and hit a non-element + break + + return elements if len(elements) > 0 else None + + class UFMP(BaseModel, title="UFM potential input options"): p: Annotated[float, Field(default=50.0)] sigma: Annotated[float, Field(default=1.5)] @@ -313,6 +356,39 @@ def _validate_all(self) -> "Input": raise ValueError("mass and elements should have same length") self.n_elements = len(self.element) + + # Validate element/mass/pair_coeff ordering consistency + # This is critical for multi-element systems where LAMMPS type numbers + # are assigned based on element order: element[0]=Type1, element[1]=Type2, etc. + if len(self.element) > 1 and self.pair_coeff is not None and len(self.pair_coeff) > 0: + extracted_elements = _extract_elements_from_pair_coeff(self.pair_coeff[0]) + + if extracted_elements is not None: + # pair_coeff specifies elements - check ordering + if set(extracted_elements) != set(self.element): + raise ValueError( + f"Element mismatch between 'element' and 'pair_coeff'!\n" + f" element: {self.element}\n" + f" pair_coeff: {extracted_elements}\n" + f"The elements specified must be the same." + ) + + if list(extracted_elements) != list(self.element): + raise ValueError( + f"Element ordering mismatch detected!\n\n" + f" element: {self.element}\n" + f" pair_coeff: {extracted_elements}\n" + f" mass: {self.mass}\n\n" + f"For multi-element systems, all three must be in the SAME order.\n\n" + f"Why this matters:\n" + f" - Element order determines LAMMPS type numbers:\n" + f" element[0] → Type 1, element[1] → Type 2, etc.\n" + f" - The pair_coeff elements must match this type order\n" + f" - The mass values must correspond to the same order\n" + f" - Composition transformations depend on this ordering\n\n" + f"Please reorder your input so element, mass, and pair_coeff\n" + f"all use the same element ordering." + ) self._pressure_input = copy.copy(self.pressure) if self.pressure is None: diff --git a/tests/test_element_ordering.py b/tests/test_element_ordering.py new file mode 100644 index 0000000..edd9107 --- /dev/null +++ b/tests/test_element_ordering.py @@ -0,0 +1,141 @@ +import pytest +from calphy.input import Calculation, read_inputfile + + +def test_correct_element_ordering(): + """Test that correct element ordering is accepted""" + calc_dict = { + 'element': ['Cu', 'Zr'], + 'mass': [63.546, 91.224], + 'pair_coeff': ['* * potential.eam.fs Cu Zr'], + 'pair_style': ['eam/fs'], + 'mode': 'fe', + 'temperature': 1000, + 'pressure': 0, + 'lattice': 'tests/conf1.data', # Use existing data file + } + + # Should not raise any exception + calc = Calculation(**calc_dict) + assert calc.element == ['Cu', 'Zr'] + assert calc.mass == [63.546, 91.224] + + +def test_wrong_element_ordering(): + """Test that mismatched element ordering is rejected""" + calc_dict = { + 'element': ['Cu', 'Zr'], + 'mass': [63.546, 91.224], + 'pair_coeff': ['* * potential.eam.fs Zr Cu'], # Wrong order! + 'pair_style': ['eam/fs'], + 'mode': 'fe', + 'temperature': 1000, + 'pressure': 0, + 'lattice': 'fcc', + 'lattice_constant': 3.61, + } + + with pytest.raises(ValueError) as exc_info: + calc = Calculation(**calc_dict) + + assert 'ordering mismatch' in str(exc_info.value).lower() + + +def test_single_element_no_ordering_issue(): + """Test that single element systems don't trigger ordering validation""" + calc_dict = { + 'element': ['Cu'], + 'mass': [63.546], + 'pair_coeff': ['* * potential.eam Cu'], + 'pair_style': ['eam'], + 'mode': 'fe', + 'temperature': 1000, + 'pressure': 0, + 'lattice': 'fcc', + 'lattice_constant': 3.61, + } + + # Should not raise any exception for single element + calc = Calculation(**calc_dict) + assert calc.element == ['Cu'] + + +def test_no_elements_in_pair_coeff(): + """Test that pair_coeff without element names skips validation""" + calc_dict = { + 'element': ['Cu', 'Zr'], + 'mass': [63.546, 91.224], + 'pair_coeff': ['* * potential.eam'], # No elements specified + 'pair_style': ['eam'], + 'mode': 'fe', + 'temperature': 1000, + 'pressure': 0, + 'lattice': 'tests/conf1.data', # Use existing data file + } + + # Should not raise exception when pair_coeff has no elements + calc = Calculation(**calc_dict) + assert calc.element == ['Cu', 'Zr'] + + +def test_element_mismatch(): + """Test that completely different elements in pair_coeff are rejected""" + calc_dict = { + 'element': ['Cu', 'Zr'], + 'mass': [63.546, 91.224], + 'pair_coeff': ['* * potential.eam.fs Al Ni'], # Different elements! + 'pair_style': ['eam/fs'], + 'mode': 'fe', + 'temperature': 1000, + 'pressure': 0, + 'lattice': 'fcc', + 'lattice_constant': 3.61, + } + + with pytest.raises(ValueError) as exc_info: + calc = Calculation(**calc_dict) + + assert 'element mismatch' in str(exc_info.value).lower() + + +def test_three_element_ordering(): + """Test ordering validation works for 3+ elements""" + # Correct ordering + calc_dict = { + 'element': ['Cu', 'Zr', 'Al'], + 'mass': [63.546, 91.224, 26.982], + 'pair_coeff': ['* * potential.eam.fs Cu Zr Al'], + 'pair_style': ['eam/fs'], + 'mode': 'fe', + 'temperature': 1000, + 'pressure': 0, + 'lattice': 'tests/conf1.data', # Use existing data file + } + + calc = Calculation(**calc_dict) + assert calc.element == ['Cu', 'Zr', 'Al'] + + # Wrong ordering + calc_dict_wrong = { + 'element': ['Cu', 'Zr', 'Al'], + 'mass': [63.546, 91.224, 26.982], + 'pair_coeff': ['* * potential.eam.fs Al Cu Zr'], # Different order + 'pair_style': ['eam/fs'], + 'mode': 'fe', + 'temperature': 1000, + 'pressure': 0, + 'lattice': 'tests/conf1.data', # Use existing data file + } + + with pytest.raises(ValueError) as exc_info: + calc = Calculation(**calc_dict_wrong) + + assert 'ordering mismatch' in str(exc_info.value).lower() + + +def test_existing_example_files(): + """Test that existing example files still load correctly""" + # Single element examples should work + calcs = read_inputfile('tests/input.yaml') + assert len(calcs) > 0 + assert calcs[0].element is not None