11import numpy as np
22import copy
33
4+ from parameter import (
5+ covalent_radii_lib ,
6+ UFF_VDW_distance_lib ,
7+ number_element ,
8+ UnitValueLib ,)
9+
410class IDPP :
511 def __init__ (self ):
612 #ref.: arXiv:1406.1512v1
@@ -65,17 +71,28 @@ def get_func_and_deriv(self, pos_list, n_node, number_of_node):
6571 return obj_func , first_deriv
6672
6773
68- def opt_path (self , geometry_list ):
74+ def opt_path (self , geometry_list , element_list ):
6975 print ("IDPP Optimization" )
76+
7077 for i in range (self .iteration ):
7178 obj_func_list = []
79+ #FBENM_instance = FBENM(element_list, geometry_list)
7280 for j in range (len (geometry_list )):
7381
7482 if j == 0 or j == len (geometry_list ) - 1 :
7583 continue
7684
77- obj_func , first_deriv = self .get_func_and_deriv (geometry_list , len (geometry_list ), j )
78- geometry_list [j ] -= self .lr * first_deriv
85+ obj_func_idpp , first_deriv_idpp = self .get_func_and_deriv (geometry_list , len (geometry_list ), j )
86+
87+ #obj_func_fbenm, first_deriv_fbenm = FBENM_instance.get_func_and_deriv(geometry_list[j])
88+
89+ obj_func = obj_func_idpp # + obj_func_fbenm
90+ first_deriv = first_deriv_idpp # + first_deriv_fbenm
91+
92+ step_norm = min (self .lr , np .linalg .norm (first_deriv ))
93+ norm_step = first_deriv / np .linalg .norm (first_deriv )
94+
95+ geometry_list [j ] -= step_norm * norm_step
7996
8097 obj_func_list .append (obj_func )
8198 if i % 200 == 0 :
@@ -90,4 +107,248 @@ def opt_path(self, geometry_list):
90107 return geometry_list
91108
92109
93-
110+ class FBENM :# This class is under construction.
111+ """
112+ Flat-Bottom Elastic Network Model (FB-ENM) potential.
113+ ref.: J.Chem.TheoryComput.2024,20,7176−7187
114+
115+ Potential per pair (i, j), with current distance r = |r_i - r_j|:
116+ V_FB-ENM = (r - d_min)^2 / (dd_min)^2 (if r < d_min)
117+ 0 (if d_min <= r <= d_max)
118+ (r - d_max)^2 / (dd_max)^2 (if r > d_max)
119+
120+ Constraints:
121+ - Strong constraints (for bonded pairs):
122+ d_min = d_max = distance in the endpoint structure that is closer to the current structure
123+ - Weak constraints (for non-bonded pairs):
124+ d_min = 0.5 * (UFF vdW radii sum)
125+ d_max = maximum distance across the path
126+ """
127+
128+ def __init__ (
129+ self ,
130+ elements ,
131+ geometry_list ,
132+ bond_scale = 1.3 ,
133+ delta_scale = 2.0 ,
134+ eps = 1e-12 ,
135+ ):
136+ """
137+ Initialize FB-ENM model.
138+
139+ Args:
140+ elements: list of element symbols (e.g., ['C','H',...]) or atomic numbers.
141+ geometry_list: list of (N,3) positions arrays. Must include endpoints [0] and [-1].
142+ bond_scale: multiplier for sum of covalent radii to determine bonding.
143+ delta_scale: scale factor for dd_min and dd_max.
144+ eps: small number for numerical stability.
145+ """
146+ self .elements = self .convert_to_symbols (elements )
147+ self .N = len (self .elements )
148+ assert len (geometry_list ) >= 2 , "geometry_list must include at least two images (endpoints)."
149+ self .geometry_list = [np .array (g , dtype = float , copy = True ) for g in geometry_list ]
150+ self .bond_scale = float (bond_scale )
151+ self .delta_scale = float (delta_scale )
152+ self .eps = float (eps )
153+
154+ self .covalent_radii_lib = covalent_radii_lib
155+ self .UFF_VDW_distance_lib = UFF_VDW_distance_lib
156+ self .number_element = number_element
157+
158+ # Precompute endpoint distances
159+ self .d0 = self .pairwise_distances (self .geometry_list [0 ])
160+ self .dL = self .pairwise_distances (self .geometry_list [- 1 ])
161+
162+ # Precompute per-pair maximum distance across path
163+ self .d_max_path = self .calculate_max_distances (self .geometry_list )
164+
165+ # Precompute per-pair covalent radii sum and UFF vdW radii sum
166+ self .cov_sum = self .calculate_covalent_sum (self .elements )
167+ self .vdw_sum = self .calculate_vdw_sum (self .elements )
168+
169+ def convert_to_symbols (self , elements ):
170+ """Convert a list of element identifiers to symbols."""
171+ syms = []
172+ for e in elements :
173+ if isinstance (e , str ):
174+ syms .append (e )
175+ else :
176+ syms .append (number_element (int (e )))
177+ return syms
178+
179+ def pairwise_distances (self , pos ):
180+ """Compute pairwise distance matrix (N,N)."""
181+ dr = pos [:, None , :] - pos [None , :, :]
182+ return np .linalg .norm (dr , axis = - 1 )
183+
184+ def calculate_max_distances (self , geometry_list ):
185+ """Compute per-pair maximum distance across a list of images."""
186+ N = geometry_list [0 ].shape [0 ]
187+ dmax = np .zeros ((N , N ), dtype = float )
188+
189+ for k , g in enumerate (geometry_list ):
190+ d = self .pairwise_distances (g )
191+ if k == 0 :
192+ dmax = d
193+ else :
194+ dmax = np .maximum (dmax , d )
195+ # Symmetrize and zero diagonals
196+ dmax = 0.5 * (dmax + dmax .T )
197+ np .fill_diagonal (dmax , 0.0 )
198+ return dmax * 2.0
199+
200+ def calculate_covalent_sum (self , elements ):
201+ """Sum of covalent radii per pair."""
202+ N = len (elements )
203+ r = np .zeros ((N ,), dtype = float )
204+ for i , e in enumerate (elements ):
205+ r [i ] = self .covalent_radii_lib (e ) * UnitValueLib ().bohr2angstroms
206+ rs = r [:, None ] + r [None , :]
207+ np .fill_diagonal (rs , 0.0 )
208+ return rs
209+
210+ def calculate_vdw_sum (self , elements ):
211+ """Sum of UFF vdW radii per pair."""
212+ N = len (elements )
213+ r = np .zeros ((N ,), dtype = float )
214+ for i , e in enumerate (elements ):
215+ r [i ] = self .UFF_VDW_distance_lib (e ) * UnitValueLib ().bohr2angstroms
216+ rs = r [:, None ] + r [None , :]
217+ np .fill_diagonal (rs , 0.0 )
218+ return rs
219+
220+ def build_band_parameters (self , pos ):
221+ """
222+ Build per-pair band parameters (d_min, d_max, dd_min, dd_max) using vectorized operations.
223+ """
224+ r = self .pairwise_distances (pos )
225+ N = len (r )
226+
227+ # Determine which pairs are bonded (covalent)
228+ bonded_threshold = self .bond_scale * self .cov_sum
229+ bonded = r <= bonded_threshold
230+
231+ # Initialize d_min and d_max arrays
232+ d_min = np .zeros ((N , N ), dtype = float )
233+ d_max = np .zeros ((N , N ), dtype = float )
234+
235+ # Create masks for upper triangle (to avoid duplicate work)
236+ triu_indices = np .triu_indices (N , k = 1 )
237+
238+ # Extract relevant distances for all upper triangle pairs
239+ current_dists = r [triu_indices ]
240+ d0_dists = self .d0 [triu_indices ]
241+ dL_dists = self .dL [triu_indices ]
242+
243+ # Determine which endpoint is closer for each pair
244+ use_d0 = np .abs (current_dists - d0_dists ) <= np .abs (current_dists - dL_dists )
245+
246+ # Create target distances array based on the closer endpoint
247+ target_dists = np .where (use_d0 , d0_dists , dL_dists )
248+
249+ # Create mask arrays for bonded and non-bonded pairs in upper triangle
250+ bonded_pairs = bonded [triu_indices ]
251+ bonded_pairs [:] = False
252+ nonbonded_pairs = ~ bonded_pairs
253+
254+ # Create temporary arrays for upper triangle values
255+ d_min_upper = np .zeros_like (current_dists )
256+ d_max_upper = np .zeros_like (current_dists )
257+
258+ # Set values for bonded pairs (strong constraint)
259+ d_min_upper [bonded_pairs ] = target_dists [bonded_pairs ]
260+ d_max_upper [bonded_pairs ] = target_dists [bonded_pairs ]
261+
262+ # Set values for non-bonded pairs (weak constraint)
263+ vdw_values = self .vdw_sum [triu_indices ]
264+ dmax_values = self .d_max_path [triu_indices ]
265+
266+ d_min_upper [nonbonded_pairs ] = vdw_values [nonbonded_pairs ]
267+ d_max_upper [nonbonded_pairs ] = dmax_values [nonbonded_pairs ]
268+
269+ # Fill the upper triangle of the matrices
270+ d_min [triu_indices ] = d_min_upper
271+ d_max [triu_indices ] = d_max_upper
272+
273+ # Make symmetric by adding transpose (diagonal will be doubled, but we zero it later)
274+ d_min = d_min + d_min .T
275+ d_max = d_max + d_max .T
276+
277+ # Zero the diagonal to handle division safely
278+ np .fill_diagonal (d_min , 0.0 )
279+ np .fill_diagonal (d_max , 0.0 )
280+
281+ # Calculate delta parameters
282+ dd_min = self .delta_scale * np .maximum (d_min , self .eps )
283+ dd_max = self .delta_scale * np .maximum (d_max , self .eps )
284+ np .fill_diagonal (dd_min , 1.0 )
285+ np .fill_diagonal (dd_max , 1.0 )
286+
287+ return d_min , d_max , dd_min , dd_max
288+
289+ def get_func_and_deriv (self , pos ):
290+ """
291+ Compute FB-ENM energy and Cartesian gradient.
292+
293+ Args:
294+ pos: (N,3) array of positions.
295+
296+ Returns:
297+ energy: float
298+ grad: (N,3) array, gradient dE/dR
299+ """
300+ pos = np .asarray (pos , dtype = float )
301+ N = pos .shape [0 ]
302+ assert N == self .N , f"Position size { N } does not match the number of elements { self .N } ."
303+
304+ # Pairwise differences and distances
305+ dr = pos [:, None , :] - pos [None , :, :] # (N,N,3)
306+ r = np .linalg .norm (dr , axis = - 1 ) # (N,N)
307+
308+ # Safe reciprocal distance (for gradient calculation)
309+ rinv = np .zeros_like (r )
310+ mask = r > 0.0
311+ rinv [mask ] = 1.0 / r [mask ]
312+
313+ # Build band parameters for the current configuration
314+ d_min , d_max , dd_min , dd_max = self .build_band_parameters (pos )
315+
316+ # Compute energy contributions per pair
317+ energy_rep = np .zeros_like (r )
318+ energy_att = np .zeros_like (r )
319+
320+ # Repulsive contribution (r < d_min)
321+ rep_mask = r < d_min
322+ if np .any (rep_mask ):
323+ energy_rep [rep_mask ] = ((r [rep_mask ] - d_min [rep_mask ]) / dd_min [rep_mask ]) ** 2
324+
325+ # Attractive contribution (r > d_max)
326+ att_mask = r > d_max
327+ if np .any (att_mask ):
328+ energy_att [att_mask ] = ((r [att_mask ] - d_max [att_mask ]) / dd_max [att_mask ]) ** 2
329+
330+ # Total pair energy
331+ energy_pairs = energy_rep + energy_att
332+
333+ # Calculate derivatives dV/dr for gradient
334+ dVdr = np .zeros_like (r )
335+
336+ # Derivative for repulsion (r < d_min)
337+ if np .any (rep_mask ):
338+ dVdr [rep_mask ] = 2.0 * (r [rep_mask ] - d_min [rep_mask ]) / (dd_min [rep_mask ] ** 2 )
339+
340+ # Derivative for attraction (r > d_max)
341+ if np .any (att_mask ):
342+ dVdr [att_mask ] = 2.0 * (r [att_mask ] - d_max [att_mask ]) / (dd_max [att_mask ] ** 2 )
343+
344+ # Compute gradient: dE/dr_i = sum_j [(dV_ij/dr_ij) * (r_i - r_j) / r_ij]
345+ # Vectorized version: multiply weights by direction vectors and sum
346+ weights = dVdr * rinv # (N,N)
347+ grad = np .einsum ('ij,ijk->ik' , weights , dr ) # Einstein summation to compute weighted sum
348+
349+ # Total energy (0.5 * sum to avoid double-counting)
350+ total_energy = 0.5 * np .sum (energy_pairs )
351+
352+ return total_energy , grad
353+
354+
0 commit comments