254254 }
255255}
256256
257+ def read_structure_composition (lattice_file , element_list ):
258+ """
259+ Read a LAMMPS data file and determine the input chemical composition.
260+
261+ Parameters
262+ ----------
263+ lattice_file : str
264+ Path to the LAMMPS data file
265+ element_list : list
266+ List of element symbols in order (element[0] = type 1, element[1] = type 2, etc.)
267+
268+ Returns
269+ -------
270+ dict
271+ Dictionary mapping element symbols to atom counts
272+ Elements not present in the structure will have count 0
273+ """
274+ from ase .io import read
275+ from collections import Counter
276+
277+ # Read the structure file
278+ structure = read (lattice_file , format = 'lammps-data' , style = 'atomic' )
279+
280+ # Get the species/types from the structure
281+ # ASE reads LAMMPS types as species strings ('1', '2', etc.)
282+ if 'species' in structure .arrays :
283+ types_in_structure = structure .arrays ['species' ]
284+ else :
285+ # Fallback: get atomic numbers and convert to strings
286+ types_in_structure = [str (x ) for x in structure .get_atomic_numbers ()]
287+
288+ # Count atoms by type
289+ type_counts = Counter (types_in_structure )
290+
291+ # Build composition mapping element names to counts
292+ # element[0] corresponds to LAMMPS type '1', element[1] to type '2', etc.
293+ input_chemical_composition = {}
294+ for idx , element in enumerate (element_list ):
295+ lammps_type = str (idx + 1 ) # LAMMPS types are 1-indexed
296+ input_chemical_composition [element ] = type_counts .get (lammps_type , 0 )
297+
298+ return input_chemical_composition
299+
300+
301+ # Constants for phase diagram preparation
302+ COMPOSITION_TOLERANCE = 1E-5
303+
304+
305+ def _create_composition_array (comp_range , interval , reference ):
306+ """
307+ Create composition array from range specification.
308+
309+ Parameters
310+ ----------
311+ comp_range : list or scalar
312+ Composition range [min, max] or single value
313+ interval : float
314+ Composition interval
315+ reference : float
316+ Reference composition value
317+
318+ Returns
319+ -------
320+ tuple
321+ (comp_arr, is_reference) - composition array and boolean array marking reference compositions
322+ """
323+ # Convert to list if scalar
324+ if not isinstance (comp_range , list ):
325+ comp_range = [comp_range ]
326+
327+ if len (comp_range ) == 2 :
328+ comp_arr = np .arange (comp_range [0 ], comp_range [- 1 ], interval )
329+ last_val = comp_range [- 1 ]
330+ if last_val not in comp_arr :
331+ comp_arr = np .append (comp_arr , last_val )
332+ is_reference = np .abs (comp_arr - reference ) < COMPOSITION_TOLERANCE
333+ elif len (comp_range ) == 1 :
334+ comp_arr = [comp_range [0 ]]
335+ is_reference = [True ]
336+ else :
337+ raise ValueError ("Composition range should be scalar or list of two values!" )
338+
339+ return comp_arr , is_reference
340+
341+
342+ def _create_temperature_array (temp_range , interval ):
343+ """
344+ Create temperature array from range specification.
345+
346+ Parameters
347+ ----------
348+ temp_range : list or scalar
349+ Temperature range [min, max] or single value
350+ interval : float
351+ Temperature interval
352+
353+ Returns
354+ -------
355+ ndarray
356+ Temperature array
357+ """
358+ # Convert to list if scalar
359+ if not isinstance (temp_range , list ):
360+ temp_range = [temp_range ]
361+
362+ if len (temp_range ) == 2 :
363+ ntemps = int ((temp_range [- 1 ] - temp_range [0 ]) / interval ) + 1
364+ temp_arr = np .linspace (temp_range [0 ], temp_range [- 1 ], ntemps , endpoint = True )
365+ elif len (temp_range ) == 1 :
366+ temp_arr = [temp_range [0 ]]
367+ else :
368+ raise ValueError ("Temperature range should be scalar or list of two values!" )
369+
370+ return temp_arr
371+
372+
373+ def _add_temperature_calculations (calc_dict , temp_arr , all_calculations ):
374+ """
375+ Helper to add calculations for each temperature point.
376+
377+ Parameters
378+ ----------
379+ calc_dict : dict
380+ Base calculation dictionary
381+ temp_arr : array
382+ Array of temperatures
383+ all_calculations : list
384+ List to append calculations to
385+ """
386+ for temp in temp_arr :
387+ calc_for_temp = copy .deepcopy (calc_dict )
388+ calc_for_temp ['temperature' ] = int (temp )
389+ all_calculations .append (calc_for_temp )
390+
391+
257392def fix_data_file (datafile , nelements ):
258393 """
259394 Change the atom types keyword in the structure file
@@ -309,6 +444,31 @@ def prepare_inputs_for_phase_diagram(inputyamlfile, calculation_base_name=None):
309444 calculation_base_name = inputyamlfile
310445
311446 for phase in data ['phases' ]:
447+ # Validate binary system assumption
448+ n_elements = len (phase ['element' ])
449+ if n_elements != 2 :
450+ raise ValueError (
451+ f"Phase diagram preparation currently supports only binary systems. "
452+ f"Found { n_elements } elements: { phase ['element' ]} "
453+ )
454+
455+ # Validate element ordering consistency with pair_coeff
456+ # This ensures element[0] -> type 1, element[1] -> type 2
457+ if 'pair_coeff' in phase :
458+ from calphy .input import _extract_elements_from_pair_coeff
459+ # pair_coeff can be a list or a string - handle both
460+ pair_coeff = phase ['pair_coeff' ]
461+ if isinstance (pair_coeff , list ):
462+ pair_coeff = pair_coeff [0 ] if pair_coeff else None
463+ pair_coeff_elements = _extract_elements_from_pair_coeff (pair_coeff )
464+ if pair_coeff_elements != phase ['element' ]:
465+ raise ValueError (
466+ f"Element ordering mismatch for phase '{ phase .get ('phase_name' , 'unnamed' )} '!\n "
467+ f"Elements in 'element' field: { phase ['element' ]} \n "
468+ f"Elements from pair_coeff: { pair_coeff_elements } \n "
469+ f"These must match exactly in order (element[0] -> LAMMPS type 1, element[1] -> type 2)."
470+ )
471+
312472 phase_reference_state = phase ['reference_phase' ]
313473 phase_name = phase ['phase_name' ]
314474
@@ -325,34 +485,18 @@ def prepare_inputs_for_phase_diagram(inputyamlfile, calculation_base_name=None):
325485 other_element_list .remove (reference_element )
326486 other_element = other_element_list [0 ]
327487
328- #convert to list if scalar
329- if not isinstance (comps ['range' ], list ):
330- comps ["range" ] = [comps ["range" ]]
331- if len (comps ["range" ]) == 2 :
332- comp_arr = np .arange (comps ['range' ][0 ], comps ['range' ][- 1 ], comps ['interval' ])
333- last_val = comps ['range' ][- 1 ]
334- if last_val not in comp_arr :
335- comp_arr = np .append (comp_arr , last_val )
336- ncomps = len (comp_arr )
337- is_reference = np .abs (comp_arr - comps ['reference' ]) < 1E-5
338- elif len (comps ["range" ]) == 1 :
339- ncomps = 1
340- comp_arr = [comps ["range" ][0 ]]
341- is_reference = [True ]
342- else :
343- raise ValueError ("Composition range should be scalar of list of two values!" )
488+ # Create composition array using helper function
489+ comp_arr , is_reference = _create_composition_array (
490+ comps ['range' ],
491+ comps ['interval' ],
492+ comps ['reference' ]
493+ )
494+ ncomps = len (comp_arr )
344495
496+ # Create temperature array using helper function
345497 temps = phase ["temperature" ]
346- if not isinstance (temps ['range' ], list ):
347- temps ["range" ] = [temps ["range" ]]
348- if len (temps ["range" ]) == 2 :
349- ntemps = int ((temps ['range' ][- 1 ]- temps ['range' ][0 ])/ temps ['interval' ])+ 1
350- temp_arr = np .linspace (temps ['range' ][0 ], temps ['range' ][- 1 ], ntemps , endpoint = True )
351- elif len (temps ["range" ]) == 1 :
352- ntemps = 1
353- temp_arr = [temps ["range" ][0 ]]
354- else :
355- raise ValueError ("Temperature range should be scalar of list of two values!" )
498+ temp_arr = _create_temperature_array (temps ['range' ], temps ['interval' ])
499+ ntemps = len (temp_arr )
356500
357501 all_calculations = []
358502
@@ -372,39 +516,39 @@ def prepare_inputs_for_phase_diagram(inputyamlfile, calculation_base_name=None):
372516 outfile = fix_data_file (calc ['lattice' ], len (calc ['element' ]))
373517
374518 #add ref phase, needed
375- calc ['reference_phase' ] = str ( phase_reference_state )
519+ calc ['reference_phase' ] = phase_reference_state
376520 calc ['reference_composition' ] = comps ['reference' ]
377- calc ['mode' ] = str ( 'fe' )
521+ calc ['mode' ] = 'fe'
378522 calc ['folder_prefix' ] = f'{ phase_name } -{ comp :.2f} '
379- calc ['lattice' ] = str ( outfile )
523+ calc ['lattice' ] = outfile
380524
381- #now we need to run this for different temp
382- for temp in temp_arr :
383- calc_for_temp = copy .deepcopy (calc )
384- calc_for_temp ['temperature' ] = int (temp )
385- all_calculations .append (calc_for_temp )
525+ # Add calculations for each temperature
526+ _add_temperature_calculations (calc , temp_arr , all_calculations )
386527 else :
387528 #off stoichiometric
388529 #copy the dict
389530 calc = copy .deepcopy (phase )
390531
391- #first thing first, we need to calculate the number of atoms
392- #we follow the convention that composition is always given with the second species
393- n_atoms = np .sum (calc ['composition' ]['number_of_atoms' ])
532+ #read the structure file to determine input composition automatically
533+ input_chemical_composition = read_structure_composition (calc ['lattice' ], calc ['element' ])
534+
535+ #calculate total number of atoms from structure
536+ n_atoms = sum (input_chemical_composition .values ())
537+
538+ if n_atoms == 0 :
539+ raise ValueError (f"No atoms found in structure file { calc ['lattice' ]} " )
394540
395- #find number of atoms of second species
541+ #find number of atoms of second species based on target composition
542+ #we follow the convention that composition is always given with the reference element
396543 output_chemical_composition = {}
397544 n_species_b = int (np .round (comp * n_atoms , decimals = 0 ))
398545 output_chemical_composition [reference_element ] = n_species_b
399546
400547 n_species_a = int (n_atoms - n_species_b )
401548 output_chemical_composition [other_element ] = n_species_a
402549
403- if n_species_a == 0 :
404- raise ValueError ("Please add pure phase as a new entry!" )
405- #create input comp dict and output comp dict
406- input_chemical_composition = {element :number for element , number in zip (calc ['element' ],
407- calc ['composition' ]['number_of_atoms' ])}
550+ # Note: Pure phases (n_species_a == 0 or n_species_b == 0) are allowed
551+ # Composition transformation can handle 100% replacement
408552
409553 #good, now we need to write such a structure out; likely better to use working directory for that
410554 folder_prefix = f'{ phase_name } -{ comp :.2f} '
@@ -421,7 +565,7 @@ def prepare_inputs_for_phase_diagram(inputyamlfile, calculation_base_name=None):
421565
422566 #just submit comp scales
423567 #add ref phase, needed
424- calc ['mode' ] = str ( 'composition_scaling' )
568+ calc ['mode' ] = 'composition_scaling'
425569 calc ['folder_prefix' ] = folder_prefix
426570 calc ['composition_scaling' ] = {}
427571 calc ['composition_scaling' ]['output_chemical_composition' ] = output_chemical_composition
@@ -447,22 +591,20 @@ def prepare_inputs_for_phase_diagram(inputyamlfile, calculation_base_name=None):
447591 _ = calc .pop (key , None )
448592
449593 #add ref phase, needed
450- calc ['mode' ] = str ( 'fe' )
594+ calc ['mode' ] = 'fe'
451595 calc ['folder_prefix' ] = folder_prefix
452- calc ['lattice' ] = str ( outfile )
596+ calc ['lattice' ] = outfile
453597
454- #now we need to run this for different temp
455- for temp in temp_arr :
456- calc_for_temp = copy .deepcopy (calc )
457- calc_for_temp ['temperature' ] = int (temp )
458- all_calculations .append (calc_for_temp )
598+ # Add calculations for each temperature
599+ _add_temperature_calculations (calc , temp_arr , all_calculations )
459600
460601 #finish and write up the file
461602 output_data = {"calculations" : all_calculations }
603+ base_name = os .path .basename (calculation_base_name )
462604 for rep in ['.yml' , '.yaml' ]:
463- calculation_base_name = calculation_base_name .replace (rep , '' )
605+ base_name = base_name .replace (rep , '' )
464606
465- outfile_phase = phase_name + '_' + calculation_base_name + ".yaml"
607+ outfile_phase = phase_name + '_' + base_name + ".yaml"
466608 with open (outfile_phase , 'w' ) as fout :
467609 yaml .safe_dump (output_data , fout )
468610 print (f'Total { len (all_calculations )} calculations found for phase { phase_name } , written to { outfile_phase } ' )
0 commit comments