@@ -250,11 +250,28 @@ def description_text(self, P=None, short=False):
250250 f" For each, evaluate the interaction energy ΔE(R) along the "
251251 f"approach using the '{ P ['contact method' ]} ' method out to "
252252 f"{ P ['maximum separation' ]} , then pool the candidate points across "
253- f"all orientations and keep a set that is flat in interaction "
254- f"energy -- sorting them into { P ['number of energy bins' ]} ΔE bins "
255- f"over the range set by '{ P ['energy levels' ]} ' and capping each bin "
256- f"equally (about { P ['target configurations' ]} configurations total)."
253+ f"all orientations and keep about { P ['target configurations' ]} of "
254+ f"them"
257255 )
256+ method = P .get ("selection method" , "energy bins" )
257+ if method == "descriptor diversity" :
258+ text += (
259+ " by clustering in a space of geometric collective variables "
260+ "plus the interaction energy and keeping one per cluster (a "
261+ "DIRECT-style, de-duplicated, diverse set)."
262+ )
263+ elif method == "energy bins + diversity" :
264+ text += (
265+ f" by sorting them into { P ['number of energy bins' ]} ΔE bins "
266+ "(flat in interaction energy) and keeping a geometrically "
267+ "diverse subset of each bin (DIRECT clustering)."
268+ )
269+ else :
270+ text += (
271+ f" by sorting them into { P ['number of energy bins' ]} ΔE bins "
272+ f"over the range set by '{ P ['energy levels' ]} ' and capping each "
273+ f"bin equally (flat in interaction energy)."
274+ )
258275 else :
259276 text += (
260277 f" For each, locate the contact distance using the "
@@ -1030,6 +1047,177 @@ def _global_stratify(self, dE_values, P, rng):
10301047 selected .extend (int (i ) for i in pick )
10311048 return sorted (selected )
10321049
1050+ def _candidate_dimers (
1051+ self , candidates , orient_data , symbols_A , symbols_B , a_idx , b_idx
1052+ ):
1053+ """Build ``dimer_analysis.Dimer`` objects for a list of candidates."""
1054+ from dimer_builder_step import dimer_analysis
1055+
1056+ out = []
1057+ for o , d , _e in candidates :
1058+ coords = orient_data [o ]["rebuild" ](d )
1059+ out .append (
1060+ dimer_analysis .Dimer (
1061+ symbols_A = symbols_A ,
1062+ xyz_A = coords [a_idx ],
1063+ symbols_B = symbols_B ,
1064+ xyz_B = coords [b_idx ],
1065+ )
1066+ )
1067+ return out
1068+
1069+ def _cluster_pick (self , dimers , dE , target , energy_weight ):
1070+ """DIRECT featurize → cluster → nearest-centroid pick; local indices.
1071+
1072+ Featurizes each dimer by the collective variables ``dimer_analysis``
1073+ computes (separation, approach unit vector, relative orientation,
1074+ closest contact); if ``dE`` is given, the interaction energy is appended
1075+ as an extra feature scaled by ``energy_weight`` (so energy can be made to
1076+ count for more than one geometric dimension). Follows DIRECT (Qi et al.,
1077+ npj Comput. Mater. 2024): standardize → PCA (variance-weighted scores) →
1078+ BIRCH into ``target`` clusters → keep the member nearest each centroid.
1079+ Returns the kept indices into ``dimers``.
1080+ """
1081+ import warnings
1082+
1083+ from dimer_builder_step import dimer_analysis
1084+ from sklearn .preprocessing import StandardScaler
1085+ from sklearn .decomposition import PCA
1086+ from sklearn .cluster import Birch
1087+ from sklearn .exceptions import ConvergenceWarning
1088+
1089+ n = len (dimers )
1090+ if n <= target :
1091+ return list (range (n ))
1092+
1093+ m = dimer_analysis .compute_metrics (dimers )
1094+ cols = [
1095+ m .R ,
1096+ m .approach_vec [:, 0 ],
1097+ m .approach_vec [:, 1 ],
1098+ m .approach_vec [:, 2 ],
1099+ m .orient_angle ,
1100+ m .min_contact ,
1101+ ]
1102+ if dE is not None :
1103+ cols .append (np .asarray (dE , dtype = float ))
1104+ feats = np .column_stack (cols )
1105+ # Fill non-finite entries (e.g. orientation angle for a monatomic
1106+ # fragment) with the column mean so every candidate is clusterable.
1107+ for j in range (feats .shape [1 ]):
1108+ col = feats [:, j ]
1109+ bad = ~ np .isfinite (col )
1110+ if bad .any ():
1111+ col [bad ] = np .nanmean (col [~ bad ]) if (~ bad ).any () else 0.0
1112+
1113+ X = StandardScaler ().fit_transform (feats )
1114+ if dE is not None :
1115+ X [:, - 1 ] *= float (energy_weight ) # up-weight the energy axis
1116+
1117+ Z = PCA (n_components = min (X .shape )).fit_transform (X )
1118+ # Normalize the (variance-weighted) PCA scores to an overall unit scale so
1119+ # the BIRCH threshold behaves consistently, then shrink the threshold
1120+ # until BIRCH resolves at least 'target' subclusters (it warns and
1121+ # returns fewer otherwise -- the classic BIRCH pitfall).
1122+ scale = float (Z .std ())
1123+ if scale > 0.0 :
1124+ Z = Z / scale
1125+ threshold = 0.5
1126+ with warnings .catch_warnings ():
1127+ warnings .simplefilter ("ignore" , ConvergenceWarning )
1128+ for _ in range (10 ):
1129+ model = Birch (threshold = threshold , n_clusters = target ).fit (Z )
1130+ if len (model .subcluster_centers_ ) >= target :
1131+ break
1132+ threshold *= 0.5
1133+ labels = model .labels_
1134+
1135+ keep = []
1136+ for lab in np .unique (labels ):
1137+ members = np .where (labels == lab )[0 ]
1138+ centroid = Z [members ].mean (axis = 0 )
1139+ nearest = members [np .argmin (((Z [members ] - centroid ) ** 2 ).sum (axis = 1 ))]
1140+ keep .append (int (nearest ))
1141+ return sorted (keep )
1142+
1143+ def _direct_select (
1144+ self , candidates , orient_data , symbols_A , symbols_B , a_idx , b_idx , P
1145+ ):
1146+ """Method A: one global DIRECT clustering over the CVs **plus ΔE**.
1147+
1148+ ΔE is included as a feature scaled by the 'energy weight' parameter, so a
1149+ single clustering covers geometry and energy together; near-duplicate
1150+ geometries collapse (de-duplication) and the kept set spreads over both
1151+ axes. Returns the kept indices into ``candidates``.
1152+ """
1153+ target = max (int (P ["target configurations" ]), 1 )
1154+ if len (candidates ) <= target :
1155+ return list (range (len (candidates )))
1156+ dimers = self ._candidate_dimers (
1157+ candidates , orient_data , symbols_A , symbols_B , a_idx , b_idx
1158+ )
1159+ dE = [c [2 ] for c in candidates ]
1160+ weight = float (P .get ("energy weight" , 1.0 ))
1161+ return self ._cluster_pick (dimers , dE , target , weight )
1162+
1163+ def _select_within_bins (
1164+ self , candidates , orient_data , symbols_A , symbols_B , a_idx , b_idx , P
1165+ ):
1166+ """Method B: flat-in-energy bins, DIRECT-diversify the geometry per bin.
1167+
1168+ Bins the candidates by ΔE (guaranteeing flat energy, like 'energy bins'),
1169+ then within each over-full bin keeps a geometrically diverse subset via
1170+ DIRECT on the geometric CVs alone (ΔE excluded -- energy is already fixed
1171+ by the bin). Returns the kept indices into ``candidates``.
1172+ """
1173+ dE = np .array ([c [2 ] for c in candidates ], dtype = float )
1174+ if dE .size == 0 :
1175+ return []
1176+ n_bins = max (int (P ["number of energy bins" ]), 1 )
1177+ per_bin = max (int (P ["target configurations" ]) // n_bins , 1 )
1178+ lo = float (dE .min ())
1179+ cap = self ._repulsive_cap (P )
1180+ hi = float (cap ) if (cap is not None and cap > lo ) else float (dE .max ())
1181+ if hi <= lo :
1182+ hi = lo + 1.0
1183+ edges = np .linspace (lo , hi , n_bins + 1 )
1184+ which = np .clip (np .digitize (dE , edges ) - 1 , 0 , n_bins - 1 )
1185+
1186+ keep = []
1187+ for b in range (n_bins ):
1188+ members = np .where (which == b )[0 ]
1189+ if len (members ) <= per_bin :
1190+ keep .extend (int (i ) for i in members )
1191+ continue
1192+ subset = [candidates [i ] for i in members ]
1193+ dimers = self ._candidate_dimers (
1194+ subset , orient_data , symbols_A , symbols_B , a_idx , b_idx
1195+ )
1196+ local = self ._cluster_pick (dimers , None , per_bin , 1.0 )
1197+ keep .extend (int (members [j ]) for j in local )
1198+ return sorted (keep )
1199+
1200+ def _select_pool (
1201+ self , candidates , orient_data , symbols_A , symbols_B , a_idx , b_idx , P , rng
1202+ ):
1203+ """Down-select the pooled candidates by the chosen 'selection method'.
1204+
1205+ Returns the kept subset of ``candidates`` (both methods target about
1206+ 'target configurations').
1207+ """
1208+ method = P .get ("selection method" , "energy bins" )
1209+ if method == "descriptor diversity" :
1210+ keep = self ._direct_select (
1211+ candidates , orient_data , symbols_A , symbols_B , a_idx , b_idx , P
1212+ )
1213+ elif method == "energy bins + diversity" :
1214+ keep = self ._select_within_bins (
1215+ candidates , orient_data , symbols_A , symbols_B , a_idx , b_idx , P
1216+ )
1217+ else :
1218+ keep = self ._global_stratify ([c [2 ] for c in candidates ], P , rng )
1219+ return [candidates [i ] for i in keep ]
1220+
10331221 @staticmethod
10341222 def _make_interpolator (ds , dE ):
10351223 """A function mapping a distance (Å) to its interpolated ΔE (kJ/mol)."""
@@ -1342,10 +1530,13 @@ def assemble(d, xyzA=xyzA, xyzB=xyzB, axis=axis):
13421530 if engine is not None :
13431531 engine .close ()
13441532
1345- # Phase 2: globally stratify the pooled candidates by energy.
1533+ # Phase 2: globally down-select the pooled candidates.
1534+ a_idx = np .arange (nA )
1535+ b_idx = np .arange (nA , nA + B0 .n_atoms )
13461536 if global_strat :
1347- keep = self ._global_stratify ([c [2 ] for c in candidates ], P , rng )
1348- candidates = [candidates [i ] for i in keep ]
1537+ candidates = self ._select_pool (
1538+ candidates , orient_data , symbols_A , symbols_B , a_idx , b_idx , P , rng
1539+ )
13491540 candidates .sort (key = lambda c : (c [0 ], c [1 ]))
13501541
13511542 # Phase 3: build the selected candidates.
@@ -1362,8 +1553,8 @@ def assemble(d, xyzA=xyzA, xyzB=xyzB, axis=axis):
13621553 movable_ids = movable_ids ,
13631554 symbols_A = symbols_A ,
13641555 symbols_B = symbols_B ,
1365- a_idx = np . arange ( nA ) ,
1366- b_idx = np . arange ( nA , nA + B0 . n_atoms ) ,
1556+ a_idx = a_idx ,
1557+ b_idx = b_idx ,
13671558 )
13681559
13691560 stats = self ._stats (name , P ["number of orientations" ], separations )
@@ -1499,10 +1690,18 @@ def assemble(
14991690 if engine is not None :
15001691 engine .close ()
15011692
1502- # Phase 2: globally stratify by energy (energy-stratified spacing) .
1693+ # Phase 2: globally down-select the pooled candidates .
15031694 if global_strat :
1504- keep = self ._global_stratify ([c [2 ] for c in candidates ], P , rng )
1505- candidates = [candidates [i ] for i in keep ]
1695+ candidates = self ._select_pool (
1696+ candidates ,
1697+ orient_data ,
1698+ symbols_fixed ,
1699+ symbols_movable ,
1700+ fixed_idx ,
1701+ movable_idx ,
1702+ P ,
1703+ rng ,
1704+ )
15061705 candidates .sort (key = lambda c : (c [0 ], c [1 ]))
15071706
15081707 # Phase 3: build the selected candidates.
0 commit comments