MultiOptPy 1.20.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- multioptpy/Calculator/__init__.py +0 -0
- multioptpy/Calculator/ase_calculation_tools.py +424 -0
- multioptpy/Calculator/ase_tools/__init__.py +0 -0
- multioptpy/Calculator/ase_tools/fairchem.py +28 -0
- multioptpy/Calculator/ase_tools/gamess.py +19 -0
- multioptpy/Calculator/ase_tools/gaussian.py +165 -0
- multioptpy/Calculator/ase_tools/mace.py +28 -0
- multioptpy/Calculator/ase_tools/mopac.py +19 -0
- multioptpy/Calculator/ase_tools/nwchem.py +31 -0
- multioptpy/Calculator/ase_tools/orca.py +22 -0
- multioptpy/Calculator/ase_tools/pygfn0.py +37 -0
- multioptpy/Calculator/dxtb_calculation_tools.py +344 -0
- multioptpy/Calculator/emt_calculation_tools.py +458 -0
- multioptpy/Calculator/gpaw_calculation_tools.py +183 -0
- multioptpy/Calculator/lj_calculation_tools.py +314 -0
- multioptpy/Calculator/psi4_calculation_tools.py +334 -0
- multioptpy/Calculator/pwscf_calculation_tools.py +189 -0
- multioptpy/Calculator/pyscf_calculation_tools.py +327 -0
- multioptpy/Calculator/sqm1_calculation_tools.py +611 -0
- multioptpy/Calculator/sqm2_calculation_tools.py +376 -0
- multioptpy/Calculator/tblite_calculation_tools.py +352 -0
- multioptpy/Calculator/tersoff_calculation_tools.py +818 -0
- multioptpy/Constraint/__init__.py +0 -0
- multioptpy/Constraint/constraint_condition.py +834 -0
- multioptpy/Coordinate/__init__.py +0 -0
- multioptpy/Coordinate/polar_coordinate.py +199 -0
- multioptpy/Coordinate/redundant_coordinate.py +638 -0
- multioptpy/IRC/__init__.py +0 -0
- multioptpy/IRC/converge_criteria.py +28 -0
- multioptpy/IRC/dvv.py +544 -0
- multioptpy/IRC/euler.py +439 -0
- multioptpy/IRC/hpc.py +564 -0
- multioptpy/IRC/lqa.py +540 -0
- multioptpy/IRC/modekill.py +662 -0
- multioptpy/IRC/rk4.py +579 -0
- multioptpy/Interpolation/__init__.py +0 -0
- multioptpy/Interpolation/adaptive_interpolation.py +283 -0
- multioptpy/Interpolation/binomial_interpolation.py +179 -0
- multioptpy/Interpolation/geodesic_interpolation.py +785 -0
- multioptpy/Interpolation/interpolation.py +156 -0
- multioptpy/Interpolation/linear_interpolation.py +473 -0
- multioptpy/Interpolation/savitzky_golay_interpolation.py +252 -0
- multioptpy/Interpolation/spline_interpolation.py +353 -0
- multioptpy/MD/__init__.py +0 -0
- multioptpy/MD/thermostat.py +185 -0
- multioptpy/MEP/__init__.py +0 -0
- multioptpy/MEP/pathopt_bneb_force.py +443 -0
- multioptpy/MEP/pathopt_dmf_force.py +448 -0
- multioptpy/MEP/pathopt_dneb_force.py +130 -0
- multioptpy/MEP/pathopt_ewbneb_force.py +207 -0
- multioptpy/MEP/pathopt_gpneb_force.py +512 -0
- multioptpy/MEP/pathopt_lup_force.py +113 -0
- multioptpy/MEP/pathopt_neb_force.py +225 -0
- multioptpy/MEP/pathopt_nesb_force.py +205 -0
- multioptpy/MEP/pathopt_om_force.py +153 -0
- multioptpy/MEP/pathopt_qsm_force.py +174 -0
- multioptpy/MEP/pathopt_qsmv2_force.py +304 -0
- multioptpy/ModelFunction/__init__.py +7 -0
- multioptpy/ModelFunction/avoiding_model_function.py +29 -0
- multioptpy/ModelFunction/binary_image_ts_search_model_function.py +47 -0
- multioptpy/ModelFunction/conical_model_function.py +26 -0
- multioptpy/ModelFunction/opt_meci.py +50 -0
- multioptpy/ModelFunction/opt_mesx.py +47 -0
- multioptpy/ModelFunction/opt_mesx_2.py +49 -0
- multioptpy/ModelFunction/seam_model_function.py +27 -0
- multioptpy/ModelHessian/__init__.py +0 -0
- multioptpy/ModelHessian/approx_hessian.py +147 -0
- multioptpy/ModelHessian/calc_params.py +227 -0
- multioptpy/ModelHessian/fischer.py +236 -0
- multioptpy/ModelHessian/fischerd3.py +360 -0
- multioptpy/ModelHessian/fischerd4.py +398 -0
- multioptpy/ModelHessian/gfn0xtb.py +633 -0
- multioptpy/ModelHessian/gfnff.py +709 -0
- multioptpy/ModelHessian/lindh.py +165 -0
- multioptpy/ModelHessian/lindh2007d2.py +707 -0
- multioptpy/ModelHessian/lindh2007d3.py +822 -0
- multioptpy/ModelHessian/lindh2007d4.py +1030 -0
- multioptpy/ModelHessian/morse.py +106 -0
- multioptpy/ModelHessian/schlegel.py +144 -0
- multioptpy/ModelHessian/schlegeld3.py +322 -0
- multioptpy/ModelHessian/schlegeld4.py +559 -0
- multioptpy/ModelHessian/shortrange.py +346 -0
- multioptpy/ModelHessian/swartd2.py +496 -0
- multioptpy/ModelHessian/swartd3.py +706 -0
- multioptpy/ModelHessian/swartd4.py +918 -0
- multioptpy/ModelHessian/tshess.py +40 -0
- multioptpy/Optimizer/QHAdam.py +61 -0
- multioptpy/Optimizer/__init__.py +0 -0
- multioptpy/Optimizer/abc_fire.py +83 -0
- multioptpy/Optimizer/adabelief.py +58 -0
- multioptpy/Optimizer/adabound.py +68 -0
- multioptpy/Optimizer/adadelta.py +65 -0
- multioptpy/Optimizer/adaderivative.py +56 -0
- multioptpy/Optimizer/adadiff.py +68 -0
- multioptpy/Optimizer/adafactor.py +70 -0
- multioptpy/Optimizer/adam.py +65 -0
- multioptpy/Optimizer/adamax.py +62 -0
- multioptpy/Optimizer/adamod.py +83 -0
- multioptpy/Optimizer/adamw.py +65 -0
- multioptpy/Optimizer/adiis.py +523 -0
- multioptpy/Optimizer/afire_neb.py +282 -0
- multioptpy/Optimizer/block_hessian_update.py +709 -0
- multioptpy/Optimizer/c2diis.py +491 -0
- multioptpy/Optimizer/component_wise_scaling.py +405 -0
- multioptpy/Optimizer/conjugate_gradient.py +82 -0
- multioptpy/Optimizer/conjugate_gradient_neb.py +345 -0
- multioptpy/Optimizer/coordinate_locking.py +405 -0
- multioptpy/Optimizer/dic_rsirfo.py +1015 -0
- multioptpy/Optimizer/ediis.py +417 -0
- multioptpy/Optimizer/eve.py +76 -0
- multioptpy/Optimizer/fastadabelief.py +61 -0
- multioptpy/Optimizer/fire.py +77 -0
- multioptpy/Optimizer/fire2.py +249 -0
- multioptpy/Optimizer/fire_neb.py +92 -0
- multioptpy/Optimizer/gan_step.py +486 -0
- multioptpy/Optimizer/gdiis.py +609 -0
- multioptpy/Optimizer/gediis.py +203 -0
- multioptpy/Optimizer/geodesic_step.py +433 -0
- multioptpy/Optimizer/gpmin.py +633 -0
- multioptpy/Optimizer/gpr_step.py +364 -0
- multioptpy/Optimizer/gradientdescent.py +78 -0
- multioptpy/Optimizer/gradientdescent_neb.py +52 -0
- multioptpy/Optimizer/hessian_update.py +433 -0
- multioptpy/Optimizer/hybrid_rfo.py +998 -0
- multioptpy/Optimizer/kdiis.py +625 -0
- multioptpy/Optimizer/lars.py +21 -0
- multioptpy/Optimizer/lbfgs.py +253 -0
- multioptpy/Optimizer/lbfgs_neb.py +355 -0
- multioptpy/Optimizer/linesearch.py +236 -0
- multioptpy/Optimizer/lookahead.py +40 -0
- multioptpy/Optimizer/nadam.py +64 -0
- multioptpy/Optimizer/newton.py +200 -0
- multioptpy/Optimizer/prodigy.py +70 -0
- multioptpy/Optimizer/purtubation.py +16 -0
- multioptpy/Optimizer/quickmin_neb.py +245 -0
- multioptpy/Optimizer/radam.py +75 -0
- multioptpy/Optimizer/rfo_neb.py +302 -0
- multioptpy/Optimizer/ric_rfo.py +842 -0
- multioptpy/Optimizer/rl_step.py +627 -0
- multioptpy/Optimizer/rmspropgrave.py +65 -0
- multioptpy/Optimizer/rsirfo.py +1647 -0
- multioptpy/Optimizer/rsprfo.py +1056 -0
- multioptpy/Optimizer/sadam.py +60 -0
- multioptpy/Optimizer/samsgrad.py +63 -0
- multioptpy/Optimizer/tr_lbfgs.py +678 -0
- multioptpy/Optimizer/trim.py +273 -0
- multioptpy/Optimizer/trust_radius.py +207 -0
- multioptpy/Optimizer/trust_radius_neb.py +121 -0
- multioptpy/Optimizer/yogi.py +60 -0
- multioptpy/OtherMethod/__init__.py +0 -0
- multioptpy/OtherMethod/addf.py +1150 -0
- multioptpy/OtherMethod/dimer.py +895 -0
- multioptpy/OtherMethod/elastic_image_pair.py +629 -0
- multioptpy/OtherMethod/modelfunction.py +456 -0
- multioptpy/OtherMethod/newton_traj.py +454 -0
- multioptpy/OtherMethod/twopshs.py +1095 -0
- multioptpy/PESAnalyzer/__init__.py +0 -0
- multioptpy/PESAnalyzer/calc_irc_curvature.py +125 -0
- multioptpy/PESAnalyzer/cmds_analysis.py +152 -0
- multioptpy/PESAnalyzer/koopman_analysis.py +268 -0
- multioptpy/PESAnalyzer/pca_analysis.py +314 -0
- multioptpy/Parameters/__init__.py +0 -0
- multioptpy/Parameters/atomic_mass.py +20 -0
- multioptpy/Parameters/atomic_number.py +22 -0
- multioptpy/Parameters/covalent_radii.py +44 -0
- multioptpy/Parameters/d2.py +61 -0
- multioptpy/Parameters/d3.py +63 -0
- multioptpy/Parameters/d4.py +103 -0
- multioptpy/Parameters/dreiding.py +34 -0
- multioptpy/Parameters/gfn0xtb_param.py +137 -0
- multioptpy/Parameters/gfnff_param.py +315 -0
- multioptpy/Parameters/gnb.py +104 -0
- multioptpy/Parameters/parameter.py +22 -0
- multioptpy/Parameters/uff.py +72 -0
- multioptpy/Parameters/unit_values.py +20 -0
- multioptpy/Potential/AFIR_potential.py +55 -0
- multioptpy/Potential/LJ_repulsive_potential.py +345 -0
- multioptpy/Potential/__init__.py +0 -0
- multioptpy/Potential/anharmonic_keep_potential.py +28 -0
- multioptpy/Potential/asym_elllipsoidal_potential.py +718 -0
- multioptpy/Potential/electrostatic_potential.py +69 -0
- multioptpy/Potential/flux_potential.py +30 -0
- multioptpy/Potential/gaussian_potential.py +101 -0
- multioptpy/Potential/idpp.py +516 -0
- multioptpy/Potential/keep_angle_potential.py +146 -0
- multioptpy/Potential/keep_dihedral_angle_potential.py +105 -0
- multioptpy/Potential/keep_outofplain_angle_potential.py +70 -0
- multioptpy/Potential/keep_potential.py +99 -0
- multioptpy/Potential/mechano_force_potential.py +74 -0
- multioptpy/Potential/nanoreactor_potential.py +52 -0
- multioptpy/Potential/potential.py +896 -0
- multioptpy/Potential/spacer_model_potential.py +221 -0
- multioptpy/Potential/switching_potential.py +258 -0
- multioptpy/Potential/universal_potential.py +34 -0
- multioptpy/Potential/value_range_potential.py +36 -0
- multioptpy/Potential/void_point_potential.py +25 -0
- multioptpy/SQM/__init__.py +0 -0
- multioptpy/SQM/sqm1/__init__.py +0 -0
- multioptpy/SQM/sqm1/sqm1_core.py +1792 -0
- multioptpy/SQM/sqm2/__init__.py +0 -0
- multioptpy/SQM/sqm2/calc_tools.py +95 -0
- multioptpy/SQM/sqm2/sqm2_basis.py +850 -0
- multioptpy/SQM/sqm2/sqm2_bond.py +119 -0
- multioptpy/SQM/sqm2/sqm2_core.py +303 -0
- multioptpy/SQM/sqm2/sqm2_data.py +1229 -0
- multioptpy/SQM/sqm2/sqm2_disp.py +65 -0
- multioptpy/SQM/sqm2/sqm2_eeq.py +243 -0
- multioptpy/SQM/sqm2/sqm2_overlapint.py +704 -0
- multioptpy/SQM/sqm2/sqm2_qm.py +578 -0
- multioptpy/SQM/sqm2/sqm2_rep.py +66 -0
- multioptpy/SQM/sqm2/sqm2_srb.py +70 -0
- multioptpy/Thermo/__init__.py +0 -0
- multioptpy/Thermo/normal_mode_analyzer.py +865 -0
- multioptpy/Utils/__init__.py +0 -0
- multioptpy/Utils/bond_connectivity.py +264 -0
- multioptpy/Utils/calc_tools.py +884 -0
- multioptpy/Utils/oniom.py +96 -0
- multioptpy/Utils/pbc.py +48 -0
- multioptpy/Utils/riemann_curvature.py +208 -0
- multioptpy/Utils/symmetry_analyzer.py +482 -0
- multioptpy/Visualization/__init__.py +0 -0
- multioptpy/Visualization/visualization.py +156 -0
- multioptpy/WFAnalyzer/MO_analysis.py +104 -0
- multioptpy/WFAnalyzer/__init__.py +0 -0
- multioptpy/Wrapper/__init__.py +0 -0
- multioptpy/Wrapper/autots.py +1239 -0
- multioptpy/Wrapper/ieip_wrapper.py +93 -0
- multioptpy/Wrapper/md_wrapper.py +92 -0
- multioptpy/Wrapper/neb_wrapper.py +94 -0
- multioptpy/Wrapper/optimize_wrapper.py +76 -0
- multioptpy/__init__.py +5 -0
- multioptpy/entrypoints.py +916 -0
- multioptpy/fileio.py +660 -0
- multioptpy/ieip.py +340 -0
- multioptpy/interface.py +1086 -0
- multioptpy/irc.py +529 -0
- multioptpy/moleculardynamics.py +432 -0
- multioptpy/neb.py +1267 -0
- multioptpy/optimization.py +1553 -0
- multioptpy/optimizer.py +709 -0
- multioptpy-1.20.2.dist-info/METADATA +438 -0
- multioptpy-1.20.2.dist-info/RECORD +246 -0
- multioptpy-1.20.2.dist-info/WHEEL +5 -0
- multioptpy-1.20.2.dist-info/entry_points.txt +9 -0
- multioptpy-1.20.2.dist-info/licenses/LICENSE +674 -0
- multioptpy-1.20.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,785 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from scipy.optimize import least_squares
|
|
3
|
+
from multioptpy.Parameters.parameter import covalent_radii_lib
|
|
4
|
+
|
|
5
|
+
#ref.: https://github.com/virtualzx-nad/geodesic-interpolate
|
|
6
|
+
|
|
7
|
+
def distribute_geometry_geodesic(geometry_list, n_points=None, spacing=None, spline_degree=3,
|
|
8
|
+
max_iterations=50, tolerance=1e-4, element_list=None, verbose=True):
|
|
9
|
+
"""
|
|
10
|
+
Performs geodesic interpolation between molecular geometries using exponential and logarithmic maps.
|
|
11
|
+
|
|
12
|
+
This function distributes geometries along a geodesic path in the internal coordinate space,
|
|
13
|
+
resulting in more realistic interpolations compared to linear methods in Cartesian space.
|
|
14
|
+
|
|
15
|
+
Parameters:
|
|
16
|
+
-----------
|
|
17
|
+
geometry_list : list
|
|
18
|
+
List of geometry arrays/objects with shape (n_atoms, 3)
|
|
19
|
+
n_points : int, optional
|
|
20
|
+
Number of points to generate along the path (including endpoints)
|
|
21
|
+
spacing : float, optional
|
|
22
|
+
If specified, points will be distributed with this spacing (in Angstroms)
|
|
23
|
+
If both n_points and spacing are specified, n_points takes precedence
|
|
24
|
+
spline_degree : int, optional
|
|
25
|
+
Degree of the spline for initial path estimation (default=3)
|
|
26
|
+
max_iterations : int, optional
|
|
27
|
+
Maximum number of geodesic optimization iterations
|
|
28
|
+
tolerance : float, optional
|
|
29
|
+
Convergence tolerance for geodesic optimization
|
|
30
|
+
element_list : list, optional
|
|
31
|
+
List of element symbols for each atom, used for better distance weighting
|
|
32
|
+
verbose : bool, optional
|
|
33
|
+
If True, print optimization progress (default=True)
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
--------
|
|
37
|
+
list
|
|
38
|
+
List of geometries distributed along the geodesic path
|
|
39
|
+
"""
|
|
40
|
+
if verbose:
|
|
41
|
+
print("="*60)
|
|
42
|
+
print("Starting Geodesic Interpolation")
|
|
43
|
+
print("="*60)
|
|
44
|
+
|
|
45
|
+
# Handle input validation
|
|
46
|
+
if len(geometry_list) < 2:
|
|
47
|
+
if verbose:
|
|
48
|
+
print("Warning: Less than 2 geometries provided. Returning original list.")
|
|
49
|
+
return geometry_list.copy()
|
|
50
|
+
|
|
51
|
+
# Convert all geometries to numpy arrays
|
|
52
|
+
geoms = [np.array(geom, dtype=np.float64) for geom in geometry_list]
|
|
53
|
+
n_atoms = geoms[0].shape[0]
|
|
54
|
+
|
|
55
|
+
if verbose:
|
|
56
|
+
print(f"Input: {len(geometry_list)} geometries with {n_atoms} atoms each")
|
|
57
|
+
|
|
58
|
+
# Use default atoms if none provided
|
|
59
|
+
atoms = element_list if element_list is not None else ['C'] * n_atoms
|
|
60
|
+
|
|
61
|
+
# Align geometries to minimize RMSD
|
|
62
|
+
if verbose:
|
|
63
|
+
print("Aligning geometries to minimize RMSD...")
|
|
64
|
+
max_rmsd, aligned_geoms = align_path(geoms, verbose=verbose)
|
|
65
|
+
|
|
66
|
+
# Determine the number of points to generate
|
|
67
|
+
if n_points is None and spacing is None:
|
|
68
|
+
n_points = len(geometry_list)
|
|
69
|
+
elif n_points is None:
|
|
70
|
+
# Estimate path length for spacing-based distribution
|
|
71
|
+
path_length = estimate_path_length(aligned_geoms)
|
|
72
|
+
n_points = max(3, int(np.ceil(path_length / spacing)) + 1)
|
|
73
|
+
if verbose:
|
|
74
|
+
print(f"Estimated path length: {path_length:.3f} Å")
|
|
75
|
+
print(f"Using spacing {spacing:.3f} Å -> {n_points} points")
|
|
76
|
+
|
|
77
|
+
if verbose:
|
|
78
|
+
print(f"Target number of geometries: {n_points}")
|
|
79
|
+
|
|
80
|
+
# Step 1: Generate initial path with correct number of points using redistribution
|
|
81
|
+
if verbose:
|
|
82
|
+
print("\nStep 1: Redistributing geometries...")
|
|
83
|
+
redistributed_path = redistribute(atoms, aligned_geoms, n_points, tol=tolerance, verbose=verbose)
|
|
84
|
+
|
|
85
|
+
# Step 2: Apply geodesic smoothing to optimize the path
|
|
86
|
+
if verbose:
|
|
87
|
+
print(f"\nStep 2: Geodesic optimization (max_iter={max_iterations}, tol={tolerance})")
|
|
88
|
+
geodesic = Geodesic(atoms, redistributed_path, scaler=1.7,
|
|
89
|
+
threshold=3, min_neighbors=4,
|
|
90
|
+
verbose=verbose, friction=1e-3)
|
|
91
|
+
|
|
92
|
+
# Perform geodesic optimization
|
|
93
|
+
optimized_path = geodesic.smooth(tol=tolerance, max_iter=max_iterations, verbose=verbose)
|
|
94
|
+
|
|
95
|
+
if verbose:
|
|
96
|
+
print("="*60)
|
|
97
|
+
print("Geodesic Interpolation Completed Successfully!")
|
|
98
|
+
print("="*60)
|
|
99
|
+
|
|
100
|
+
return [geom.copy() for geom in optimized_path]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def align_path(path, verbose=False):
|
|
104
|
+
"""
|
|
105
|
+
Rotate and translate images to minimize RMSD movements along the path.
|
|
106
|
+
Also moves the geometric center of all images to the origin.
|
|
107
|
+
Optimized with vectorized operations where possible.
|
|
108
|
+
"""
|
|
109
|
+
path = np.array(path, dtype=np.float64)
|
|
110
|
+
# Center the first geometry
|
|
111
|
+
path[0] -= np.mean(path[0], axis=0)
|
|
112
|
+
|
|
113
|
+
max_rmsd = 0
|
|
114
|
+
# Sequential alignment (cannot be fully vectorized due to dependency)
|
|
115
|
+
for i in range(len(path) - 1):
|
|
116
|
+
rmsd, path[i + 1] = align_geom(path[i], path[i + 1])
|
|
117
|
+
max_rmsd = max(max_rmsd, rmsd)
|
|
118
|
+
if verbose:
|
|
119
|
+
print(f" Aligned geometry {i+1}: RMSD = {rmsd:.4f} Å")
|
|
120
|
+
|
|
121
|
+
if verbose:
|
|
122
|
+
print(f"Alignment completed. Maximum RMSD: {max_rmsd:.4f} Å")
|
|
123
|
+
|
|
124
|
+
return max_rmsd, path
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def align_geom(refgeom, geom):
|
|
128
|
+
"""
|
|
129
|
+
Find translation/rotation that moves a given geometry to maximally overlap
|
|
130
|
+
with a reference geometry using the Kabsch algorithm.
|
|
131
|
+
"""
|
|
132
|
+
center = np.mean(refgeom, axis=0) # Find the geometric center
|
|
133
|
+
ref2 = refgeom - center
|
|
134
|
+
geom2 = geom - np.mean(geom, axis=0)
|
|
135
|
+
cov = np.dot(geom2.T, ref2)
|
|
136
|
+
v, sv, w = np.linalg.svd(cov)
|
|
137
|
+
if np.linalg.det(v) * np.linalg.det(w) < 0:
|
|
138
|
+
sv[-1] = -sv[-1]
|
|
139
|
+
v[:, -1] = -v[:, -1]
|
|
140
|
+
u = np.dot(v, w)
|
|
141
|
+
new_geom = np.dot(geom2, u) + center
|
|
142
|
+
rmsd = np.sqrt(np.mean((new_geom - refgeom) ** 2))
|
|
143
|
+
return rmsd, new_geom
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def estimate_path_length(geometries):
|
|
147
|
+
"""
|
|
148
|
+
Estimates the total path length in Cartesian space.
|
|
149
|
+
"""
|
|
150
|
+
geometries = np.array(geometries)
|
|
151
|
+
# Vectorized calculation of consecutive differences
|
|
152
|
+
diffs = geometries[1:] - geometries[:-1]
|
|
153
|
+
# Calculate RMSD for each consecutive pair
|
|
154
|
+
rmsds = np.sqrt(np.mean(np.sum(diffs**2, axis=2), axis=1))
|
|
155
|
+
return np.sum(rmsds)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def get_bond_list(geom, atoms=None, threshold=4, min_neighbors=4, snapshots=30, bond_threshold=1.8,
|
|
160
|
+
enforce=()):
|
|
161
|
+
"""
|
|
162
|
+
Get the list of all the important atom pairs.
|
|
163
|
+
Samples a number of snapshots from a list of geometries to generate all
|
|
164
|
+
distances that are below a given threshold in any of them.
|
|
165
|
+
"""
|
|
166
|
+
from scipy.spatial import KDTree
|
|
167
|
+
|
|
168
|
+
# Type casting and value checks on input parameters
|
|
169
|
+
geom = np.asarray(geom)
|
|
170
|
+
if len(geom.shape) < 3:
|
|
171
|
+
# If there is only one geometry or it is flattened, promote to 3d
|
|
172
|
+
geom = geom.reshape(1, -1, 3)
|
|
173
|
+
min_neighbors = min(min_neighbors, geom.shape[1] - 1)
|
|
174
|
+
|
|
175
|
+
# Determine which images to be used to determine distances
|
|
176
|
+
snapshots = min(len(geom), snapshots)
|
|
177
|
+
images = [0, len(geom) - 1]
|
|
178
|
+
if snapshots > 2:
|
|
179
|
+
images.extend(np.random.choice(range(1, snapshots - 1), snapshots - 2, replace=False))
|
|
180
|
+
|
|
181
|
+
# Get neighbor list for included geometry and merge them (fully vectorized)
|
|
182
|
+
rijset = set(enforce)
|
|
183
|
+
|
|
184
|
+
for image in images:
|
|
185
|
+
tree = KDTree(geom[image])
|
|
186
|
+
pairs = tree.query_pairs(threshold)
|
|
187
|
+
rijset.update(pairs)
|
|
188
|
+
bonded = tree.query_pairs(bond_threshold)
|
|
189
|
+
|
|
190
|
+
if bonded:
|
|
191
|
+
bonded_array = np.array(list(bonded))
|
|
192
|
+
n_atoms = geom.shape[1]
|
|
193
|
+
|
|
194
|
+
# Choose optimal algorithm based on system size and bonding density
|
|
195
|
+
n_bonds = len(bonded_array)
|
|
196
|
+
density = n_bonds / (n_atoms * (n_atoms - 1) / 2)
|
|
197
|
+
|
|
198
|
+
if n_atoms > 500 or density < 0.1:
|
|
199
|
+
# Sparse approach for large or sparse systems
|
|
200
|
+
from scipy.sparse import csr_matrix
|
|
201
|
+
|
|
202
|
+
# Build sparse adjacency matrix with self-connections
|
|
203
|
+
all_i = np.concatenate([bonded_array[:, 0], bonded_array[:, 1], np.arange(n_atoms)])
|
|
204
|
+
all_j = np.concatenate([bonded_array[:, 1], bonded_array[:, 0], np.arange(n_atoms)])
|
|
205
|
+
adj_sparse = csr_matrix((np.ones(len(all_i)), (all_i, all_j)),
|
|
206
|
+
shape=(n_atoms, n_atoms), dtype=bool)
|
|
207
|
+
|
|
208
|
+
# Compute extended connections via sparse matrix multiplication
|
|
209
|
+
extended_sparse = np.dot(adj_sparse, adj_sparse)
|
|
210
|
+
extended_coo = extended_sparse.tocoo()
|
|
211
|
+
|
|
212
|
+
# Filter to upper triangular (i < j) and add to rijset
|
|
213
|
+
mask = extended_coo.row < extended_coo.col
|
|
214
|
+
new_pairs = set(zip(extended_coo.row[mask], extended_coo.col[mask]))
|
|
215
|
+
rijset.update(new_pairs)
|
|
216
|
+
else:
|
|
217
|
+
# Dense approach for smaller dense systems
|
|
218
|
+
adj_matrix = np.eye(n_atoms, dtype=bool) # Start with identity (self-connections)
|
|
219
|
+
adj_matrix[bonded_array[:, 0], bonded_array[:, 1]] = True
|
|
220
|
+
adj_matrix[bonded_array[:, 1], bonded_array[:, 0]] = True
|
|
221
|
+
|
|
222
|
+
# Extended connections via matrix multiplication
|
|
223
|
+
extended = np.dot(adj_matrix, adj_matrix)
|
|
224
|
+
|
|
225
|
+
# Extract upper triangular indices efficiently
|
|
226
|
+
triu_indices = np.triu_indices(n_atoms, k=1)
|
|
227
|
+
valid_mask = extended[triu_indices]
|
|
228
|
+
i_valid = triu_indices[0][valid_mask]
|
|
229
|
+
j_valid = triu_indices[1][valid_mask]
|
|
230
|
+
|
|
231
|
+
# Add to rijset
|
|
232
|
+
new_pairs = set(zip(i_valid, j_valid))
|
|
233
|
+
rijset.update(new_pairs)
|
|
234
|
+
rijlist = sorted(rijset)
|
|
235
|
+
|
|
236
|
+
# Check neighbor count to make sure `min_neighbors` is satisfied using vectorized operations
|
|
237
|
+
rijlist = sorted(rijset)
|
|
238
|
+
if not rijlist:
|
|
239
|
+
re = np.array([])
|
|
240
|
+
return rijlist, re
|
|
241
|
+
|
|
242
|
+
# Vectorized neighbor counting
|
|
243
|
+
rij_array = np.array(rijlist)
|
|
244
|
+
count = np.zeros(geom.shape[1], dtype=int)
|
|
245
|
+
unique_i, counts_i = np.unique(rij_array[:, 0], return_counts=True)
|
|
246
|
+
unique_j, counts_j = np.unique(rij_array[:, 1], return_counts=True)
|
|
247
|
+
|
|
248
|
+
count[unique_i] += counts_i
|
|
249
|
+
count[unique_j] += counts_j
|
|
250
|
+
|
|
251
|
+
# Find atoms with insufficient neighbors
|
|
252
|
+
insufficient_atoms = np.where(count < min_neighbors)[0]
|
|
253
|
+
|
|
254
|
+
for idx in insufficient_atoms:
|
|
255
|
+
tree = KDTree(geom[-1])
|
|
256
|
+
_, neighbors = tree.query(geom[-1, idx], k=min_neighbors + 1)
|
|
257
|
+
for i in neighbors:
|
|
258
|
+
if i == idx:
|
|
259
|
+
continue
|
|
260
|
+
pair = tuple(sorted([i, idx]))
|
|
261
|
+
if pair in rijset:
|
|
262
|
+
continue
|
|
263
|
+
else:
|
|
264
|
+
rijset.add(pair)
|
|
265
|
+
rijlist.append(pair)
|
|
266
|
+
count[i] += 1
|
|
267
|
+
count[idx] += 1
|
|
268
|
+
|
|
269
|
+
if atoms is None:
|
|
270
|
+
re = np.full(len(rijlist), 2.0)
|
|
271
|
+
else:
|
|
272
|
+
radius = np.array([covalent_radii_lib(atom.capitalize()) for atom in atoms])
|
|
273
|
+
re = np.array([radius[i] + radius[j] for i, j in rijlist])
|
|
274
|
+
|
|
275
|
+
return rijlist, re
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def morse_scaler(re=1.5, alpha=1.7, beta=0.01):
|
|
279
|
+
"""
|
|
280
|
+
Returns a scaling function that determines the metric of the internal
|
|
281
|
+
coordinates using morse potential. Optimized for vectorized operations.
|
|
282
|
+
"""
|
|
283
|
+
# Ensure re is a numpy array for vectorized operations
|
|
284
|
+
re = np.asarray(re)
|
|
285
|
+
|
|
286
|
+
def scaler(x):
|
|
287
|
+
x = np.asarray(x)
|
|
288
|
+
ratio = x / re
|
|
289
|
+
|
|
290
|
+
# Vectorized exponential and ratio calculations
|
|
291
|
+
val1 = np.exp(alpha * (1 - ratio))
|
|
292
|
+
val2 = beta / ratio
|
|
293
|
+
|
|
294
|
+
# Vectorized derivative calculation
|
|
295
|
+
dval = -alpha / re * val1 - val2 / x
|
|
296
|
+
|
|
297
|
+
return val1 + val2, dval
|
|
298
|
+
return scaler
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def compute_wij(geom, rij_list, func):
|
|
302
|
+
"""
|
|
303
|
+
Calculate a list of scaled distances and their derivatives using vectorized operations
|
|
304
|
+
"""
|
|
305
|
+
geom = np.asarray(geom).reshape(-1, 3)
|
|
306
|
+
nrij = len(rij_list)
|
|
307
|
+
|
|
308
|
+
if nrij == 0:
|
|
309
|
+
return np.array([]), np.zeros((0, geom.size))
|
|
310
|
+
|
|
311
|
+
rij, bmat = compute_rij(geom, rij_list)
|
|
312
|
+
wij, dwdr = func(rij)
|
|
313
|
+
|
|
314
|
+
# Vectorized multiplication of gradients
|
|
315
|
+
bmat_reshaped = bmat.reshape(nrij, -1)
|
|
316
|
+
bmat_scaled = bmat_reshaped * dwdr[:, np.newaxis]
|
|
317
|
+
|
|
318
|
+
return wij, bmat_scaled
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def compute_rij(geom, rij_list):
|
|
322
|
+
"""
|
|
323
|
+
Calculate a list of distances and their derivatives using vectorized operations
|
|
324
|
+
"""
|
|
325
|
+
geom = np.asarray(geom).reshape(-1, 3)
|
|
326
|
+
nrij = len(rij_list)
|
|
327
|
+
|
|
328
|
+
if nrij == 0:
|
|
329
|
+
return np.array([]), np.zeros((0, len(geom), 3))
|
|
330
|
+
|
|
331
|
+
# Convert rij_list to numpy arrays for vectorized operations
|
|
332
|
+
rij_array = np.array(rij_list)
|
|
333
|
+
i_indices = rij_array[:, 0]
|
|
334
|
+
j_indices = rij_array[:, 1]
|
|
335
|
+
|
|
336
|
+
# Vectorized calculation of displacement vectors
|
|
337
|
+
dvec = geom[i_indices] - geom[j_indices]
|
|
338
|
+
|
|
339
|
+
# Vectorized distance calculation
|
|
340
|
+
rij = np.linalg.norm(dvec, axis=1)
|
|
341
|
+
|
|
342
|
+
# Avoid division by zero
|
|
343
|
+
safe_rij = np.where(rij > 1e-12, rij, 1e-12)
|
|
344
|
+
grad = dvec / safe_rij[:, np.newaxis]
|
|
345
|
+
|
|
346
|
+
# Initialize B matrix
|
|
347
|
+
bmat = np.zeros((nrij, len(geom), 3))
|
|
348
|
+
|
|
349
|
+
# Vectorized gradient assignment
|
|
350
|
+
bmat[np.arange(nrij), i_indices] = grad
|
|
351
|
+
bmat[np.arange(nrij), j_indices] = -grad
|
|
352
|
+
|
|
353
|
+
return rij, bmat
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def mid_point(atoms, geom1, geom2, tol=1e-2, nudge=0.01, threshold=4, verbose=False):
|
|
357
|
+
"""
|
|
358
|
+
Find the Cartesian geometry that has internal coordinate values closest to the average of
|
|
359
|
+
two geometries.
|
|
360
|
+
"""
|
|
361
|
+
# Process the initial geometries, construct coordinate system and obtain average internals
|
|
362
|
+
geom1, geom2 = np.array(geom1), np.array(geom2)
|
|
363
|
+
add_pair = set()
|
|
364
|
+
geom_list = [geom1, geom2]
|
|
365
|
+
|
|
366
|
+
iteration = 0
|
|
367
|
+
while True:
|
|
368
|
+
iteration += 1
|
|
369
|
+
rijlist, re = get_bond_list(geom_list, threshold=threshold + 1, enforce=add_pair)
|
|
370
|
+
scaler = morse_scaler(alpha=0.7, re=re)
|
|
371
|
+
w1, _ = compute_wij(geom1, rijlist, scaler)
|
|
372
|
+
w2, _ = compute_wij(geom2, rijlist, scaler)
|
|
373
|
+
w = (w1 + w2) / 2
|
|
374
|
+
d_min, x_min = np.inf, None
|
|
375
|
+
friction = 0.1 / np.sqrt(geom1.shape[0])
|
|
376
|
+
|
|
377
|
+
if verbose:
|
|
378
|
+
print(f" Mid-point iteration {iteration}: {len(rijlist)} coordinate pairs")
|
|
379
|
+
|
|
380
|
+
# The inner loop performs minimization using either end-point as the starting guess
|
|
381
|
+
for coef_idx, coef in enumerate([0.02, 0.98]):
|
|
382
|
+
x0 = (geom1 * coef + (1 - coef) * geom2).ravel()
|
|
383
|
+
x0 += nudge * np.random.random_sample(x0.shape)
|
|
384
|
+
if verbose:
|
|
385
|
+
print(f" Starting optimization from coef={coef:.2f}")
|
|
386
|
+
|
|
387
|
+
result = least_squares(
|
|
388
|
+
lambda x: np.concatenate([compute_wij(x, rijlist, scaler)[0] - w, (x-x0)*friction]),
|
|
389
|
+
x0,
|
|
390
|
+
lambda x: np.vstack([compute_wij(x, rijlist, scaler)[1], np.identity(x.size) * friction]),
|
|
391
|
+
ftol=tol,
|
|
392
|
+
gtol=tol
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
x_mid = result['x'].reshape(-1, 3)
|
|
396
|
+
|
|
397
|
+
# Take the interpolated geometry, construct new pair list and check for new contacts
|
|
398
|
+
new_list = geom_list + [x_mid]
|
|
399
|
+
new_rij, _ = get_bond_list(new_list, threshold=threshold, min_neighbors=0)
|
|
400
|
+
extras = set(new_rij) - set(rijlist)
|
|
401
|
+
|
|
402
|
+
if extras:
|
|
403
|
+
if verbose:
|
|
404
|
+
print(f" New contacts detected. Adding {len(extras)} pairs.")
|
|
405
|
+
# Update pair list then go back to the minimization loop if new contacts are found
|
|
406
|
+
geom_list = new_list
|
|
407
|
+
add_pair |= extras
|
|
408
|
+
break
|
|
409
|
+
|
|
410
|
+
# Perform local geodesic optimization for the new image
|
|
411
|
+
smoother = Geodesic(atoms, [geom1, x_mid, geom2], 0.7, threshold=threshold, verbose=False, friction=1)
|
|
412
|
+
smoother.compute_disps()
|
|
413
|
+
|
|
414
|
+
# Vectorized width calculation
|
|
415
|
+
geom_array = np.array([geom1, geom2])
|
|
416
|
+
diffs = geom_array - smoother.path[1]
|
|
417
|
+
widths = np.sqrt(np.mean(np.sum(diffs**2, axis=2), axis=1))
|
|
418
|
+
width = np.max(widths)
|
|
419
|
+
|
|
420
|
+
dist, x_mid = width + smoother.length, smoother.path[1]
|
|
421
|
+
if verbose:
|
|
422
|
+
print(f" Path length: {dist:.6f} after {result['nfev']} evaluations")
|
|
423
|
+
|
|
424
|
+
if dist < d_min:
|
|
425
|
+
d_min, x_min = dist, x_mid
|
|
426
|
+
|
|
427
|
+
else: # Both starting guesses finished without new atom pairs. Minimization successful
|
|
428
|
+
if verbose:
|
|
429
|
+
print(f" Mid-point converged with path length: {d_min:.6f}")
|
|
430
|
+
break
|
|
431
|
+
|
|
432
|
+
return x_min
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def redistribute(atoms, geoms, nimages, tol=1e-2, verbose=False):
|
|
436
|
+
"""
|
|
437
|
+
Add or remove images so that the path length matches the desired number.
|
|
438
|
+
"""
|
|
439
|
+
_, geoms = align_path(geoms)
|
|
440
|
+
geoms = list(geoms)
|
|
441
|
+
|
|
442
|
+
if verbose:
|
|
443
|
+
print(f" Initial path has {len(geoms)} geometries, target: {nimages}")
|
|
444
|
+
|
|
445
|
+
# If there are too few images, add bisection points
|
|
446
|
+
while len(geoms) < nimages:
|
|
447
|
+
# Vectorized distance calculation
|
|
448
|
+
geoms_array = np.array(geoms)
|
|
449
|
+
diffs = geoms_array[1:] - geoms_array[:-1]
|
|
450
|
+
dists = np.sqrt(np.mean(np.sum(diffs**2, axis=2), axis=1))
|
|
451
|
+
max_i = np.argmax(dists)
|
|
452
|
+
|
|
453
|
+
if verbose:
|
|
454
|
+
print(f" Inserting geometry between {max_i} and {max_i + 1} (RMSD={dists[max_i]:.3f})")
|
|
455
|
+
|
|
456
|
+
insertion = mid_point(atoms, geoms[max_i], geoms[max_i + 1], tol, verbose=verbose)
|
|
457
|
+
_, insertion = align_geom(geoms[max_i], insertion)
|
|
458
|
+
geoms.insert(max_i + 1, insertion)
|
|
459
|
+
geoms = list(align_path(geoms)[1])
|
|
460
|
+
|
|
461
|
+
if verbose:
|
|
462
|
+
print(f" New path length: {len(geoms)}")
|
|
463
|
+
|
|
464
|
+
# If there are too many images, remove points
|
|
465
|
+
while len(geoms) > nimages:
|
|
466
|
+
# Vectorized distance calculation for removal
|
|
467
|
+
geoms_array = np.array(geoms)
|
|
468
|
+
diffs = geoms_array[2:] - geoms_array[:-2]
|
|
469
|
+
dists = np.sqrt(np.mean(np.sum(diffs**2, axis=2), axis=1))
|
|
470
|
+
min_i = np.argmin(dists)
|
|
471
|
+
|
|
472
|
+
if verbose:
|
|
473
|
+
print(f" Removing geometry {min_i + 1} (merged RMSD={dists[min_i]:.3f})")
|
|
474
|
+
|
|
475
|
+
del geoms[min_i + 1]
|
|
476
|
+
geoms = list(align_path(geoms)[1])
|
|
477
|
+
|
|
478
|
+
if verbose:
|
|
479
|
+
print(f" New path length: {len(geoms)}")
|
|
480
|
+
|
|
481
|
+
return geoms
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
class Geodesic(object):
|
|
485
|
+
"""
|
|
486
|
+
Optimizer to obtain geodesic in redundant internal coordinates.
|
|
487
|
+
Core part is the calculation of the path length in the internal metric.
|
|
488
|
+
"""
|
|
489
|
+
def __init__(self, atoms, path, scaler=1.7, threshold=3, min_neighbors=4, verbose=True,
|
|
490
|
+
friction=1e-3):
|
|
491
|
+
"""Initialize the interpolater"""
|
|
492
|
+
rmsd0, self.path = align_path(path)
|
|
493
|
+
if verbose:
|
|
494
|
+
print(f" Maximum RMSD change in initial path: {rmsd0:.4f} Å")
|
|
495
|
+
|
|
496
|
+
if self.path.ndim != 3:
|
|
497
|
+
raise ValueError('The path to be interpolated must have 3 dimensions')
|
|
498
|
+
self.nimages, self.natoms, _ = self.path.shape
|
|
499
|
+
|
|
500
|
+
# Construct coordinates
|
|
501
|
+
self.rij_list, self.re = get_bond_list(path, atoms, threshold=threshold, min_neighbors=min_neighbors)
|
|
502
|
+
if isinstance(scaler, float):
|
|
503
|
+
self.scaler = morse_scaler(re=self.re, alpha=1.7)
|
|
504
|
+
else:
|
|
505
|
+
self.scaler = scaler
|
|
506
|
+
self.nrij = len(self.rij_list)
|
|
507
|
+
self.friction = friction
|
|
508
|
+
self.verbose = verbose
|
|
509
|
+
|
|
510
|
+
# Initialize internal storages
|
|
511
|
+
if verbose:
|
|
512
|
+
print(f" Geodesic setup: {self.nimages} images, {self.natoms} atoms, {self.nrij} coordinate pairs")
|
|
513
|
+
|
|
514
|
+
self.neval = 0
|
|
515
|
+
self.w = [None] * len(path)
|
|
516
|
+
self.dwdR = [None] * len(path)
|
|
517
|
+
self.X_mid = [None] * (len(path) - 1)
|
|
518
|
+
self.w_mid = [None] * (len(path) - 1)
|
|
519
|
+
self.dwdR_mid = [None] * (len(path) - 1)
|
|
520
|
+
self.disps = self.grad = self.segment = None
|
|
521
|
+
self.conv_path = []
|
|
522
|
+
|
|
523
|
+
# Track optimization progress
|
|
524
|
+
self.optimization_history = {
|
|
525
|
+
'iterations': [],
|
|
526
|
+
'path_lengths': [],
|
|
527
|
+
'optimalities': [],
|
|
528
|
+
'rmsds': []
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
def get_optimization_summary(self):
|
|
532
|
+
"""
|
|
533
|
+
Return a summary of the optimization process
|
|
534
|
+
"""
|
|
535
|
+
if not self.optimization_history['iterations']:
|
|
536
|
+
return "No optimization performed yet."
|
|
537
|
+
|
|
538
|
+
summary = f"""
|
|
539
|
+
Geodesic Optimization Summary:
|
|
540
|
+
==============================
|
|
541
|
+
Total iterations: {len(self.optimization_history['iterations'])}
|
|
542
|
+
Initial path length: {self.optimization_history['path_lengths'][0]:.6f}
|
|
543
|
+
Final path length: {self.optimization_history['path_lengths'][-1]:.6f}
|
|
544
|
+
Path length reduction: {self.optimization_history['path_lengths'][0] - self.optimization_history['path_lengths'][-1]:.6f}
|
|
545
|
+
Final optimality: {self.optimization_history['optimalities'][-1]:.3e}
|
|
546
|
+
Final RMSD: {self.optimization_history['rmsds'][-1]:.4f} Å
|
|
547
|
+
"""
|
|
548
|
+
return summary
|
|
549
|
+
|
|
550
|
+
def print_optimization_progress(self):
|
|
551
|
+
"""
|
|
552
|
+
Print detailed optimization progress
|
|
553
|
+
"""
|
|
554
|
+
if not self.optimization_history['iterations']:
|
|
555
|
+
print("No optimization data available.")
|
|
556
|
+
return
|
|
557
|
+
|
|
558
|
+
print("\nDetailed Optimization Progress:")
|
|
559
|
+
print("-" * 50)
|
|
560
|
+
print("Iter | Path Length | Optimality | RMSD (Å)")
|
|
561
|
+
print("-" * 50)
|
|
562
|
+
|
|
563
|
+
for i, (iteration, length, opt, rmsd) in enumerate(zip(
|
|
564
|
+
self.optimization_history['iterations'],
|
|
565
|
+
self.optimization_history['path_lengths'],
|
|
566
|
+
self.optimization_history['optimalities'],
|
|
567
|
+
self.optimization_history['rmsds']
|
|
568
|
+
)):
|
|
569
|
+
if i % 5 == 0 or i == len(self.optimization_history['iterations']) - 1:
|
|
570
|
+
print(f"{iteration:4d} | {length:13.6f} | {opt:9.3e} | {rmsd:8.4f}")
|
|
571
|
+
print("-" * 50)
|
|
572
|
+
|
|
573
|
+
def update_intc(self):
|
|
574
|
+
"""
|
|
575
|
+
Adjust unknown locations of mid points and compute missing values of
|
|
576
|
+
internal coordinates and their derivatives.
|
|
577
|
+
"""
|
|
578
|
+
for i, (X, w, dwdR) in enumerate(zip(self.path, self.w, self.dwdR)):
|
|
579
|
+
if w is None:
|
|
580
|
+
self.w[i], self.dwdR[i] = compute_wij(X, self.rij_list, self.scaler)
|
|
581
|
+
for i, (X0, X1, w) in enumerate(zip(self.path, self.path[1:], self.w_mid)):
|
|
582
|
+
if w is None:
|
|
583
|
+
self.X_mid[i] = Xm = (X0 + X1) / 2
|
|
584
|
+
self.w_mid[i], self.dwdR_mid[i] = compute_wij(Xm, self.rij_list, self.scaler)
|
|
585
|
+
|
|
586
|
+
def update_geometry(self, X, start, end):
|
|
587
|
+
"""
|
|
588
|
+
Update the geometry of a segment of the path, then set the corresponding internal
|
|
589
|
+
coordinate, derivatives and midpoint locations to unknown
|
|
590
|
+
"""
|
|
591
|
+
X = X.reshape(self.path[start:end].shape)
|
|
592
|
+
if np.array_equal(X, self.path[start:end]):
|
|
593
|
+
return False
|
|
594
|
+
self.path[start:end] = X
|
|
595
|
+
for i in range(start, end):
|
|
596
|
+
self.w_mid[i] = self.w[i] = None
|
|
597
|
+
self.w_mid[start - 1] = None
|
|
598
|
+
return True
|
|
599
|
+
|
|
600
|
+
def compute_disps(self, start=1, end=-1, dx=None, friction=1e-3):
|
|
601
|
+
"""
|
|
602
|
+
Compute displacement vectors and total length between two images using vectorized operations.
|
|
603
|
+
Only recalculate internal coordinates if they are unknown.
|
|
604
|
+
"""
|
|
605
|
+
if end < 0:
|
|
606
|
+
end += self.nimages
|
|
607
|
+
self.update_intc()
|
|
608
|
+
|
|
609
|
+
# Vectorized calculation of displacement vectors in each segment
|
|
610
|
+
w_left = np.array(self.w[start - 1:end])
|
|
611
|
+
w_mid_left = np.array(self.w_mid[start - 1:end])
|
|
612
|
+
w_right = np.array(self.w[start:end + 1])
|
|
613
|
+
w_mid_right = np.array(self.w_mid[start - 1:end])
|
|
614
|
+
|
|
615
|
+
vecs_l = w_mid_left - w_left
|
|
616
|
+
vecs_r = w_right - w_mid_right
|
|
617
|
+
|
|
618
|
+
# Vectorized norm calculation
|
|
619
|
+
norms_l = np.linalg.norm(vecs_l, axis=1)
|
|
620
|
+
norms_r = np.linalg.norm(vecs_r, axis=1)
|
|
621
|
+
self.length = np.sum(norms_l) + np.sum(norms_r)
|
|
622
|
+
|
|
623
|
+
if dx is None:
|
|
624
|
+
trans = np.zeros(self.path[start:end].size)
|
|
625
|
+
else:
|
|
626
|
+
trans = friction * dx # Translation from initial geometry with friction term
|
|
627
|
+
|
|
628
|
+
self.disps = np.concatenate([vecs_l.ravel(), vecs_r.ravel(), trans])
|
|
629
|
+
self.disps0 = self.disps[:len(vecs_l.ravel()) + len(vecs_r.ravel())]
|
|
630
|
+
|
|
631
|
+
def compute_disp_grad(self, start, end, friction=1e-3):
|
|
632
|
+
"""
|
|
633
|
+
Compute derivatives of the displacement vectors with respect to the Cartesian coordinates
|
|
634
|
+
using vectorized operations
|
|
635
|
+
"""
|
|
636
|
+
# Calculate derivatives of displacement vectors with respect to image Cartesians
|
|
637
|
+
l = end - start + 1
|
|
638
|
+
total_size = l * 2 * self.nrij + 3 * (end - start) * self.natoms
|
|
639
|
+
dof_size = (end - start) * 3 * self.natoms
|
|
640
|
+
|
|
641
|
+
self.grad = np.zeros((total_size, dof_size))
|
|
642
|
+
self.grad0 = self.grad[:l * 2 * self.nrij]
|
|
643
|
+
|
|
644
|
+
grad_shape = (l, self.nrij, end - start, 3 * self.natoms)
|
|
645
|
+
grad_l = self.grad[:l * self.nrij].reshape(grad_shape)
|
|
646
|
+
grad_r = self.grad[l * self.nrij:l * self.nrij * 2].reshape(grad_shape)
|
|
647
|
+
|
|
648
|
+
# Vectorized gradient calculation
|
|
649
|
+
for i, image in enumerate(range(start, end)):
|
|
650
|
+
dmid1 = self.dwdR_mid[image - 1] / 2
|
|
651
|
+
dmid2 = self.dwdR_mid[image] / 2
|
|
652
|
+
dwdR_image = self.dwdR[image]
|
|
653
|
+
|
|
654
|
+
if i + 1 < l:
|
|
655
|
+
grad_l[i + 1, :, i, :] = dmid2 - dwdR_image
|
|
656
|
+
grad_l[i, :, i, :] = dmid1
|
|
657
|
+
|
|
658
|
+
if i + 1 < l:
|
|
659
|
+
grad_r[i + 1, :, i, :] = -dmid2
|
|
660
|
+
grad_r[i, :, i, :] = dwdR_image - dmid1
|
|
661
|
+
|
|
662
|
+
# Vectorized friction term assignment
|
|
663
|
+
friction_indices = np.arange((end - start) * 3 * self.natoms)
|
|
664
|
+
self.grad[l * self.nrij * 2 + friction_indices, friction_indices] = friction
|
|
665
|
+
|
|
666
|
+
def compute_target_func(self, X=None, start=1, end=-1, x0=None, friction=1e-3):
|
|
667
|
+
"""
|
|
668
|
+
Compute the vectorized target function, which is then used for least squares minimization.
|
|
669
|
+
"""
|
|
670
|
+
if end < 0:
|
|
671
|
+
end += self.nimages
|
|
672
|
+
if X is not None and not self.update_geometry(X, start, end) and self.segment == (start, end):
|
|
673
|
+
return
|
|
674
|
+
|
|
675
|
+
self.segment = start, end
|
|
676
|
+
dx = np.zeros(self.path[start:end].size) if x0 is None else self.path[start:end].ravel() - x0.ravel()
|
|
677
|
+
self.compute_disps(start, end, dx=dx, friction=friction)
|
|
678
|
+
self.compute_disp_grad(start, end, friction=friction)
|
|
679
|
+
self.optimality = np.linalg.norm(np.einsum('i,i...', self.disps, self.grad), ord=np.inf)
|
|
680
|
+
|
|
681
|
+
# Calculate current RMSD for tracking
|
|
682
|
+
current_rmsd = 0.0
|
|
683
|
+
for i in range(len(self.path) - 1):
|
|
684
|
+
rmsd = np.sqrt(np.mean((self.path[i+1] - self.path[i]) ** 2))
|
|
685
|
+
current_rmsd = max(current_rmsd, rmsd)
|
|
686
|
+
|
|
687
|
+
# Store optimization history
|
|
688
|
+
self.optimization_history['iterations'].append(self.neval)
|
|
689
|
+
self.optimization_history['path_lengths'].append(self.length)
|
|
690
|
+
self.optimization_history['optimalities'].append(self.optimality)
|
|
691
|
+
self.optimization_history['rmsds'].append(current_rmsd)
|
|
692
|
+
|
|
693
|
+
if self.verbose:
|
|
694
|
+
# Check if we should print this iteration
|
|
695
|
+
should_print = True
|
|
696
|
+
if hasattr(self, '_progress_freq') and self._progress_freq > 1:
|
|
697
|
+
should_print = (self.neval % self._progress_freq == 0) or (self.neval == 0)
|
|
698
|
+
|
|
699
|
+
if should_print:
|
|
700
|
+
print(f" Iteration {self.neval:3d}: Length={self.length:10.6f}, |dL|={self.optimality:8.3e}, RMSD={current_rmsd:.4f}")
|
|
701
|
+
|
|
702
|
+
self.conv_path.append(self.path[1].copy())
|
|
703
|
+
self.neval += 1
|
|
704
|
+
|
|
705
|
+
def target_func(self, X, **kwargs):
|
|
706
|
+
"""
|
|
707
|
+
Wrapper around `compute_target_func` to prevent repeated evaluation at the same geometry
|
|
708
|
+
"""
|
|
709
|
+
self.compute_target_func(X, **kwargs)
|
|
710
|
+
return self.disps
|
|
711
|
+
|
|
712
|
+
def target_deriv(self, X, **kwargs):
|
|
713
|
+
"""
|
|
714
|
+
Wrapper around `compute_target_func` to prevent repeated evaluation at the same geometry
|
|
715
|
+
"""
|
|
716
|
+
self.compute_target_func(X, **kwargs)
|
|
717
|
+
return self.grad
|
|
718
|
+
|
|
719
|
+
def smooth(self, tol=1e-3, max_iter=50, start=1, end=-1, verbose=None, friction=None,
|
|
720
|
+
xref=None, progress_freq=1):
|
|
721
|
+
"""
|
|
722
|
+
Minimize the path length as an overall function of the coordinates of all the images.
|
|
723
|
+
|
|
724
|
+
Parameters:
|
|
725
|
+
-----------
|
|
726
|
+
progress_freq : int, optional
|
|
727
|
+
Print progress every N iterations (default=1, set to 0 to disable progress)
|
|
728
|
+
"""
|
|
729
|
+
if verbose is None:
|
|
730
|
+
verbose = self.verbose
|
|
731
|
+
|
|
732
|
+
# Temporarily adjust verbosity for iteration printing
|
|
733
|
+
original_verbose = self.verbose
|
|
734
|
+
if progress_freq == 0:
|
|
735
|
+
self.verbose = False
|
|
736
|
+
elif progress_freq > 1:
|
|
737
|
+
self._progress_freq = progress_freq
|
|
738
|
+
self._iteration_count = 0
|
|
739
|
+
|
|
740
|
+
X0 = np.array(self.path[start:end]).ravel()
|
|
741
|
+
if xref is None:
|
|
742
|
+
xref = X0
|
|
743
|
+
self.disps = self.grad = self.segment = None
|
|
744
|
+
|
|
745
|
+
if verbose:
|
|
746
|
+
print(f" Starting geodesic optimization with {len(X0)} degrees of freedom")
|
|
747
|
+
if progress_freq > 1:
|
|
748
|
+
print(f" Progress will be shown every {progress_freq} iterations")
|
|
749
|
+
|
|
750
|
+
if friction is None:
|
|
751
|
+
friction = self.friction
|
|
752
|
+
|
|
753
|
+
# Configure the keyword arguments that will be sent to the target function
|
|
754
|
+
kwargs = dict(start=start, end=end, x0=xref, friction=friction)
|
|
755
|
+
self.compute_target_func(**kwargs) # Compute length and optimality
|
|
756
|
+
|
|
757
|
+
if self.optimality > tol:
|
|
758
|
+
if verbose:
|
|
759
|
+
print(" Starting least-squares optimization...")
|
|
760
|
+
|
|
761
|
+
result = least_squares(self.target_func, X0, self.target_deriv, ftol=tol, gtol=tol,
|
|
762
|
+
max_nfev=max_iter, kwargs=kwargs, loss='soft_l1')
|
|
763
|
+
self.update_geometry(result['x'], start, end)
|
|
764
|
+
|
|
765
|
+
if verbose:
|
|
766
|
+
print(f" Optimization converged after {result['nfev']} iterations")
|
|
767
|
+
print(f" Success: {result['success']}, Message: {result['message']}")
|
|
768
|
+
else:
|
|
769
|
+
if verbose:
|
|
770
|
+
print(" Path already optimal, skipping optimization")
|
|
771
|
+
|
|
772
|
+
# Restore original verbosity
|
|
773
|
+
self.verbose = original_verbose
|
|
774
|
+
|
|
775
|
+
rmsd, self.path = align_path(self.path)
|
|
776
|
+
|
|
777
|
+
if verbose:
|
|
778
|
+
print(f" Final path length: {self.length:.6f}")
|
|
779
|
+
print(f" Maximum RMSD in path: {rmsd:.4f} Å")
|
|
780
|
+
|
|
781
|
+
# Print optimization summary
|
|
782
|
+
if len(self.optimization_history['iterations']) > 1:
|
|
783
|
+
print(self.get_optimization_summary())
|
|
784
|
+
|
|
785
|
+
return self.path
|