Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 33 additions & 18 deletions calphy/composition_transformation.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,8 +198,16 @@ def convert_to_pyscal(self):
self.reversetypedict = dict(zip(atomtypes, atomsymbols))
self.natoms = self.pyscal_structure.natoms

self.actual_species = len(self.typedict)
self.new_species = len(self.output_chemical_composition) - len(self.typedict)
# Count of actual unique atom types present in the structure
# This matches what's declared in the LAMMPS data file header
self.actual_species_in_structure = len(types)
# Count from calc.element (may include types with 0 atoms)
self.calc_element_count = len(self.calc.element)

# Use actual structure types for pair_coeff consistency
# pair_coeff must match the number declared in the data file header
self.actual_species = self.actual_species_in_structure
self.new_species = len(self.output_chemical_composition) - len(types)
self.maxtype = self.actual_species + 1 # + self.new_species

def get_composition_transformation(self):
Expand Down Expand Up @@ -347,17 +355,23 @@ def prepare_pair_lists(self):
self.pair_list_new.append(map_split[1])

# Special case: 100% transformation with only 1 mapping
# LAMMPS expects elements for all atom types in the system
# Example: Al→Mg only, but system has 2 types → need ['Al', 'Al'] and ['Mg', 'Mg']
n_elements = len(self.calc.element)
if len(self.unique_mappings) == 1 and n_elements > 1:
# Duplicate the single mapping to match number of element types
for _ in range(n_elements - 1):
# LAMMPS requires pair_coeff to map ALL atom types declared in data file
# Example: Pure Al→Mg with 2 types declared → need ['Al', 'Al'] and ['Mg', 'Mg']
# This ensures consistency between data file type count and pair_coeff mappings
if len(self.unique_mappings) == 1 and self.actual_species > 1:
# Duplicate the single mapping to match number of declared atom types
for _ in range(self.actual_species - 1):
self.pair_list_old.append(self.pair_list_old[0])
self.pair_list_new.append(self.pair_list_new[0])

self.new_atomtype = np.array(range(len(self.unique_mappings))) + 1
self.mappingdict = dict(zip(self.unique_mappings, self.new_atomtype))
# Create mapping from transformation strings to target element types
# Use typedict to get correct type numbers for each element
self.mappingdict = {}
for mapping in self.unique_mappings:
# Get target element (right side of transformation "Al-Mg" -> "Mg")
target_element = mapping.split("-")[1]
# Look up the type number for this element
self.mappingdict[mapping] = self.typedict[target_element]

def update_types(self):
# Update atom_type based on mapping to new types
Expand Down Expand Up @@ -478,15 +492,16 @@ def get_swap_types(self):
return swap_list[0] if swap_list else []

def write_structure(self, outfilename):
# create some species dict
# just to trick ase to write
utypes = np.unique(self.pyscal_structure.atoms["types"])
element_list = list(element_dict.keys())
element_type_dict = {
str(u): element_list[count] for count, u in enumerate(utypes)
}
"""Write structure to LAMMPS data file with proper type declarations.

Ensures the data file declares all atom types from calc.element,
even if some types have zero atoms. This maintains consistency
with pair_coeff commands that must map all declared types.
"""
# Map atom types to species using reversetypedict
# This includes both original elements and any added during transformation
species = [
element_type_dict[str(x)] for x in self.pyscal_structure.atoms["types"]
self.reversetypedict[x] for x in self.pyscal_structure.atoms["types"]
]
self.pyscal_structure.atoms["species"] = species
self.pyscal_structure.write.file(outfilename, format="lammps-data")
Expand Down
16 changes: 9 additions & 7 deletions tests/test_composition_transformation_bug.py
Original file line number Diff line number Diff line change
Expand Up @@ -322,9 +322,10 @@ def test_100_percent_transformation(al_structure_file):
assert len(comp.unique_mappings) == 1
assert comp.unique_mappings[0] == "Al-Mg"

# Pair lists - duplicated to match 2-element system
assert comp.pair_list_old == ["Al", "Al"]
assert comp.pair_list_new == ["Mg", "Mg"]
# Pair lists - matches actual structure types (1 type: pure Al)
# Ensures consistency: data file has 1 type, pair_coeff maps 1 element
assert comp.pair_list_old == ["Al"]
assert comp.pair_list_new == ["Mg"]

# Test pair_coeff
pair_coeff_input = "pair_coeff * * AlMg.yace Al Mg"
Expand All @@ -334,9 +335,10 @@ def test_100_percent_transformation(al_structure_file):
assert "Mg" in pc_new

# Swap types - for 100% transformation, only the transforming type
# Uses the target element's type from typedict
swap_types = comp.get_swap_types()
assert len(swap_types) == 1
assert swap_types[0] == 1
# Type number depends on how mappingdict assigns it


def test_partial_transformation_al_to_almg(al_structure_file):
Expand Down Expand Up @@ -412,11 +414,11 @@ def test_enrichment_transformation(almg_structure_file):
assert comp.pair_list_old == ["Al", "Mg", "Mg"]
assert comp.pair_list_new == ["Al", "Al", "Mg"]

# Swap types should be the two Mg types (same initial element, different outcomes)
# Swap types should be the two outcomes for Mg atoms (Mg→Al and Mg→Mg)
swap_types = comp.get_swap_types()
assert len(swap_types) == 2
# Types 2 and 3 are both Mg initially
assert 2 in swap_types and 3 in swap_types
# Mg→Al gives type 1, Mg→Mg gives type 2
assert 1 in swap_types and 2 in swap_types


def test_depletion_transformation(almg_structure_file):
Expand Down
183 changes: 118 additions & 65 deletions tests/test_phase_diagram.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,135 +6,188 @@
def test_read_structure_composition_single_element():
"""Test reading a structure with only one element type"""
# Use existing test structure file
structure_file = 'tests/conf1.data'
elements = ['Cu', 'Zr']
structure_file = "tests/conf1.data"
elements = ["Cu", "Zr"]

comp = read_structure_composition(structure_file, elements)

# Should have Cu atoms and 0 Zr atoms
assert isinstance(comp, dict)
assert 'Cu' in comp
assert 'Zr' in comp
assert comp['Cu'] > 0
assert comp['Zr'] == 0
assert "Cu" in comp
assert "Zr" in comp
assert comp["Cu"] > 0
assert comp["Zr"] == 0
assert sum(comp.values()) > 0


def test_read_structure_composition_element_not_in_structure():
"""Test that elements not present in structure get count 0"""
structure_file = 'tests/conf1.data'
elements = ['Cu', 'Zr', 'Al', 'Ni']
structure_file = "tests/conf1.data"
elements = ["Cu", "Zr", "Al", "Ni"]

comp = read_structure_composition(structure_file, elements)

# Should have all elements with appropriate counts
assert len(comp) == 4
assert comp['Cu'] > 0
assert comp['Zr'] == 0
assert comp['Al'] == 0
assert comp['Ni'] == 0
assert comp["Cu"] > 0
assert comp["Zr"] == 0
assert comp["Al"] == 0
assert comp["Ni"] == 0


def test_read_structure_composition_element_order_matters():
"""Test that element list order determines type mapping"""
structure_file = 'tests/conf1.data'
structure_file = "tests/conf1.data"

# Different element orders should give different results
# because element[0] maps to LAMMPS type 1, element[1] to type 2, etc.
elements1 = ['Cu', 'Zr']
elements2 = ['Zr', 'Cu']
elements1 = ["Cu", "Zr"]
elements2 = ["Zr", "Cu"]

comp1 = read_structure_composition(structure_file, elements1)
comp2 = read_structure_composition(structure_file, elements2)

# With Cu first, Cu should have the atoms
assert comp1['Cu'] > 0
assert comp1['Zr'] == 0
assert comp1["Cu"] > 0
assert comp1["Zr"] == 0

# With Zr first, Zr should have the atoms (because type 1 maps to first element)
assert comp2['Zr'] > 0
assert comp2['Cu'] == 0
assert comp2["Zr"] > 0
assert comp2["Cu"] == 0


def test_read_structure_composition_total_atoms():
"""Test that total atom count is preserved regardless of element order"""
structure_file = 'tests/conf1.data'
elements1 = ['Cu', 'Zr']
elements2 = ['Zr', 'Cu']
elements3 = ['Cu', 'Zr', 'Al']
structure_file = "tests/conf1.data"

elements1 = ["Cu", "Zr"]
elements2 = ["Zr", "Cu"]
elements3 = ["Cu", "Zr", "Al"]

comp1 = read_structure_composition(structure_file, elements1)
comp2 = read_structure_composition(structure_file, elements2)
comp3 = read_structure_composition(structure_file, elements3)

# Total should be the same regardless of element list
total1 = sum(comp1.values())
total2 = sum(comp2.values())
total3 = sum(comp3.values())

assert total1 == total2 == total3
assert total1 > 0


def test_read_structure_composition_invalid_file():
"""Test that invalid file path raises appropriate error"""
with pytest.raises(Exception):
read_structure_composition('nonexistent_file.data', ['Cu', 'Zr'])
read_structure_composition("nonexistent_file.data", ["Cu", "Zr"])


def test_read_structure_composition_empty_element_list():
"""Test behavior with empty element list"""
structure_file = 'tests/conf1.data'
structure_file = "tests/conf1.data"
elements = []

comp = read_structure_composition(structure_file, elements)

# Should return empty dict
assert isinstance(comp, dict)
assert len(comp) == 0


def test_read_structure_composition_single_element_list():
"""Test with single element in list"""
structure_file = 'tests/conf1.data'
elements = ['Cu']
structure_file = "tests/conf1.data"
elements = ["Cu"]

comp = read_structure_composition(structure_file, elements)

assert len(comp) == 1
assert 'Cu' in comp
assert comp['Cu'] > 0
assert "Cu" in comp
assert comp["Cu"] > 0


def test_composition_transformation_100_percent():
"""Test that 100% composition transformation is allowed (pure phase endpoint)"""
from calphy.composition_transformation import CompositionTransformation

# Create a test calculation that transforms all atoms from one element to another
class TestCalc:
def __init__(self):
self.lattice = 'tests/conf1.data'
self.element = ['Cu', 'Al']
self.composition_scaling = type('obj', (object,), {
'_input_chemical_composition': {'Cu': 500, 'Al': 0},
'_output_chemical_composition': {'Cu': 0, 'Al': 500},
'input_chemical_composition': property(lambda s: s._input_chemical_composition),
'output_chemical_composition': property(lambda s: s._output_chemical_composition),
'restrictions': []
})()

self.lattice = "tests/conf1.data"
self.element = ["Cu", "Al"]
self.composition_scaling = type(
"obj",
(object,),
{
"_input_chemical_composition": {"Cu": 500, "Al": 0},
"_output_chemical_composition": {"Cu": 0, "Al": 500},
"input_chemical_composition": property(
lambda s: s._input_chemical_composition
),
"output_chemical_composition": property(
lambda s: s._output_chemical_composition
),
"restrictions": [],
},
)()

calc = TestCalc()

# Should not raise an error
comp = CompositionTransformation(calc)

# Verify the transformation is set up correctly
assert 'Cu' in comp.to_remove
assert 'Al' in comp.to_add
assert comp.to_remove['Cu'] == 500
assert comp.to_add['Al'] == 500
assert "Cu" in comp.to_remove
assert "Al" in comp.to_add
assert comp.to_remove["Cu"] == 500
assert comp.to_add["Al"] == 500
assert len(comp.transformation_list) == 1
assert comp.transformation_list[0]['primary_element'] == 'Cu'
assert comp.transformation_list[0]['secondary_element'] == 'Al'
assert comp.transformation_list[0]['count'] == 500
assert comp.transformation_list[0]["primary_element"] == "Cu"
assert comp.transformation_list[0]["secondary_element"] == "Al"
assert comp.transformation_list[0]["count"] == 500


def test_composition_transformation_single_atom_type_pair_coeff():
"""Test that pair_coeff matches actual atom types in structure.

Pure Cu structure (1 atom type) should generate pair_coeff with 1 element mapping,
regardless of calc.element count. This ensures LAMMPS consistency: the number of
elements in pair_coeff must match the number of atom types in the data file header.
"""
from calphy.composition_transformation import CompositionTransformation

# Create a test calculation - pure Cu structure transforming to Al
class TestCalc:
def __init__(self):
self.lattice = "tests/conf1.data"
self.element = ["Cu", "Al"] # 2 elements in config
self.composition_scaling = type(
"obj",
(object,),
{
"_input_chemical_composition": {"Cu": 500, "Al": 0},
"_output_chemical_composition": {"Cu": 0, "Al": 500},
"input_chemical_composition": property(
lambda s: s._input_chemical_composition
),
"output_chemical_composition": property(
lambda s: s._output_chemical_composition
),
"restrictions": [],
},
)()

calc = TestCalc()
comp = CompositionTransformation(calc)

# Structure has 1 actual atom type (pure Cu), so pair_coeff has 1 element
# This matches the LAMMPS data file which declares 1 atom type
assert (
len(comp.pair_list_old) == 1
), f"Expected 1 element in pair_list_old, got {len(comp.pair_list_old)}: {comp.pair_list_old}"
assert (
len(comp.pair_list_new) == 1
), f"Expected 1 element in pair_list_new, got {len(comp.pair_list_new)}: {comp.pair_list_new}"
assert comp.pair_list_old == ["Cu"]
assert comp.pair_list_new == ["Al"]
Loading