Skip to content

Commit 3eec4b4

Browse files
authored
Merge pull request #197 from ICAMS/pd_fixes
Pd fixes
2 parents 8988664 + d8b31d6 commit 3eec4b4

File tree

3 files changed

+586
-53
lines changed

3 files changed

+586
-53
lines changed

calphy/phase_diagram.py

Lines changed: 195 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,141 @@
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+
257392
def 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

Comments
 (0)