11"""
22Module for PXRD indexing and lattice parameter estimation.
33"""
4+ from pyxtal import pyxtal
5+ from pyxtal .lattice import Lattice
46import numpy as np
57from itertools import combinations
6- from pyxtal .symmetry import Group , get_bravais_lattice , get_lattice_type , generate_possible_hkls
8+ from pyxtal .symmetry import rf , Group , get_bravais_lattice , get_lattice_type , generate_possible_hkls
9+ from pyxtal .database .element import Element
10+
11+
12+ def find_wp_assignments (comp , ids , nums ):
13+ """
14+ Assigns Wyckoff position IDs to a composition. This function can handle
15+ cases where a composition number is a sum of multiple Wyckoff multiplicities.
16+
17+ Args:
18+ comp (list): The target composition counts, e.g., [18, 6].
19+ ids (list): The list of Wyckoff position IDs, e.g., [1, 1, 6, 8, 9].
20+ nums (list): The corresponding multiplicities for each ID, e.g., [8, 8, 4, 2, 2].
21+
22+ Returns:
23+ list: A list of all possible valid assignments. Each assignment is a list
24+ of lists, where each inner list contains the WP IDs for an element.
25+ """
26+
27+ # Pair IDs with their multiplicities and indices for unique tracking
28+ wp_info = list (zip (ids , nums , range (len (ids ))))
29+
30+ # --- Helper function to find all subsets that sum to a target ---
31+ def find_subsets (target , available_wps , start_index = 0 , current_subset = []):
32+ if target == 0 :
33+ yield current_subset
34+ return
35+ if target < 0 or start_index == len (available_wps ):
36+ return
37+
38+ for i in range (start_index , len (available_wps )):
39+ wp_id , wp_num , original_index = available_wps [i ]
40+
41+ # To handle duplicate numbers, we only proceed if this is the first
42+ # occurrence or if the previous identical element was not chosen.
43+ #if i > start_index and available_wps[i][1] == available_wps[i-1][1]:
44+ # continue
45+
46+ # Recurse with the current element included
47+ yield from find_subsets (
48+ target - wp_num ,
49+ available_wps ,
50+ i + 1 ,
51+ current_subset + [available_wps [i ]]
52+ )
53+
54+ # --- Main backtracking solver ---
55+ solutions = []
56+
57+ def solve (comp_targets , current_assignment , available_wps ):
58+ if not comp_targets :
59+ # Sort the assignment by the original index before storing
60+ sorted_assignment = sorted (current_assignment , key = lambda x : x [0 ])
61+ # Extract just the WP lists in the correct order
62+ final_solution = [part for index , part in sorted_assignment ]
63+ solutions .append (final_solution )
64+ return
65+
66+ original_index , target_comp = comp_targets [0 ]
67+ remaining_comp = comp_targets [1 :]
68+
69+ # Find all possible ways to form the target composition number
70+ possible_subsets = list (find_subsets (target_comp , available_wps ))
71+
72+ for subset in possible_subsets :
73+ # For each valid subset, create a new assignment
74+ new_assignment_part = [wp [0 ] for wp in subset ] # Get just the IDs
75+
76+ # Determine the remaining available WPs for the next recursive call
77+ used_indices = {wp [2 ] for wp in subset }
78+ next_available_wps = [wp for wp in available_wps if wp [2 ] not in used_indices ]
79+
80+ # Recurse, adding the original index along with the assignment part
81+ solve (remaining_comp , current_assignment + [(original_index , new_assignment_part )], next_available_wps )
82+
83+ # Pair composition values with their original indices
84+ indexed_comp = list (enumerate (comp ))
85+ # Sort by composition value (descending) to prune search space faster
86+ sorted_indexed_comp = sorted (indexed_comp , key = lambda x : x [1 ], reverse = True )
87+
88+ solve (sorted_indexed_comp , [], wp_info )
89+ #print(comp, ids, nums, solutions)
90+ #if len(solutions) > 0: import sys; sys.exit()
91+ return solutions
92+
93+
794
895def get_cell_params (bravais , hkls , two_thetas , wave_length = 1.54184 ):
996 """
@@ -499,7 +586,7 @@ def get_cell_from_thetas(spg, long_thetas, N_add=5, max_mismatch=20, theta_tol=0
499586 for sol in sols :
500587 guess , match , mis_match = sol ['id' ], sol ['matched_peaks' ], sol ['mis_matched_peaks' ]
501588 if len (match ) == len (long_thetas ):
502- cell1 = np .sort (np .array (sol ['cell' ]))
589+ cell1 = sol [ 'cell' ] # np.sort(np.array(sol['cell']))
503590 d2 = np .sum (guess ** 2 )
504591
505592 if len (cell_all ) == 0 :
@@ -517,15 +604,150 @@ def get_cell_from_thetas(spg, long_thetas, N_add=5, max_mismatch=20, theta_tol=0
517604
518605 return results
519606
607+ class XtalManager :
608+ def __init__ (self , spg , species , numIons , cell , WPs ):
609+ """
610+ Crystal Manager is used to handle crystal structure related operations.
611+
612+ Args:
613+ spg (int): Space group number
614+ WPs (list): Wyckoff positions
615+ cell (list): Cell parameters
616+ """
617+ self .spg = Group (spg )
618+ self .WPs = WPs
619+ self .cell = cell
620+ self .species = species
621+ self .numIons = numIons
622+ dof = 0
623+ sites = [[] for _ in range (len (species ))]
624+ for i , wp in enumerate (WPs ):
625+ for _wp in wp :
626+ dof += self .spg [_wp ].get_dof ()
627+ sites [i ].append (self .spg [_wp ].get_label ())
628+ self .dof = dof
629+ self .sites = sites
630+ print (f"Space group: { spg } , Wyckoff positions: { sites } , DOF: { dof } " )
631+
632+ def generate_structure (self ):
633+ """
634+ Generate the crystal structure from the Wyckoff positions and cell parameters.
635+ """
636+ xtal = pyxtal ()
637+ xtal .from_random (3 , self .spg , self .species , self .numIons ,
638+ lattice = self .cell , sites = self .sites , force_pass = True )
639+ return xtal
640+
641+ class WPManager :
642+ def __init__ (self , spg , cell , composition = {'Si' : 1 , 'O' : 2 }, max_wp = 6 , max_Z = 8 ):
643+ """
644+ WP Manager is used to infer likely Wyckoff positions from the given space group,
645+ cell, composition, and density constraint.
646+
647+ Args:
648+ spg (int): Space group number
649+ cell (list): Cell parameters
650+ composition (dict): Elemental composition
651+ max_wp (int): Maximum number of Wyckoff positions to consider
652+ max_Z (int): Maximum Z value to consider for volume estimation
653+ """
654+ from pandas import read_csv
655+ df = read_csv (rf ("pyxtal" , "database/spg_num_wps_raw.csv" ))
656+ self .spg = spg
657+ self .df = df [df ['spg' ] == self .spg ]
658+ self .cell = cell
659+ self .composition = composition
660+ self .comp = [composition [key ] for key in composition .keys ()]
661+ self .group = Group (spg )
662+ self .orders = self .group .get_orders ()
663+ self .lattice = Lattice .from_1d_representation (cell , self .group .lattice_type )
664+ volume = self .lattice .volume
665+ self .max_wp = max_wp
666+ vol = [0 , 0 ]
667+ for el in composition .keys ():
668+ sp = Element (el )
669+ vol1 , vol2 = 0.8 * sp .covalent_radius ** 3 , 2.5 * sp .covalent_radius ** 3 #sp.vdw_radius**3
670+ vol [0 ] += composition [el ] * vol1 * np .pi * 4 / 3
671+ vol [1 ] += composition [el ] * vol2 * np .pi * 4 / 3
672+
673+ self .Zs = (int (np .round (volume / vol [1 ])), int (np .round (volume / vol [0 ])))
674+ if self .Zs [1 ] > max_Z :
675+ self .Zs = (self .Zs [0 ], max_Z )
676+ #print(f"Estimated Z range: {self.Zs} {volume} {vol}")
677+
678+ def get_wyckoff_positions (self ):
679+ """
680+ Infer possible Wyckoff position combinations based on the composition and Z range.
681+ """
682+ sols = []
683+ for Z in range (self .Zs [0 ], self .Zs [1 ]+ 1 ):
684+ comp = [n * Z for n in self .comp ]
685+ wps , _ , ids = self .group .list_wyckoff_combinations (comp , numWp = (0 , self .max_wp ))
686+ indices = sorted (range (len (ids )), key = lambda i : sum (len (x ) for x in ids [i ]))
687+ ids = [ids [i ] for i in indices ]
688+ wps = [wps [i ] for i in indices ]
689+ if len (ids ) > 0 :
690+ print (f"Z={ Z } : Found { len (ids )} Wyckoff position combinations." )
691+ wp_lists = []
692+ for id in ids :
693+ # Check if the combination is alternative to existing ones
694+ duplicate = False
695+ tmp = [len (self .group )- 1 - item for sublist in id for item in sublist ]
696+ for order in self .orders :
697+ tmp_list = order [tmp ].tolist ()
698+ #print("Checking order:", wps[i], tmp, tmp_list)
699+ if tmp_list in wp_lists :
700+ duplicate = True
701+ #print(wps[i], "is duplicate")
702+ break
703+ if not duplicate :
704+ dof = [self .group [wp ].get_dof () for sublist in id for wp in sublist ]
705+ wp_lists .append (tmp )
706+ sols .append ((self .spg , comp , self .lattice , id , len (tmp ), sum (dof )))
707+ print (f"Z={ Z } : Kept { len (sols )} Wyckoff position combinations." )
708+ # sort sols by DOF and number of WPs
709+ sols = sorted (sols , key = lambda x : (x [5 ], x [4 ]))
710+ #import sys; sys.exit()
711+ return sols
712+
713+ def get_wyckoff_positions (self ):
714+ """
715+ Infer possible Wyckoff position combinations based on the composition and Z range.
716+ """
717+ sols = []
718+ for Z in range (self .Zs [0 ], self .Zs [1 ]+ 1 ):
719+ df_z = self .df [self .df ['n_atoms' ] == Z * sum (self .comp )]
720+ if len (df_z ) == 0 : continue
721+ #print(f"Z={Z}: Found {len(df_z)} Wyckoff position combinations.")
722+ comp = [n * Z for n in self .comp ]
723+ for _ , row in df_z .iterrows ():
724+ ids = [int (x ) for x in row ['wps' ].split ('-' )]
725+ nums = [self .group [id ].multiplicity for id in ids ]
726+ solutions = find_wp_assignments (comp , ids , nums )
727+ #print(comp, ids, nums, solutions)
728+ for sol in solutions :
729+ dof = [self .group [w ].get_dof () for wp in sol for w in wp ]
730+ sols .append ((self .spg , comp , self .lattice , sol , len (sol ), sum (dof )))
731+ print (f"Z={ Z } : Kept { len (sols )} Wyckoff position combinations." )
732+ # sort sols by DOF and number of WPs
733+ sols = sorted (sols , key = lambda x : (x [5 ], x [4 ]))
734+ #import sys; sys.exit()
735+ return sols
736+
737+
738+
520739class CellManager :
521- def __init__ (self , params , missing ):
740+ def __init__ (self , spg , params , missing ):
522741 # Store raw parameters
523742 self .raw_params = params
524743 # Sort dimensions immediately for consistent comparison (e.g. [5, 44] == [44, 5])
525- self .dims = np .sort (np .array (params ))
744+ #self.dims = np.sort(np.array(params))
745+ self .dims = np .array (params )
526746 self .missing = missing
527747 # Proxy for size (Area for 2D, Volume for 3D) used for sorting
528- self .size_proxy = np .prod (self .dims )
748+ self .group = Group (spg )
749+ lattice = Lattice .from_1d_representation (self .dims , self .group .lattice_type )
750+ self .size_proxy = lattice .volume
529751
530752 def is_supercell_of (self , other , tol = 0.05 ):
531753 """Instance method: Check if 'self' is an integer multiple (supercell) of 'other'."""
@@ -553,10 +775,10 @@ def consolidate(cls, raw_data, merge_tol=0.04, supercell_tol=0.05, verbose=False
553775 sorts, merges duplicates, removes supercells, and returns the clean list.
554776 """
555777 # 1. Instantiate objects
556- solutions = [cls (d [0 ], d [1 ]) for d in raw_data ]
778+ solutions = [cls (d [0 ], d [1 ], d [ 2 ] ) for d in raw_data ]
557779
558780 # 2. Sort: Primary = Fewest Missing (Quality), Secondary = Smallest Size (Parsimony)
559- solutions .sort (key = lambda x : (x .missing , x .size_proxy ))
781+ solutions .sort (key = lambda x : (x .size_proxy , x .missing ))
560782
561783 kept_solutions = []
562784 indices_to_skip = set ()
0 commit comments