Skip to content

Commit b6dfb23

Browse files
committed
add fix for compscale
1 parent 4489abe commit b6dfb23

File tree

2 files changed

+216
-31
lines changed

2 files changed

+216
-31
lines changed

calphy/composition_transformation.py

Lines changed: 34 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,17 @@ def convert_to_pyscal(self):
172172
"""
173173
Convert a given system to pyscal and give a dict of type mappings
174174
"""
175-
aseobj = read(self.calc.lattice, format="lammps-data", style="atomic")
175+
# Create Z_of_type mapping to properly read LAMMPS data files
176+
# This ensures atoms are correctly identified by their element
177+
Z_of_type = dict(
178+
[
179+
(count + 1, element(el).atomic_number)
180+
for count, el in enumerate(self.calc.element)
181+
]
182+
)
183+
aseobj = read(
184+
self.calc.lattice, format="lammps-data", style="atomic", Z_of_type=Z_of_type
185+
)
176186
pstruct = pc.System(aseobj, format="ase")
177187

178188
# here we have to validate the input composition dict; and map it
@@ -191,7 +201,6 @@ def convert_to_pyscal(self):
191201
self.actual_species = len(self.typedict)
192202
self.new_species = len(self.output_chemical_composition) - len(self.typedict)
193203
self.maxtype = self.actual_species + 1 # + self.new_species
194-
# print(self.typedict)
195204

196205
def get_composition_transformation(self):
197206
"""
@@ -215,26 +224,28 @@ def get_composition_transformation(self):
215224
self.to_remove = to_remove
216225
self.to_add = to_add
217226

218-
def get_random_index_of_species(self, species):
227+
def get_random_index_of_species(self, species_name):
219228
"""
220-
Get a random index of a given species
229+
Get a random index of a given species by element name
221230
"""
222-
ids = [count for count, x in enumerate(self.atom_type) if x == species]
231+
ids = [count for count, x in enumerate(self.atom_species) if x == species_name]
223232
return ids[np.random.randint(0, len(ids))]
224233

225234
def mark_atoms(self):
226235
for i in range(self.natoms):
227236
self.atom_mark.append(False)
228237

238+
# Use species (element symbols) instead of numeric types
239+
self.atom_species = self.pyscal_structure.atoms.species
229240
self.atom_type = self.pyscal_structure.atoms.types
230-
self.mappings = [f"{x}-{x}" for x in self.atom_type]
241+
self.mappings = [f"{x}-{x}" for x in self.atom_species]
231242

232243
def update_mark_atoms(self):
233244
self.marked_atoms = []
234245
for key, val in self.to_remove.items():
235-
# print(f"Element {key}, count {val}")
246+
# key is the element name (e.g., "Mg")
236247
for i in range(100000):
237-
rint = self.get_random_index_of_species(self.typedict[key])
248+
rint = self.get_random_index_of_species(key)
238249
if rint not in self.marked_atoms:
239250
self.atom_mark[rint] = True
240251
self.marked_atoms.append(rint)
@@ -257,22 +268,20 @@ def update_typedicts(self):
257268

258269
def compute_possible_mappings(self):
259270
self.possible_mappings = []
260-
# Now make a list of possible mappings
271+
# Now make a list of possible mappings using element names
261272
for key1, val1 in self.to_remove.items():
262273
for key2, val2 in self.to_add.items():
263274
mapping = f"{key1}-{key2}"
264275
if mapping not in self.restrictions:
265-
self.possible_mappings.append(
266-
f"{self.typedict[key1]}-{self.typedict[key2]}"
267-
)
276+
self.possible_mappings.append(mapping)
268277

269278
def update_mappings(self):
270279
marked_atoms = self.marked_atoms.copy()
271280
for key, val in self.to_add.items():
272281
# now get all
273282

274283
# we to see if we can get val number of atoms from marked ones
275-
if val < len(marked_atoms):
284+
if val > len(marked_atoms):
276285
raise ValueError(
277286
f"Not enough atoms to choose {val} from {len(marked_atoms)} not possible"
278287
)
@@ -284,8 +293,8 @@ def update_mappings(self):
284293
to_del = []
285294
for x in range(len(self.marked_atoms)):
286295
random_choice = np.random.choice(marked_atoms)
287-
# find corresponding mappiong
288-
mapping = f"{self.atom_type[random_choice]}-{self.typedict[key]}"
296+
# find corresponding mapping using species name
297+
mapping = f"{self.atom_species[random_choice]}-{key}"
289298
if mapping in self.possible_mappings:
290299
# this is a valid choice
291300
self.mappings[random_choice] = mapping
@@ -314,12 +323,8 @@ def update_mappings(self):
314323
mapsplit = mapping.split("-")
315324
if not mapsplit[0] == mapsplit[1]:
316325
transformation_dict = {}
317-
transformation_dict["primary_element"] = self.reversetypedict[
318-
int(mapsplit[0])
319-
]
320-
transformation_dict["secondary_element"] = self.reversetypedict[
321-
int(mapsplit[1])
322-
]
326+
transformation_dict["primary_element"] = mapsplit[0]
327+
transformation_dict["secondary_element"] = mapsplit[1]
323328
transformation_dict["count"] = self.unique_mapping_counts[count]
324329
self.transformation_list.append(transformation_dict)
325330

@@ -333,25 +338,23 @@ def prepare_pair_lists(self):
333338
self.pair_list_new = []
334339
for mapping in self.unique_mappings:
335340
map_split = mapping.split("-")
336-
# conserved atom
341+
# conserved atom - mappings now use element names directly
337342
if map_split[0] == map_split[1]:
338-
self.pair_list_old.append(self.reversetypedict[int(map_split[0])])
339-
self.pair_list_new.append(self.reversetypedict[int(map_split[0])])
343+
self.pair_list_old.append(map_split[0])
344+
self.pair_list_new.append(map_split[0])
340345
else:
341-
self.pair_list_old.append(self.reversetypedict[int(map_split[0])])
342-
self.pair_list_new.append(self.reversetypedict[int(map_split[1])])
346+
self.pair_list_old.append(map_split[0])
347+
self.pair_list_new.append(map_split[1])
343348
self.new_atomtype = np.array(range(len(self.unique_mappings))) + 1
344349
self.mappingdict = dict(zip(self.unique_mappings, self.new_atomtype))
345350

346351
def update_types(self):
352+
# Update atom_type based on mapping to new types
347353
for x in range(len(self.atom_type)):
348354
self.atom_type[x] = self.mappingdict[self.mappings[x]]
349355

350-
# smartify these loops
351-
# npyscal = len(self.pyscal_structure.atoms.types)
356+
# Update pyscal structure types
352357
self.pyscal_structure.atoms.types = self.atom_type
353-
# for count in range(npyscal)):
354-
# self.pyscal_structure.atoms.types[count] = self.atom_type[count]
355358

356359
def iselement(self, symbol):
357360
try:
@@ -392,7 +395,7 @@ def update_pair_coeff(self, pair_coeff):
392395
# If element_group matches our pair_list_old, replace with pair_list_new
393396
# Otherwise replace with pair_list_old (for the old/reference command)
394397
if element_group == self.pair_list_old or set(element_group) == set(
395-
self.element
398+
self.calc.element
396399
):
397400
# This needs special handling - we'll mark position for later
398401
result_parts.append("__ELEMENTS__")
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
"""
2+
Test for composition transformation bug fix.
3+
4+
This test verifies that composition_transformation.py correctly handles
5+
LAMMPS data files where all atoms are of a single type that needs to be
6+
partially transformed to another element.
7+
8+
Bug: When reading LAMMPS data files, the code wasn't providing Z_of_type
9+
mapping, causing ASE to incorrectly identify atom types. Additionally, the
10+
code was using pyscal's numeric types instead of element species names.
11+
"""
12+
13+
import pytest
14+
import numpy as np
15+
import os
16+
import tempfile
17+
from calphy.composition_transformation import CompositionTransformation
18+
from calphy.phase_diagram import SimpleCalculation
19+
20+
21+
@pytest.fixture
22+
def mg_structure_file():
23+
"""Create a temporary LAMMPS data file with only Mg atoms (all type 2)."""
24+
with tempfile.TemporaryDirectory() as tmpdir:
25+
filepath = os.path.join(tmpdir, "Mg.lammps.data")
26+
27+
# Simple HCP Mg structure - all atoms are type 2
28+
natoms = 2048
29+
content = f"""(written by ASE)
30+
31+
{natoms} atoms
32+
2 atom types
33+
34+
0.0 25.68 xlo xhi
35+
0.0 44.479064738368763 ylo yhi
36+
0.0 41.93544 zlo zhi
37+
38+
Atoms # atomic
39+
40+
"""
41+
42+
# Generate atom positions for a simple HCP lattice
43+
nx, ny, nz = 8, 8, 16 # Should give 8*8*16*2 = 2048 atoms
44+
lx, ly, lz = 25.68, 44.479064738368763, 41.93544
45+
46+
atom_id = 1
47+
for ix in range(nx):
48+
for iy in range(ny):
49+
for iz in range(nz):
50+
for sublat in range(2):
51+
x = (ix + sublat * 0.5) * lx / nx
52+
y = (iy + sublat * 0.5) * ly / ny
53+
z = (iz + sublat * 0.5) * lz / nz
54+
content += f" {atom_id:4d} 2 {x:20.15f} {y:20.15f} {z:20.15f}\n"
55+
atom_id += 1
56+
57+
with open(filepath, 'w') as f:
58+
f.write(content)
59+
60+
yield filepath
61+
62+
63+
def test_composition_transformation_single_type_file(mg_structure_file):
64+
"""
65+
Test composition transformation when starting structure has all atoms
66+
of a single type (type 2 = Mg) and we want to add Al atoms.
67+
68+
Input: 2048 Mg atoms (all type 2 in LAMMPS file)
69+
Output: 102 Al + 1946 Mg atoms
70+
71+
This test reproduces the bug where the transformation fails with
72+
"ValueError: high <= 0" due to incorrect type/species handling.
73+
"""
74+
# Create a simple calculation object
75+
calc = SimpleCalculation(
76+
lattice=mg_structure_file,
77+
element=["Al", "Mg"],
78+
input_chemical_composition={"Al": 0, "Mg": 2048},
79+
output_chemical_composition={"Al": 102, "Mg": 1946}
80+
)
81+
82+
# Create composition transformation
83+
comp = CompositionTransformation(calc)
84+
85+
# Verify basic properties
86+
assert comp.natoms == 2048
87+
assert comp.typedict == {"Al": 1, "Mg": 2}
88+
89+
# Verify input/output compositions
90+
assert comp.input_chemical_composition == {"Al": 0, "Mg": 2048}
91+
assert comp.output_chemical_composition == {"Al": 102, "Mg": 1946}
92+
93+
# Verify transformation requirements
94+
assert comp.to_remove == {"Mg": 102}
95+
assert comp.to_add == {"Al": 102}
96+
97+
# Check that we have exactly one transformation: Mg -> Al
98+
assert len(comp.transformation_list) == 1
99+
assert comp.transformation_list[0]['primary_element'] == 'Mg'
100+
assert comp.transformation_list[0]['secondary_element'] == 'Al'
101+
assert comp.transformation_list[0]['count'] == 102
102+
103+
# Verify pair lists
104+
assert comp.pair_list_old == ['Mg', 'Mg']
105+
assert comp.pair_list_new == ['Al', 'Mg']
106+
107+
# Test pair_coeff update
108+
pair_coeff_input = "pair_coeff * * /path/to/potential.yace Al Mg"
109+
pc_old, pc_new = comp.update_pair_coeff(pair_coeff_input)
110+
111+
# Verify pair coeffs are different and properly formatted
112+
# pc_old should have Mg because all atoms start as Mg
113+
# pc_new should have both Al and Mg after transformation
114+
assert "Mg" in pc_old
115+
assert "Al" in pc_new and "Mg" in pc_new
116+
117+
# Write structure and verify
118+
output_structure = os.path.join(os.path.dirname(mg_structure_file), "transformed.lammps.data")
119+
comp.write_structure(output_structure)
120+
121+
assert os.path.exists(output_structure)
122+
123+
# Read and verify the written structure
124+
with open(output_structure, 'r') as f:
125+
content = f.read()
126+
assert "2048 atoms" in content
127+
128+
# Verify entropy contribution calculation
129+
entropy = comp.entropy_contribution
130+
assert isinstance(entropy, float)
131+
assert entropy < 0 # Should be negative for this transformation
132+
133+
134+
def test_composition_transformation_with_pace_potential(mg_structure_file):
135+
"""
136+
Test with PACE potential format (as in the original bug report).
137+
Verifies that pair_coeff commands are correctly generated for PACE potentials.
138+
"""
139+
# Create a simple calculation object
140+
calc = SimpleCalculation(
141+
lattice=mg_structure_file,
142+
element=["Al", "Mg"],
143+
input_chemical_composition={"Al": 0, "Mg": 2048},
144+
output_chemical_composition={"Al": 102, "Mg": 1946}
145+
)
146+
147+
# Create composition transformation
148+
comp = CompositionTransformation(calc)
149+
150+
# Test PACE potential format
151+
pair_coeff_input = "pair_coeff * * /home/user/AlMgZn.yace Al Mg"
152+
pc_old, pc_new = comp.update_pair_coeff(pair_coeff_input)
153+
154+
# Both should contain the potential file path
155+
assert "/home/user/AlMgZn.yace" in pc_old
156+
assert "/home/user/AlMgZn.yace" in pc_new
157+
158+
# Verify element specifications
159+
assert "Mg" in pc_old
160+
assert "Al" in pc_new and "Mg" in pc_new
161+
162+
163+
def test_composition_transformation_swap_types(mg_structure_file):
164+
"""
165+
Test get_swap_types method returns correct type mappings.
166+
"""
167+
# Create a simple calculation object
168+
calc = SimpleCalculation(
169+
lattice=mg_structure_file,
170+
element=["Al", "Mg"],
171+
input_chemical_composition={"Al": 0, "Mg": 2048},
172+
output_chemical_composition={"Al": 102, "Mg": 1946}
173+
)
174+
175+
# Create composition transformation
176+
comp = CompositionTransformation(calc)
177+
178+
# Get swap types
179+
swap_types = comp.get_swap_types()
180+
181+
assert isinstance(swap_types, list)
182+
assert len(swap_types) == 2

0 commit comments

Comments
 (0)