Skip to content

Commit df1d977

Browse files
committed
enable test for pxrd
1 parent 638fb6b commit df1d977

File tree

4 files changed

+248
-13
lines changed

4 files changed

+248
-13
lines changed

pyxtal/XRD_indexer.py

Lines changed: 229 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,96 @@
11
"""
22
Module for PXRD indexing and lattice parameter estimation.
33
"""
4+
from pyxtal import pyxtal
5+
from pyxtal.lattice import Lattice
46
import numpy as np
57
from 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

895
def 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+
520739
class 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()

pyxtal/db.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1590,7 +1590,7 @@ def update_row_topology(self, StructureType="Auto", overwrite=True, prefix=None,
15901590
"""
15911591

15921592
# Save Julia script
1593-
script_path = "process_topology.jl"
1593+
script_path = prefix + "_process_topology.jl"
15941594
with open(script_path, "w") as f:
15951595
f.write(julia_script)
15961596

pyxtal/symmetry.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -511,6 +511,19 @@ def extract_dof(site):
511511

512512
return np.array([extract_dof(site) for site in sites])
513513

514+
def get_orders(self):
515+
"""
516+
Get possible Wyckoff position orders based on the composition and Z range.
517+
"""
518+
orders = []
519+
for map_str in self.get_alternatives()['Transformed WP'][1:]:
520+
original_list = map_str.split()
521+
sorted_reference = sorted(original_list)
522+
order = [sorted_reference.index(char) for char in original_list]
523+
orders.append(order)
524+
orders = np.array(orders, dtype=int)
525+
return orders
526+
514527
def get_spg_representation(self):
515528
"""
516529
Get the one-hot encoding of the space group.
@@ -566,7 +579,7 @@ def get_subgroup_composition(self, ids, g_types=['t', 'k'], max_atoms=100,
566579
letter = r[-1]#; print("test letter:", relation[true_id])
567580
sub_ids[j].append(len(sub_gg) - letters.index(letter) - 1)
568581
if sum(len(sublist) for sublist in sub_ids) <= max_wps:
569-
data = (sub_g, sub_ids)
582+
data = (sub_g, sub_ids, N_atoms * sub['index'][i])
570583
if data not in sub_symmetries:
571584
sub_symmetries.append(data)
572585
if verbose:

tests/test_group.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -88,10 +88,10 @@ def test_check_compatible(self):
8888
def test_subgroup_composition(self):
8989
g = Group(154)
9090
comps = g.get_subgroup_composition([[2], [0]], g_types=["t", "k"])
91-
assert comps[0] == (5, [[2, 0], [0, 0, 0]])
92-
assert comps[1] == (145, [[0], [0, 0]])
93-
assert comps[2] == (152, [[0], [0, 0]])
94-
assert comps[3] == (152, [[2, 1], [0, 0]])
91+
assert comps[0] == (5, [[2, 0], [0, 0, 0]], 27)
92+
assert comps[1] == (145, [[0], [0, 0]], 18)
93+
assert comps[2] == (152, [[0], [0, 0]], 18)
94+
assert comps[3] == (152, [[2, 1], [0, 0]], 18)
9595
assert len(comps) == 8
9696

9797
def test_search_supergroup_paths(self):

0 commit comments

Comments
 (0)