MultiOptPy 1.20.4__py3-none-any.whl → 1.20.6__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.
@@ -0,0 +1,314 @@
1
+ import numpy as np
2
+ import os
3
+ import shutil
4
+
5
+ from multioptpy.Utils.calc_tools import Calculationtools
6
+ from multioptpy.Visualization.visualization import Graph
7
+
8
+ class SpringPairMethod:
9
+ """
10
+ Implementation of the Spring Pair Method (SPM) with Adaptive Step Size & Momentum.
11
+ Modified to accept a SINGLE initial structure and auto-generate the second one via perturbation.
12
+ """
13
+ def __init__(self, config):
14
+ self.config = config
15
+
16
+ # --- TUNING PARAMETERS ---
17
+ self.k_spring = 10.0
18
+ self.l_s = max(getattr(self.config, 'L_covergence', 0.1), 0.1)
19
+ self.initial_drift_step = 0.01
20
+ self.climb_step_size = 0.50
21
+ self.drift_limit = 100
22
+ self.momentum = 0.3
23
+ self.MAX_FORCE_THRESHOLD = 0.00100
24
+ self.RMS_FORCE_THRESHOLD = 0.00005
25
+
26
+ # Magnitude of random perturbation to generate Image 2 (Bohr)
27
+ self.perturbation_scale = 0.1
28
+
29
+ def RMS(self, mat):
30
+ return np.sqrt(np.mean(mat**2))
31
+
32
+ def print_info(self, dat, phase):
33
+ print(f"[[{phase} information]]")
34
+ print(f" image_1 image_2")
35
+ print(f"energy (Hartree) : {dat['energy_1']:.8f} {dat['energy_2']:.8f}")
36
+ print(f"gradient RMS : {self.RMS(dat['gradient_1']):.6f} {self.RMS(dat['gradient_2']):.6f}")
37
+
38
+ if "perp_force_1" in dat:
39
+ print(f"perp_force (Drift) : {self.RMS(dat['perp_force_1']):.6f} {self.RMS(dat['perp_force_2']):.6f}")
40
+ print(f"spring_force : {self.RMS(dat['spring_force_1']):.6f} {self.RMS(dat['spring_force_2']):.6f}")
41
+
42
+ print(f"spring_length (Bohr) : {dat['spring_length']:.6f}")
43
+ step_info = dat.get('step_size', 0)
44
+ print(f"DEBUG: k={self.k_spring:.1f}, step={step_info:.6f}")
45
+ print("-" * 80)
46
+ return
47
+
48
+ def get_spring_vectors(self, geom_1, geom_2):
49
+ diff = geom_2 - geom_1
50
+ dist = np.linalg.norm(diff)
51
+ if dist < 1e-10:
52
+ rand_vec = np.random.randn(*diff.shape)
53
+ unit_vec = rand_vec / np.linalg.norm(rand_vec)
54
+ dist = 1e-10
55
+ else:
56
+ unit_vec = diff / dist
57
+ return dist, unit_vec
58
+
59
+ def decompose_gradient(self, gradient, unit_vec):
60
+ grad_flat = gradient.flatten()
61
+ vec_flat = unit_vec.flatten()
62
+ grad_par_mag = np.dot(grad_flat, vec_flat)
63
+ grad_par = grad_par_mag * unit_vec
64
+ grad_perp = gradient - grad_par
65
+ return grad_par, grad_perp
66
+
67
+ def _generate_perturbed_structure(self, geom, scale):
68
+ """Generate a new structure by adding random perturbation to the given geometry."""
69
+ # Generate random noise
70
+ noise = np.random.randn(*geom.shape)
71
+ # Normalize to unit vectors per atom to distribute perturbation evenly
72
+ norms = np.linalg.norm(noise, axis=1, keepdims=True)
73
+ noise = noise / (norms + 1e-10)
74
+ # Scale the noise
75
+ perturbation = noise * scale
76
+ return geom + perturbation
77
+
78
+ def iteration(self, file_directory_1, SP1, element_list, electric_charge_and_multiplicity, FIO1):
79
+ """
80
+ Main SPM Optimization Loop.
81
+ Accepts ONE initial structure. Image 2 is auto-generated.
82
+ """
83
+ G = Graph(self.config.iEIP_FOLDER_DIRECTORY)
84
+ ENERGY_LIST_1, ENERGY_LIST_2 = [], []
85
+ GRAD_LIST_1, GRAD_LIST_2 = [], []
86
+
87
+ # --- Initialize Image 2 from Image 1 ---
88
+ print("### Initializing SPM ###")
89
+ print(f"Base Structure (Image 1): {file_directory_1}")
90
+
91
+ # 1. Get initial geometry of Image 1
92
+ # We perform a dummy single point calculation or just read the geom if possible.
93
+ # Here we run SP1 to get the geometry and energy reliably.
94
+ init_energy, init_grad, init_geom, err = SP1.single_point(
95
+ file_directory_1, element_list, "init_check",
96
+ electric_charge_and_multiplicity, self.config.force_data["xtb"]
97
+ )
98
+ if err:
99
+ print("[Error] Failed to read initial structure.")
100
+ return
101
+
102
+ # 2. Generate Image 2 geometry
103
+ print(f"Generating Image 2 with random perturbation (Scale: {self.perturbation_scale} Bohr)...")
104
+ init_geom_2 = self._generate_perturbed_structure(init_geom, self.perturbation_scale)
105
+
106
+ # Create a directory for Image 2 (internally managed)
107
+ # We can just use the same SP1 object but we need a file path for it.
108
+ # We will generate input files for Image 2 on the fly using FIO1 logic.
109
+ # Note: We reuse SP1 and FIO1 for both images since they share physics/settings.
110
+
111
+ # Initialize loop variables
112
+ geom_num_list_1 = init_geom
113
+ geom_num_list_2 = init_geom_2
114
+
115
+ velocity_1 = np.zeros((len(element_list), 3))
116
+ velocity_2 = np.zeros((len(element_list), 3))
117
+
118
+ current_drift_step = self.initial_drift_step
119
+
120
+ # Variables to store the latest geometry
121
+ new_geom_1 = None
122
+ new_geom_2 = None
123
+
124
+ for cycle in range(0, self.config.microiterlimit):
125
+ if os.path.isfile(self.config.iEIP_FOLDER_DIRECTORY+"end.txt"):
126
+ break
127
+
128
+ print(f"### Cycle {cycle} Start ###")
129
+
130
+ # =========================================================================
131
+ # 1. Drifting Phase
132
+ # =========================================================================
133
+ print(f"--- Drifting Phase (Cycle {cycle}) ---")
134
+
135
+ prev_force_drift_1 = None
136
+ prev_force_drift_2 = None
137
+
138
+ drift_temp_label = f"{cycle}_drift_temp"
139
+
140
+ for d_step in range(self.drift_limit):
141
+ iter_label = drift_temp_label
142
+
143
+ # Make input files for this step
144
+ # Note: file_directory_1/2 here are just strings for the input file path
145
+ # generated by _make_next_input.
146
+ # However, SP1.single_point expects the directory path or file path depending on implementation.
147
+ # Assuming _make_next_input returns the PATH to the input file.
148
+
149
+ input_path_1 = self._make_next_input(FIO1, geom_num_list_1, element_list, electric_charge_and_multiplicity, iter_label + "_img1")
150
+ input_path_2 = self._make_next_input(FIO1, geom_num_list_2, element_list, electric_charge_and_multiplicity, iter_label + "_img2")
151
+
152
+ # 1. QM Calculation
153
+ # We reuse SP1 for both. It's just a calculator.
154
+ energy_1, gradient_1, g1_read, error_flag_1 = SP1.single_point(input_path_1, element_list, iter_label + "_img1", electric_charge_and_multiplicity, self.config.force_data["xtb"])
155
+ energy_2, gradient_2, g2_read, error_flag_2 = SP1.single_point(input_path_2, element_list, iter_label + "_img2", electric_charge_and_multiplicity, self.config.force_data["xtb"])
156
+
157
+ # Update geom from read result to be safe, or stick to internal numpy array
158
+ # Using internal array (geom_num_list_1/2) is safer for consistency unless optimizer changes it.
159
+ # But let's align them just in case output orientation changed.
160
+ # g1_read, g2_read = Calculationtools().kabsch_algorithm(g1_read, g2_read)
161
+ # geom_num_list_1, geom_num_list_2 = g1_read, g2_read
162
+
163
+ # Align current internal geometries
164
+ geom_num_list_1, geom_num_list_2 = Calculationtools().kabsch_algorithm(geom_num_list_1, geom_num_list_2)
165
+
166
+ if error_flag_1 or error_flag_2:
167
+ return
168
+
169
+ # 2. Vector & Force Calculation
170
+ ds, vs = self.get_spring_vectors(geom_num_list_1, geom_num_list_2)
171
+
172
+ _, grad_perp_1 = self.decompose_gradient(gradient_1, vs)
173
+ _, grad_perp_2 = self.decompose_gradient(gradient_2, vs)
174
+
175
+ force_perp_1 = -grad_perp_1
176
+ force_perp_2 = -grad_perp_2
177
+
178
+ spring_force_mag = self.k_spring * (ds - self.l_s)
179
+ spring_force_1 = spring_force_mag * vs
180
+ spring_force_2 = spring_force_mag * (-vs)
181
+
182
+ total_force_1 = force_perp_1 + spring_force_1
183
+ total_force_2 = force_perp_2 + spring_force_2
184
+
185
+ # --- Adaptive Step Logic ---
186
+ if prev_force_drift_1 is not None:
187
+ dot_1 = np.sum(prev_force_drift_1 * total_force_1)
188
+ dot_2 = np.sum(prev_force_drift_2 * total_force_2)
189
+
190
+ if dot_1 < 0 or dot_2 < 0:
191
+ current_drift_step *= 0.5
192
+ velocity_1 *= 0.1
193
+ velocity_2 *= 0.1
194
+ print(f" [Auto-Brake] Oscillation detected at step {d_step}. Reduced drift step to {current_drift_step:.6f}")
195
+ else:
196
+ current_drift_step = min(current_drift_step * 1.05, self.initial_drift_step)
197
+
198
+ prev_force_drift_1 = total_force_1.copy()
199
+ prev_force_drift_2 = total_force_2.copy()
200
+
201
+ # Update Position
202
+ velocity_1 = self.momentum * velocity_1 + current_drift_step * total_force_1
203
+ velocity_2 = self.momentum * velocity_2 + current_drift_step * total_force_2
204
+
205
+ geom_num_list_1 += velocity_1
206
+ geom_num_list_2 += velocity_2
207
+
208
+ # Check Convergence
209
+ drift_metric = max(self.RMS(force_perp_1), self.RMS(force_perp_2))
210
+
211
+ if d_step % 5 == 0:
212
+ info_dat = {
213
+ "energy_1": energy_1, "energy_2": energy_2,
214
+ "gradient_1": gradient_1, "gradient_2": gradient_2,
215
+ "perp_force_1": force_perp_1, "perp_force_2": force_perp_2,
216
+ "spring_force_1": spring_force_1, "spring_force_2": spring_force_2,
217
+ "spring_length": ds,
218
+ "step_size": current_drift_step,
219
+ "convergence_metric": drift_metric
220
+ }
221
+ self.print_info(info_dat, f"Cycle {cycle} - Drifting {d_step}")
222
+
223
+ if drift_metric < self.RMS_FORCE_THRESHOLD:
224
+ print(f" >> Drifting converged at step {d_step}")
225
+ break
226
+
227
+ # =========================================================================
228
+ # 2. Climbing Phase
229
+ # =========================================================================
230
+ print(f"--- Climbing Phase (Cycle {cycle}) ---")
231
+
232
+ iter_label = f"{cycle}_climb"
233
+
234
+ input_path_1 = self._make_next_input(FIO1, geom_num_list_1, element_list, electric_charge_and_multiplicity, iter_label + "_img1")
235
+ input_path_2 = self._make_next_input(FIO1, geom_num_list_2, element_list, electric_charge_and_multiplicity, iter_label + "_img2")
236
+
237
+ energy_1, gradient_1, g1_read, error_flag_1 = SP1.single_point(input_path_1, element_list, iter_label + "_img1", electric_charge_and_multiplicity, self.config.force_data["xtb"])
238
+ energy_2, gradient_2, g2_read, error_flag_2 = SP1.single_point(input_path_2, element_list, iter_label + "_img2", electric_charge_and_multiplicity, self.config.force_data["xtb"])
239
+
240
+ geom_num_list_1, geom_num_list_2 = Calculationtools().kabsch_algorithm(geom_num_list_1, geom_num_list_2)
241
+
242
+ ds, vs = self.get_spring_vectors(geom_num_list_1, geom_num_list_2)
243
+ grad_par_1, _ = self.decompose_gradient(gradient_1, vs)
244
+ grad_par_2, _ = self.decompose_gradient(gradient_2, vs)
245
+
246
+ active_climb_step = self.climb_step_size
247
+
248
+ # Move UP
249
+ geom_num_list_1 += active_climb_step * grad_par_1
250
+ geom_num_list_2 += active_climb_step * grad_par_2
251
+
252
+ # Update 'new_geom' for final output reference
253
+ new_geom_1 = geom_num_list_1.copy()
254
+ new_geom_2 = geom_num_list_2.copy()
255
+
256
+ grad_norm_1 = np.linalg.norm(gradient_1)
257
+ grad_norm_2 = np.linalg.norm(gradient_2)
258
+ global_metric = min(grad_norm_1, grad_norm_2)
259
+
260
+ info_dat = {
261
+ "energy_1": energy_1, "energy_2": energy_2,
262
+ "gradient_1": gradient_1, "gradient_2": gradient_2,
263
+ "par_force_1": grad_par_1, "par_force_2": grad_par_2,
264
+ "spring_length": ds, "convergence_metric": global_metric,
265
+ "step_size": active_climb_step
266
+ }
267
+ self.print_info(info_dat, f"Cycle {cycle} - Climbing")
268
+
269
+ ENERGY_LIST_1.append(energy_1 * self.config.hartree2kcalmol)
270
+ ENERGY_LIST_2.append(energy_2 * self.config.hartree2kcalmol)
271
+ GRAD_LIST_1.append(grad_norm_1)
272
+ GRAD_LIST_2.append(grad_norm_2)
273
+
274
+ if cycle > 5 and global_metric < self.MAX_FORCE_THRESHOLD:
275
+ print("!!! Global Convergence Reached !!!")
276
+ print(f"Saddle point candidate found around cycle {cycle}")
277
+ break
278
+
279
+ # --- END OF OPTIMIZATION LOOP ---
280
+
281
+ # Save the optimized structure
282
+ if new_geom_1 is not None and new_geom_2 is not None:
283
+ avg_geom = (new_geom_1 + new_geom_2) / 2.0
284
+ avg_geom_ang = avg_geom * self.config.bohr2angstroms
285
+
286
+ dir_name = os.path.basename(os.path.normpath(self.config.iEIP_FOLDER_DIRECTORY))
287
+ output_xyz_name = f"{dir_name}_optimized.xyz"
288
+
289
+ try:
290
+ with open(output_xyz_name, "w") as f:
291
+ num_atoms = len(element_list)
292
+ f.write(f"{num_atoms}\n")
293
+ f.write(f"SPM Optimized Saddle Point (Average)\n")
294
+ for i, elem in enumerate(element_list):
295
+ x, y, z = avg_geom_ang[i]
296
+ f.write(f"{elem} {x:.10f} {y:.10f} {z:.10f}\n")
297
+ print(f"\n[Success] Final optimized structure saved to: {output_xyz_name}")
298
+ except Exception as e:
299
+ print(f"\n[Error] Failed to save optimized structure: {e}")
300
+
301
+ return
302
+
303
+ def _make_next_input(self, FIO, geom, element_list, charge_mult, iter_label):
304
+ """
305
+ Creates a PSI4 input file and returns its path.
306
+ """
307
+ geom_tolist = (geom * self.config.bohr2angstroms).tolist()
308
+ for i, elem in enumerate(element_list):
309
+ geom_tolist[i].insert(0, elem)
310
+ geom_tolist.insert(0, charge_mult)
311
+
312
+ # FIO.make_psi4_input_file usually returns the directory or path.
313
+ # Ensure this matches your FIO implementation.
314
+ return FIO.make_psi4_input_file([geom_tolist], iter_label)