Skip to content
This repository was archived by the owner on May 5, 2026. It is now read-only.

Commit e6af2c2

Browse files
authored
Add files via upload
- Improve performance of IDPP method
1 parent dd1f800 commit e6af2c2

2 files changed

Lines changed: 267 additions & 6 deletions

File tree

multioptpy/idpp.py

Lines changed: 265 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import numpy as np
22
import copy
33

4+
from parameter import (
5+
covalent_radii_lib,
6+
UFF_VDW_distance_lib,
7+
number_element,
8+
UnitValueLib,)
9+
410
class 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+

multioptpy/neb.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -711,7 +711,7 @@ def make_geometry_list(self, init_input, partition_function):
711711
# Apply IDPP if requested
712712
if self.config.IDPP_flag:
713713
IDPP_obj = IDPP()
714-
tmp_data = IDPP_obj.opt_path(tmp_data)
714+
tmp_data = IDPP_obj.opt_path(tmp_data, element_list)
715715

716716
# Align distances if requested
717717
if self.config.align_distances > 0:
@@ -821,7 +821,7 @@ def apply_climbing_image(geometry_list, energy_list):
821821
tmp_geometry = geometry_list[i] + (geometry_list[i+1] - geometry_list[i]) * delta_t
822822
tmp_geom_list = [geometry_list[i], tmp_geometry, geometry_list[i+1]]
823823
idpp_instance = IDPP()
824-
tmp_geom_list = idpp_instance.opt_path(tmp_geom_list)
824+
tmp_geom_list = idpp_instance.opt_path(tmp_geom_list, element_list)
825825
geometry_list[i] = tmp_geom_list[1]
826826
return geometry_list
827827

0 commit comments

Comments
 (0)