calphy 1.4.5__py3-none-any.whl → 1.4.12__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,14 +3,14 @@ calphy: a Python library and command line interface for automated free
3
3
  energy calculations.
4
4
 
5
5
  Copyright 2021 (c) Sarath Menon^1, Yury Lysogorskiy^2, Ralf Drautz^2
6
- ^1: Max Planck Institut für Eisenforschung, Dusseldorf, Germany
6
+ ^1: Max Planck Institut für Eisenforschung, Dusseldorf, Germany
7
7
  ^2: Ruhr-University Bochum, Bochum, Germany
8
8
 
9
- calphy is published and distributed under the Academic Software License v1.0 (ASL).
10
- calphy is distributed in the hope that it will be useful for non-commercial academic research,
11
- but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
9
+ calphy is published and distributed under the Academic Software License v1.0 (ASL).
10
+ calphy is distributed in the hope that it will be useful for non-commercial academic research,
11
+ but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
12
12
  calphy API is published and distributed under the BSD 3-Clause "New" or "Revised" License
13
- See the LICENSE FILE for more details.
13
+ See the LICENSE FILE for more details.
14
14
 
15
15
  More information about the program can be found in:
16
16
  Menon, Sarath, Yury Lysogorskiy, Jutta Rogal, and Ralf Drautz.
@@ -32,9 +32,10 @@ from ase.atoms import Atoms
32
32
  from pyscal3.core import element_dict
33
33
  from calphy.integrators import kb
34
34
 
35
+
35
36
  class CompositionTransformation:
36
37
  """
37
- Class for performing composition transformations and
38
+ Class for performing composition transformations and
38
39
  generating necessary pair styles for such transformations.
39
40
 
40
41
  Parameters
@@ -43,11 +44,11 @@ class CompositionTransformation:
43
44
  input structure which is used for composition transformation
44
45
 
45
46
  input_chemical_formula: dict
46
- dictionary of input chemical
47
+ dictionary of input chemical
47
48
 
48
49
  output_chemical_formula: string
49
50
  the required chemical composition string
50
-
51
+
51
52
  restrictions: list of strings, optional
52
53
  Can be used to specify restricted transformations
53
54
 
@@ -74,17 +75,17 @@ class CompositionTransformation:
74
75
  transformations should not take place. The code for this is:
75
76
 
76
77
  ```
77
- comp = CompositionTransformation(filename, {"Al":500, "Li":5},
78
+ comp = CompositionTransformation(filename, {"Al":500, "Li":5},
78
79
  {"Al": 494, "Li": 2, "O": 3, "C":1}, restrictions=["Al-O"])
79
80
  ```
80
81
 
81
- If the restrictions are not satisfiable, an error will be raised.
82
+ If the restrictions are not satisfiable, an error will be raised.
82
83
 
83
84
  The LAMMPS data file or dump files do not contain any information about the species except the type numbers.
84
85
  In general the number of atoms are respected, for example if the file has 10 atoms of type 1, 5 of type 2,
85
86
  and 1 of type 3. If the `input_chemical_composition` is `{"Li": 5, "Al": 10, "O": 1}`, type 1 is assigned to Al,
86
87
  type 2 is assigned to Li and type 3 is assigned to O. This is done irrespective of the order in which
87
- `input_chemical_composition` is specified. However, if there are equal number of atoms, the order is respected.
88
+ `input_chemical_composition` is specified. However, if there are equal number of atoms, the order is respected.
88
89
  Therefore it is important to make sure that the `input_chemical_composition` is in the same order as that of
89
90
  types in structure file. For example, consider a NiAl structure of 10 Ni atoms and 10 Al atoms. Ni atoms are type 1 in LAMMPS terminology
90
91
  and Al atoms are type 2. In this case, to preserve the order, `input_chemical_composition` should be `{"Ni": 5, "Al": 10}`.
@@ -112,22 +113,27 @@ class CompositionTransformation:
112
113
  ```
113
114
  The output is written in LAMMPS dump format.
114
115
  """
116
+
115
117
  def __init__(self, calc):
116
-
117
- self.input_chemical_composition = calc.composition_scaling._input_chemical_composition
118
- self.output_chemical_composition = calc.composition_scaling.output_chemical_composition
118
+
119
+ self.input_chemical_composition = (
120
+ calc.composition_scaling._input_chemical_composition
121
+ )
122
+ self.output_chemical_composition = (
123
+ calc.composition_scaling.output_chemical_composition
124
+ )
119
125
  self.restrictions = calc.composition_scaling.restrictions
120
126
  self.calc = calc
121
127
  self.actual_species = None
122
128
  self.new_species = None
123
- self.maxtype = None
129
+ self.maxtype = None
124
130
  self.atom_mark = None
125
131
  self.atom_species = None
126
132
  self.mappings = None
127
133
  self.unique_mappings = None
128
134
  self.mappingdict = None
129
135
  self.prepare_mappings()
130
-
136
+
131
137
  def dict_to_string(self, inputdict):
132
138
  strlst = []
133
139
  for key, val in inputdict.items():
@@ -141,49 +147,68 @@ class CompositionTransformation:
141
147
  Find the entropy entribution of the transformation. To get
142
148
  free energies, multiply by -T.
143
149
  """
150
+
144
151
  def _log(val):
145
152
  if val == 0:
146
153
  return 0
147
154
  else:
148
155
  return np.log(val)
156
+
149
157
  ents = []
150
158
  for key, val in self.output_chemical_composition.items():
151
159
  if key in self.input_chemical_composition.keys():
152
- t1 = self.input_chemical_composition[key]/self.natoms
153
- t2 = self.output_chemical_composition[key]/self.natoms
154
- cont = t2*_log(t2) - t1*_log(t1)
160
+ t1 = self.input_chemical_composition[key] / self.natoms
161
+ t2 = self.output_chemical_composition[key] / self.natoms
162
+ cont = t2 * _log(t2) - t1 * _log(t1)
155
163
  else:
156
164
  t1 = 0
157
- t2 = self.output_chemical_composition[key]/self.natoms
158
- cont = t2*_log(t2) - 0
165
+ t2 = self.output_chemical_composition[key] / self.natoms
166
+ cont = t2 * _log(t2) - 0
159
167
  ents.append(cont)
160
- entropy_term = kb*np.sum(ents)
168
+ entropy_term = kb * np.sum(ents)
161
169
  return entropy_term
162
-
170
+
163
171
  def convert_to_pyscal(self):
164
172
  """
165
173
  Convert a given system to pyscal and give a dict of type mappings
166
174
  """
167
- aseobj = read(self.calc.lattice, format='lammps-data', style='atomic')
168
- pstruct = pc.System(aseobj, format='ase')
169
-
170
- #here we have to validate the input composition dict; and map it
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
+ )
186
+ pstruct = pc.System(aseobj, format="ase")
187
+
188
+ # here we have to validate the input composition dict; and map it
171
189
  typelist = pstruct.atoms.species
172
190
  types, typecounts = np.unique(typelist, return_counts=True)
173
191
  composition = {types[x]: typecounts[x] for x in range(len(types))}
174
192
 
175
193
  atomsymbols = self.calc.element
176
- atomtypes = [x+1 for x in range(len(self.calc.element))]
177
-
194
+ atomtypes = [x + 1 for x in range(len(self.calc.element))]
195
+
178
196
  self.pyscal_structure = pstruct
179
197
  self.typedict = dict(zip(atomsymbols, atomtypes))
180
198
  self.reversetypedict = dict(zip(atomtypes, atomsymbols))
181
199
  self.natoms = self.pyscal_structure.natoms
182
-
183
- self.actual_species = len(self.typedict)
184
- self.new_species = len(self.output_chemical_composition) - len(self.typedict)
185
- self.maxtype = self.actual_species + 1 #+ self.new_species
186
- #print(self.typedict)
200
+
201
+ # Count of actual unique atom types present in the structure
202
+ # This matches what's declared in the LAMMPS data file header
203
+ self.actual_species_in_structure = len(types)
204
+ # Count from calc.element (may include types with 0 atoms)
205
+ self.calc_element_count = len(self.calc.element)
206
+
207
+ # Use actual structure types for pair_coeff consistency
208
+ # pair_coeff must match the number declared in the data file header
209
+ self.actual_species = self.actual_species_in_structure
210
+ self.new_species = len(self.output_chemical_composition) - len(types)
211
+ self.maxtype = self.actual_species + 1 # + self.new_species
187
212
 
188
213
  def get_composition_transformation(self):
189
214
  """
@@ -203,41 +228,43 @@ class CompositionTransformation:
203
228
  to_remove[key] = np.abs(val)
204
229
  else:
205
230
  to_add[key] = val
206
-
231
+
207
232
  self.to_remove = to_remove
208
233
  self.to_add = to_add
209
234
 
210
- def get_random_index_of_species(self, species):
235
+ def get_random_index_of_species(self, species_name):
211
236
  """
212
- Get a random index of a given species
237
+ Get a random index of a given species by element name
213
238
  """
214
- ids = [count for count, x in enumerate(self.atom_type) if x==species]
239
+ ids = [count for count, x in enumerate(self.atom_species) if x == species_name]
215
240
  return ids[np.random.randint(0, len(ids))]
216
-
241
+
217
242
  def mark_atoms(self):
218
243
  for i in range(self.natoms):
219
244
  self.atom_mark.append(False)
220
-
245
+
246
+ # Use species (element symbols) instead of numeric types
247
+ self.atom_species = self.pyscal_structure.atoms.species
221
248
  self.atom_type = self.pyscal_structure.atoms.types
222
- self.mappings = [f"{x}-{x}" for x in self.atom_type]
223
-
249
+ self.mappings = [f"{x}-{x}" for x in self.atom_species]
250
+
224
251
  def update_mark_atoms(self):
225
252
  self.marked_atoms = []
226
253
  for key, val in self.to_remove.items():
227
- #print(f"Element {key}, count {val}")
254
+ # key is the element name (e.g., "Mg")
228
255
  for i in range(100000):
229
- rint = self.get_random_index_of_species(self.typedict[key])
256
+ rint = self.get_random_index_of_species(key)
230
257
  if rint not in self.marked_atoms:
231
258
  self.atom_mark[rint] = True
232
259
  self.marked_atoms.append(rint)
233
260
  val -= 1
234
- if (val <= 0):
235
- break
236
-
261
+ if val <= 0:
262
+ break
263
+
237
264
  def update_typedicts(self):
238
- #in a cycle add things to the typedict
265
+ # in a cycle add things to the typedict
239
266
  for key, val in self.to_add.items():
240
- #print(f"Element {key}, count {val}")
267
+ # print(f"Element {key}, count {val}")
241
268
  if key in self.typedict.keys():
242
269
  newtype = self.typedict[key]
243
270
  else:
@@ -245,61 +272,67 @@ class CompositionTransformation:
245
272
  self.typedict[key] = self.maxtype
246
273
  self.reversetypedict[self.maxtype] = key
247
274
  self.maxtype += 1
248
- #print(f"Element {key}, newtype {newtype}")
249
-
275
+ # print(f"Element {key}, newtype {newtype}")
276
+
250
277
  def compute_possible_mappings(self):
251
278
  self.possible_mappings = []
252
- #Now make a list of possible mappings
279
+ # Now make a list of possible mappings using element names
253
280
  for key1, val1 in self.to_remove.items():
254
281
  for key2, val2 in self.to_add.items():
255
282
  mapping = f"{key1}-{key2}"
256
283
  if mapping not in self.restrictions:
257
- self.possible_mappings.append(f"{self.typedict[key1]}-{self.typedict[key2]}")
258
-
284
+ self.possible_mappings.append(mapping)
285
+
259
286
  def update_mappings(self):
260
287
  marked_atoms = self.marked_atoms.copy()
261
288
  for key, val in self.to_add.items():
262
- #now get all
263
-
264
- #we to see if we can get val number of atoms from marked ones
265
- if val < len(marked_atoms):
266
- raise ValueError(f'Not enough atoms to choose {val} from {len(marked_atoms)} not possible')
267
-
268
- #otherwise find atoms until we find enough
289
+ # now get all
290
+
291
+ # we to see if we can get val number of atoms from marked ones
292
+ if val > len(marked_atoms):
293
+ raise ValueError(
294
+ f"Not enough atoms to choose {val} from {len(marked_atoms)} not possible"
295
+ )
296
+
297
+ # otherwise find atoms until we find enough
269
298
  for i in range(val):
270
- #choose a number from marked atoms
299
+ # choose a number from marked atoms
271
300
  found = False
272
301
  to_del = []
273
302
  for x in range(len(self.marked_atoms)):
274
303
  random_choice = np.random.choice(marked_atoms)
275
- #find corresponding mappiong
276
- mapping = f"{self.atom_type[random_choice]}-{self.typedict[key]}"
304
+ # find corresponding mapping using species name
305
+ mapping = f"{self.atom_species[random_choice]}-{key}"
277
306
  if mapping in self.possible_mappings:
278
- #this is a valid choice
307
+ # this is a valid choice
279
308
  self.mappings[random_choice] = mapping
280
309
  found = True
281
310
  if found:
282
- #finish up, change the array, and break
283
- #to_del.append(random_choice)
311
+ # finish up, change the array, and break
312
+ # to_del.append(random_choice)
284
313
  marked_atoms.remove(random_choice)
285
314
  break
286
- #if it was not found, the loop finished, throw error
315
+ # if it was not found, the loop finished, throw error
287
316
  if not found:
288
- raise ValueError("A possible transformation could not be found, please check the restrictions")
289
- #otherwise modify our marked atoms, list, and move on
290
- #for item in to_del:
317
+ raise ValueError(
318
+ "A possible transformation could not be found, please check the restrictions"
319
+ )
320
+ # otherwise modify our marked atoms, list, and move on
321
+ # for item in to_del:
291
322
  # marked_atoms.remove(item)
292
-
293
- self.unique_mappings, self.unique_mapping_counts = np.unique(self.mappings, return_counts=True)
294
323
 
295
- #now make the transformation dict
324
+ self.unique_mappings, self.unique_mapping_counts = np.unique(
325
+ self.mappings, return_counts=True
326
+ )
327
+
328
+ # now make the transformation dict
296
329
  self.transformation_list = []
297
330
  for count, mapping in enumerate(self.unique_mappings):
298
331
  mapsplit = mapping.split("-")
299
332
  if not mapsplit[0] == mapsplit[1]:
300
333
  transformation_dict = {}
301
- transformation_dict["primary_element"] = self.reversetypedict[int(mapsplit[0])]
302
- transformation_dict["secondary_element"] = self.reversetypedict[int(mapsplit[1])]
334
+ transformation_dict["primary_element"] = mapsplit[0]
335
+ transformation_dict["secondary_element"] = mapsplit[1]
303
336
  transformation_dict["count"] = self.unique_mapping_counts[count]
304
337
  self.transformation_list.append(transformation_dict)
305
338
 
@@ -307,104 +340,233 @@ class CompositionTransformation:
307
340
  self.update_typedicts()
308
341
  self.compute_possible_mappings()
309
342
  self.update_mappings()
310
-
343
+
311
344
  def prepare_pair_lists(self):
312
345
  self.pair_list_old = []
313
346
  self.pair_list_new = []
314
347
  for mapping in self.unique_mappings:
315
348
  map_split = mapping.split("-")
316
- #conserved atom
317
- if (map_split[0]==map_split[1]):
318
- self.pair_list_old.append(self.reversetypedict[int(map_split[0])])
319
- self.pair_list_new.append(self.reversetypedict[int(map_split[0])])
349
+ # conserved atom - mappings now use element names directly
350
+ if map_split[0] == map_split[1]:
351
+ self.pair_list_old.append(map_split[0])
352
+ self.pair_list_new.append(map_split[0])
320
353
  else:
321
- self.pair_list_old.append(self.reversetypedict[int(map_split[0])])
322
- self.pair_list_new.append(self.reversetypedict[int(map_split[1])])
323
- self.new_atomtype = np.array(range(len(self.unique_mappings)))+1
324
- self.mappingdict = dict(zip(self.unique_mappings, self.new_atomtype))
325
-
354
+ self.pair_list_old.append(map_split[0])
355
+ self.pair_list_new.append(map_split[1])
356
+
357
+ # Special case: 100% transformation with only 1 mapping
358
+ # LAMMPS requires pair_coeff to map ALL atom types declared in data file
359
+ # Example: Pure Al→Mg with 2 types declared → need ['Al', 'Al'] and ['Mg', 'Mg']
360
+ # This ensures consistency between data file type count and pair_coeff mappings
361
+ if len(self.unique_mappings) == 1 and self.actual_species > 1:
362
+ # Duplicate the single mapping to match number of declared atom types
363
+ for _ in range(self.actual_species - 1):
364
+ self.pair_list_old.append(self.pair_list_old[0])
365
+ self.pair_list_new.append(self.pair_list_new[0])
366
+
367
+ # Create mapping from transformation strings to UNIQUE type numbers
368
+ # Each unique transformation mapping needs its own type for LAMMPS swapping
369
+ # Example: Al-Al, Mg-Al, Mg-Mg should map to types 1, 2, 3 respectively
370
+ self.mappingdict = {}
371
+ for idx, mapping in enumerate(self.unique_mappings, start=1):
372
+ self.mappingdict[mapping] = idx
373
+
374
+ # Update reversetypedict - map each type to its source element
375
+ # We'll handle species naming in write_structure to keep types separate
376
+ self.reversetypedict = {}
377
+ for mapping, type_num in self.mappingdict.items():
378
+ source_element = mapping.split("-")[0]
379
+ self.reversetypedict[type_num] = source_element
380
+
326
381
  def update_types(self):
382
+ # Update atom_type based on mapping to new types
327
383
  for x in range(len(self.atom_type)):
328
384
  self.atom_type[x] = self.mappingdict[self.mappings[x]]
329
-
330
- #smartify these loops
331
- #npyscal = len(self.pyscal_structure.atoms.types)
385
+
386
+ # Update pyscal structure types
332
387
  self.pyscal_structure.atoms.types = self.atom_type
333
- #for count in range(npyscal)):
334
- # self.pyscal_structure.atoms.types[count] = self.atom_type[count]
335
-
388
+
336
389
  def iselement(self, symbol):
337
390
  try:
338
391
  _ = element(symbol)
339
392
  return True
340
393
  except:
341
394
  return False
342
-
395
+
343
396
  def update_pair_coeff(self, pair_coeff):
397
+ """
398
+ Update pair_coeff command with new element specifications.
399
+
400
+ Handles both single-file formats (EAM alloy):
401
+ pair_coeff * * potential.eam.alloy El1 El2
402
+
403
+ And two-file formats (MEAM):
404
+ pair_coeff * * library.meam El1 El2 potential.meam El1 El2
405
+
406
+ For MEAM potentials, both element specifications are updated identically.
407
+ """
344
408
  pcsplit = pair_coeff.strip().split()
345
- pc_before = []
346
- pc_after = []
347
-
348
- started = False
349
- stopped = False
350
-
351
- for p in pcsplit:
352
- if ((not self.iselement(p)) and (not started)):
353
- pc_before.append(p)
354
- elif (self.iselement(p) and (not started)):
355
- started = True
356
- elif ((not self.iselement(p)) and started):
357
- stopped = True
358
- elif ((not self.iselement(p)) and stopped):
359
- pc_after.append(p)
360
-
361
- pc_old = " ".join([*pc_before, *self.pair_list_old, *pc_after])
362
- pc_new = " ".join([*pc_before, *self.pair_list_new, *pc_after])
409
+ result_parts = []
410
+ i = 0
411
+
412
+ while i < len(pcsplit):
413
+ token = pcsplit[i]
414
+
415
+ # Check if this token starts an element specification
416
+ # (either it's an element, or the next token is an element)
417
+ if self.iselement(token):
418
+ # Found start of element list - collect all consecutive elements
419
+ element_group = []
420
+ while i < len(pcsplit) and self.iselement(pcsplit[i]):
421
+ element_group.append(pcsplit[i])
422
+ i += 1
423
+
424
+ # Determine which element list to use based on what we found
425
+ # If element_group matches our pair_list_old, replace with pair_list_new
426
+ # Otherwise replace with pair_list_old (for the old/reference command)
427
+ if element_group == self.pair_list_old or set(element_group) == set(
428
+ self.calc.element
429
+ ):
430
+ # This needs special handling - we'll mark position for later
431
+ result_parts.append("__ELEMENTS__")
432
+ else:
433
+ # Keep non-matching element groups as-is
434
+ result_parts.extend(element_group)
435
+ else:
436
+ # Non-element token (potential file, wildcards, options, etc.)
437
+ result_parts.append(token)
438
+ i += 1
439
+
440
+ # Now build old and new commands by replacing __ELEMENTS__ markers
441
+ pc_old_parts = [
442
+ self.pair_list_old if p == "__ELEMENTS__" else [p] for p in result_parts
443
+ ]
444
+ pc_new_parts = [
445
+ self.pair_list_new if p == "__ELEMENTS__" else [p] for p in result_parts
446
+ ]
447
+
448
+ # Flatten the lists
449
+ pc_old = " ".join(
450
+ [
451
+ item
452
+ for sublist in pc_old_parts
453
+ for item in (sublist if isinstance(sublist, list) else [sublist])
454
+ ]
455
+ )
456
+ pc_new = " ".join(
457
+ [
458
+ item
459
+ for sublist in pc_new_parts
460
+ for item in (sublist if isinstance(sublist, list) else [sublist])
461
+ ]
462
+ )
463
+
363
464
  return pc_old, pc_new
364
465
 
365
466
  def get_swap_types(self):
366
467
  """
367
- Get swapping types
468
+ Get swapping types for configurational entropy calculation.
469
+
470
+ Returns types that share the same initial element but have different
471
+ transformation paths (e.g., Al→Al vs Al→Mg).
472
+
473
+ The order matters for reversibility:
474
+ - Forward pass (e.g., Mg→Al enrichment): swap between Mg types
475
+ - Backward pass (e.g., Al→Mg depletion): swap between Al types
476
+
477
+ Returns list ordered as: [conserved_type, transforming_type]
478
+ where conserved_type is X→X and transforming_type is X→Y
368
479
  """
369
480
  swap_list = []
370
481
  for mapping in self.unique_mappings:
371
482
  map_split = mapping.split("-")
372
- #conserved atom
373
- if (map_split[0]==map_split[1]):
483
+ # conserved atom - skip
484
+ if map_split[0] == map_split[1]:
374
485
  pass
375
486
  else:
376
487
  first_type = map_split[0]
377
488
  second_type = map_split[1]
378
- first_map = f'{first_type}-{first_type}'
489
+ first_map = f"{first_type}-{first_type}"
379
490
  second_map = mapping
380
491
 
381
- #get the numbers from dict
382
- first_swap_type = self.mappingdict[first_map]
383
- second_swap_type = self.mappingdict[second_map]
492
+ # Check if conserved mapping exists
493
+ if first_map in self.mappingdict:
494
+ # get the numbers from dict
495
+ first_swap_type = self.mappingdict[first_map]
496
+ second_swap_type = self.mappingdict[second_map]
497
+ # Order: [transforming_type, conserved_type]
498
+ # This represents: atoms that transform vs atoms that don't
499
+ swap_list.append([second_swap_type, first_swap_type])
500
+ else:
501
+ # 100% transformation case - no conserved atoms of this type
502
+ # Only the transforming type exists
503
+ second_swap_type = self.mappingdict[second_map]
504
+ swap_list.append([second_swap_type])
505
+
506
+ return swap_list[0] if swap_list else []
384
507
 
385
- swap_list.append([first_swap_type, second_swap_type])
386
- return swap_list[0]
387
-
388
-
389
508
  def write_structure(self, outfilename):
390
- #create some species dict
391
- #just to trick ase to write
392
- utypes = np.unique(self.pyscal_structure.atoms["types"])
393
- element_list = list(element_dict.keys())
394
- element_type_dict = {str(u):element_list[count] for count, u in enumerate(utypes)}
395
- species = [element_type_dict[str(x)] for x in self.pyscal_structure.atoms["types"]]
396
- self.pyscal_structure.atoms["species"] = species
397
- self.pyscal_structure.write.file(outfilename, format='lammps-data')
398
-
399
-
400
-
401
-
509
+ """Write structure to LAMMPS data file with proper type declarations.
510
+
511
+ Writes using ASE directly with custom atom types to preserve distinct
512
+ type numbers for each transformation mapping.
513
+ """
514
+ from ase.io import write as ase_write
515
+ from ase import Atoms as ASEAtoms
516
+
517
+ # Get positions and cell from pyscal structure
518
+ positions = self.pyscal_structure.atoms.positions
519
+ cell = self.pyscal_structure.box
520
+
521
+ # Create ASE Atoms object with chemical symbols from reversetypedict
522
+ # All atoms get their source element symbol
523
+ symbols = [self.reversetypedict[t] for t in self.pyscal_structure.atoms.types]
524
+
525
+ ase_atoms = ASEAtoms(symbols=symbols, positions=positions, cell=cell, pbc=True)
526
+
527
+ # Write using ASE with atom_style
528
+ ase_write(outfilename, ase_atoms, format="lammps-data", atom_style="atomic")
529
+
530
+ # Post-process to fix the type column with our custom types
531
+ with open(outfilename, "r") as f:
532
+ lines = f.readlines()
533
+
534
+ # Find the Atoms section and replace type numbers
535
+ in_atoms_section = False
536
+ atom_idx = 0
537
+ for i, line in enumerate(lines):
538
+ if "Atoms" in line and "#" in line:
539
+ in_atoms_section = True
540
+ continue
541
+
542
+ if in_atoms_section and line.strip():
543
+ parts = line.split()
544
+ if len(parts) >= 5: # atom_id type x y z
545
+ # Replace the type (column 1, 0-indexed) with our custom type
546
+ custom_type = self.pyscal_structure.atoms.types[atom_idx]
547
+ parts[1] = str(custom_type)
548
+ lines[i] = " " + " ".join(parts) + "\n"
549
+ atom_idx += 1
550
+ if atom_idx >= len(self.pyscal_structure.atoms.types):
551
+ break
552
+
553
+ # Update the number of atom types in the header
554
+ required_ntypes = len(self.pair_list_old)
555
+ for i, line in enumerate(lines):
556
+ if "atom types" in line:
557
+ lines[i] = f"{required_ntypes} atom types\n"
558
+ break
559
+
560
+ # Write the corrected file
561
+ with open(outfilename, "w") as f:
562
+ f.writelines(lines)
563
+
402
564
  def prepare_mappings(self):
403
565
  self.atom_mark = []
404
566
  self.atom_species = []
405
567
  self.mappings = []
406
568
  self.unique_mappings = []
407
-
569
+
408
570
  self.get_composition_transformation()
409
571
  self.convert_to_pyscal()
410
572
 
@@ -412,4 +574,4 @@ class CompositionTransformation:
412
574
  self.update_mark_atoms()
413
575
  self.get_mappings()
414
576
  self.prepare_pair_lists()
415
- self.update_types()
577
+ self.update_types()