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.
Files changed (246) hide show
  1. multioptpy/Calculator/__init__.py +0 -0
  2. multioptpy/Calculator/ase_calculation_tools.py +424 -0
  3. multioptpy/Calculator/ase_tools/__init__.py +0 -0
  4. multioptpy/Calculator/ase_tools/fairchem.py +28 -0
  5. multioptpy/Calculator/ase_tools/gamess.py +19 -0
  6. multioptpy/Calculator/ase_tools/gaussian.py +165 -0
  7. multioptpy/Calculator/ase_tools/mace.py +28 -0
  8. multioptpy/Calculator/ase_tools/mopac.py +19 -0
  9. multioptpy/Calculator/ase_tools/nwchem.py +31 -0
  10. multioptpy/Calculator/ase_tools/orca.py +22 -0
  11. multioptpy/Calculator/ase_tools/pygfn0.py +37 -0
  12. multioptpy/Calculator/dxtb_calculation_tools.py +344 -0
  13. multioptpy/Calculator/emt_calculation_tools.py +458 -0
  14. multioptpy/Calculator/gpaw_calculation_tools.py +183 -0
  15. multioptpy/Calculator/lj_calculation_tools.py +314 -0
  16. multioptpy/Calculator/psi4_calculation_tools.py +334 -0
  17. multioptpy/Calculator/pwscf_calculation_tools.py +189 -0
  18. multioptpy/Calculator/pyscf_calculation_tools.py +327 -0
  19. multioptpy/Calculator/sqm1_calculation_tools.py +611 -0
  20. multioptpy/Calculator/sqm2_calculation_tools.py +376 -0
  21. multioptpy/Calculator/tblite_calculation_tools.py +352 -0
  22. multioptpy/Calculator/tersoff_calculation_tools.py +818 -0
  23. multioptpy/Constraint/__init__.py +0 -0
  24. multioptpy/Constraint/constraint_condition.py +834 -0
  25. multioptpy/Coordinate/__init__.py +0 -0
  26. multioptpy/Coordinate/polar_coordinate.py +199 -0
  27. multioptpy/Coordinate/redundant_coordinate.py +638 -0
  28. multioptpy/IRC/__init__.py +0 -0
  29. multioptpy/IRC/converge_criteria.py +28 -0
  30. multioptpy/IRC/dvv.py +544 -0
  31. multioptpy/IRC/euler.py +439 -0
  32. multioptpy/IRC/hpc.py +564 -0
  33. multioptpy/IRC/lqa.py +540 -0
  34. multioptpy/IRC/modekill.py +662 -0
  35. multioptpy/IRC/rk4.py +579 -0
  36. multioptpy/Interpolation/__init__.py +0 -0
  37. multioptpy/Interpolation/adaptive_interpolation.py +283 -0
  38. multioptpy/Interpolation/binomial_interpolation.py +179 -0
  39. multioptpy/Interpolation/geodesic_interpolation.py +785 -0
  40. multioptpy/Interpolation/interpolation.py +156 -0
  41. multioptpy/Interpolation/linear_interpolation.py +473 -0
  42. multioptpy/Interpolation/savitzky_golay_interpolation.py +252 -0
  43. multioptpy/Interpolation/spline_interpolation.py +353 -0
  44. multioptpy/MD/__init__.py +0 -0
  45. multioptpy/MD/thermostat.py +185 -0
  46. multioptpy/MEP/__init__.py +0 -0
  47. multioptpy/MEP/pathopt_bneb_force.py +443 -0
  48. multioptpy/MEP/pathopt_dmf_force.py +448 -0
  49. multioptpy/MEP/pathopt_dneb_force.py +130 -0
  50. multioptpy/MEP/pathopt_ewbneb_force.py +207 -0
  51. multioptpy/MEP/pathopt_gpneb_force.py +512 -0
  52. multioptpy/MEP/pathopt_lup_force.py +113 -0
  53. multioptpy/MEP/pathopt_neb_force.py +225 -0
  54. multioptpy/MEP/pathopt_nesb_force.py +205 -0
  55. multioptpy/MEP/pathopt_om_force.py +153 -0
  56. multioptpy/MEP/pathopt_qsm_force.py +174 -0
  57. multioptpy/MEP/pathopt_qsmv2_force.py +304 -0
  58. multioptpy/ModelFunction/__init__.py +7 -0
  59. multioptpy/ModelFunction/avoiding_model_function.py +29 -0
  60. multioptpy/ModelFunction/binary_image_ts_search_model_function.py +47 -0
  61. multioptpy/ModelFunction/conical_model_function.py +26 -0
  62. multioptpy/ModelFunction/opt_meci.py +50 -0
  63. multioptpy/ModelFunction/opt_mesx.py +47 -0
  64. multioptpy/ModelFunction/opt_mesx_2.py +49 -0
  65. multioptpy/ModelFunction/seam_model_function.py +27 -0
  66. multioptpy/ModelHessian/__init__.py +0 -0
  67. multioptpy/ModelHessian/approx_hessian.py +147 -0
  68. multioptpy/ModelHessian/calc_params.py +227 -0
  69. multioptpy/ModelHessian/fischer.py +236 -0
  70. multioptpy/ModelHessian/fischerd3.py +360 -0
  71. multioptpy/ModelHessian/fischerd4.py +398 -0
  72. multioptpy/ModelHessian/gfn0xtb.py +633 -0
  73. multioptpy/ModelHessian/gfnff.py +709 -0
  74. multioptpy/ModelHessian/lindh.py +165 -0
  75. multioptpy/ModelHessian/lindh2007d2.py +707 -0
  76. multioptpy/ModelHessian/lindh2007d3.py +822 -0
  77. multioptpy/ModelHessian/lindh2007d4.py +1030 -0
  78. multioptpy/ModelHessian/morse.py +106 -0
  79. multioptpy/ModelHessian/schlegel.py +144 -0
  80. multioptpy/ModelHessian/schlegeld3.py +322 -0
  81. multioptpy/ModelHessian/schlegeld4.py +559 -0
  82. multioptpy/ModelHessian/shortrange.py +346 -0
  83. multioptpy/ModelHessian/swartd2.py +496 -0
  84. multioptpy/ModelHessian/swartd3.py +706 -0
  85. multioptpy/ModelHessian/swartd4.py +918 -0
  86. multioptpy/ModelHessian/tshess.py +40 -0
  87. multioptpy/Optimizer/QHAdam.py +61 -0
  88. multioptpy/Optimizer/__init__.py +0 -0
  89. multioptpy/Optimizer/abc_fire.py +83 -0
  90. multioptpy/Optimizer/adabelief.py +58 -0
  91. multioptpy/Optimizer/adabound.py +68 -0
  92. multioptpy/Optimizer/adadelta.py +65 -0
  93. multioptpy/Optimizer/adaderivative.py +56 -0
  94. multioptpy/Optimizer/adadiff.py +68 -0
  95. multioptpy/Optimizer/adafactor.py +70 -0
  96. multioptpy/Optimizer/adam.py +65 -0
  97. multioptpy/Optimizer/adamax.py +62 -0
  98. multioptpy/Optimizer/adamod.py +83 -0
  99. multioptpy/Optimizer/adamw.py +65 -0
  100. multioptpy/Optimizer/adiis.py +523 -0
  101. multioptpy/Optimizer/afire_neb.py +282 -0
  102. multioptpy/Optimizer/block_hessian_update.py +709 -0
  103. multioptpy/Optimizer/c2diis.py +491 -0
  104. multioptpy/Optimizer/component_wise_scaling.py +405 -0
  105. multioptpy/Optimizer/conjugate_gradient.py +82 -0
  106. multioptpy/Optimizer/conjugate_gradient_neb.py +345 -0
  107. multioptpy/Optimizer/coordinate_locking.py +405 -0
  108. multioptpy/Optimizer/dic_rsirfo.py +1015 -0
  109. multioptpy/Optimizer/ediis.py +417 -0
  110. multioptpy/Optimizer/eve.py +76 -0
  111. multioptpy/Optimizer/fastadabelief.py +61 -0
  112. multioptpy/Optimizer/fire.py +77 -0
  113. multioptpy/Optimizer/fire2.py +249 -0
  114. multioptpy/Optimizer/fire_neb.py +92 -0
  115. multioptpy/Optimizer/gan_step.py +486 -0
  116. multioptpy/Optimizer/gdiis.py +609 -0
  117. multioptpy/Optimizer/gediis.py +203 -0
  118. multioptpy/Optimizer/geodesic_step.py +433 -0
  119. multioptpy/Optimizer/gpmin.py +633 -0
  120. multioptpy/Optimizer/gpr_step.py +364 -0
  121. multioptpy/Optimizer/gradientdescent.py +78 -0
  122. multioptpy/Optimizer/gradientdescent_neb.py +52 -0
  123. multioptpy/Optimizer/hessian_update.py +433 -0
  124. multioptpy/Optimizer/hybrid_rfo.py +998 -0
  125. multioptpy/Optimizer/kdiis.py +625 -0
  126. multioptpy/Optimizer/lars.py +21 -0
  127. multioptpy/Optimizer/lbfgs.py +253 -0
  128. multioptpy/Optimizer/lbfgs_neb.py +355 -0
  129. multioptpy/Optimizer/linesearch.py +236 -0
  130. multioptpy/Optimizer/lookahead.py +40 -0
  131. multioptpy/Optimizer/nadam.py +64 -0
  132. multioptpy/Optimizer/newton.py +200 -0
  133. multioptpy/Optimizer/prodigy.py +70 -0
  134. multioptpy/Optimizer/purtubation.py +16 -0
  135. multioptpy/Optimizer/quickmin_neb.py +245 -0
  136. multioptpy/Optimizer/radam.py +75 -0
  137. multioptpy/Optimizer/rfo_neb.py +302 -0
  138. multioptpy/Optimizer/ric_rfo.py +842 -0
  139. multioptpy/Optimizer/rl_step.py +627 -0
  140. multioptpy/Optimizer/rmspropgrave.py +65 -0
  141. multioptpy/Optimizer/rsirfo.py +1647 -0
  142. multioptpy/Optimizer/rsprfo.py +1056 -0
  143. multioptpy/Optimizer/sadam.py +60 -0
  144. multioptpy/Optimizer/samsgrad.py +63 -0
  145. multioptpy/Optimizer/tr_lbfgs.py +678 -0
  146. multioptpy/Optimizer/trim.py +273 -0
  147. multioptpy/Optimizer/trust_radius.py +207 -0
  148. multioptpy/Optimizer/trust_radius_neb.py +121 -0
  149. multioptpy/Optimizer/yogi.py +60 -0
  150. multioptpy/OtherMethod/__init__.py +0 -0
  151. multioptpy/OtherMethod/addf.py +1150 -0
  152. multioptpy/OtherMethod/dimer.py +895 -0
  153. multioptpy/OtherMethod/elastic_image_pair.py +629 -0
  154. multioptpy/OtherMethod/modelfunction.py +456 -0
  155. multioptpy/OtherMethod/newton_traj.py +454 -0
  156. multioptpy/OtherMethod/twopshs.py +1095 -0
  157. multioptpy/PESAnalyzer/__init__.py +0 -0
  158. multioptpy/PESAnalyzer/calc_irc_curvature.py +125 -0
  159. multioptpy/PESAnalyzer/cmds_analysis.py +152 -0
  160. multioptpy/PESAnalyzer/koopman_analysis.py +268 -0
  161. multioptpy/PESAnalyzer/pca_analysis.py +314 -0
  162. multioptpy/Parameters/__init__.py +0 -0
  163. multioptpy/Parameters/atomic_mass.py +20 -0
  164. multioptpy/Parameters/atomic_number.py +22 -0
  165. multioptpy/Parameters/covalent_radii.py +44 -0
  166. multioptpy/Parameters/d2.py +61 -0
  167. multioptpy/Parameters/d3.py +63 -0
  168. multioptpy/Parameters/d4.py +103 -0
  169. multioptpy/Parameters/dreiding.py +34 -0
  170. multioptpy/Parameters/gfn0xtb_param.py +137 -0
  171. multioptpy/Parameters/gfnff_param.py +315 -0
  172. multioptpy/Parameters/gnb.py +104 -0
  173. multioptpy/Parameters/parameter.py +22 -0
  174. multioptpy/Parameters/uff.py +72 -0
  175. multioptpy/Parameters/unit_values.py +20 -0
  176. multioptpy/Potential/AFIR_potential.py +55 -0
  177. multioptpy/Potential/LJ_repulsive_potential.py +345 -0
  178. multioptpy/Potential/__init__.py +0 -0
  179. multioptpy/Potential/anharmonic_keep_potential.py +28 -0
  180. multioptpy/Potential/asym_elllipsoidal_potential.py +718 -0
  181. multioptpy/Potential/electrostatic_potential.py +69 -0
  182. multioptpy/Potential/flux_potential.py +30 -0
  183. multioptpy/Potential/gaussian_potential.py +101 -0
  184. multioptpy/Potential/idpp.py +516 -0
  185. multioptpy/Potential/keep_angle_potential.py +146 -0
  186. multioptpy/Potential/keep_dihedral_angle_potential.py +105 -0
  187. multioptpy/Potential/keep_outofplain_angle_potential.py +70 -0
  188. multioptpy/Potential/keep_potential.py +99 -0
  189. multioptpy/Potential/mechano_force_potential.py +74 -0
  190. multioptpy/Potential/nanoreactor_potential.py +52 -0
  191. multioptpy/Potential/potential.py +896 -0
  192. multioptpy/Potential/spacer_model_potential.py +221 -0
  193. multioptpy/Potential/switching_potential.py +258 -0
  194. multioptpy/Potential/universal_potential.py +34 -0
  195. multioptpy/Potential/value_range_potential.py +36 -0
  196. multioptpy/Potential/void_point_potential.py +25 -0
  197. multioptpy/SQM/__init__.py +0 -0
  198. multioptpy/SQM/sqm1/__init__.py +0 -0
  199. multioptpy/SQM/sqm1/sqm1_core.py +1792 -0
  200. multioptpy/SQM/sqm2/__init__.py +0 -0
  201. multioptpy/SQM/sqm2/calc_tools.py +95 -0
  202. multioptpy/SQM/sqm2/sqm2_basis.py +850 -0
  203. multioptpy/SQM/sqm2/sqm2_bond.py +119 -0
  204. multioptpy/SQM/sqm2/sqm2_core.py +303 -0
  205. multioptpy/SQM/sqm2/sqm2_data.py +1229 -0
  206. multioptpy/SQM/sqm2/sqm2_disp.py +65 -0
  207. multioptpy/SQM/sqm2/sqm2_eeq.py +243 -0
  208. multioptpy/SQM/sqm2/sqm2_overlapint.py +704 -0
  209. multioptpy/SQM/sqm2/sqm2_qm.py +578 -0
  210. multioptpy/SQM/sqm2/sqm2_rep.py +66 -0
  211. multioptpy/SQM/sqm2/sqm2_srb.py +70 -0
  212. multioptpy/Thermo/__init__.py +0 -0
  213. multioptpy/Thermo/normal_mode_analyzer.py +865 -0
  214. multioptpy/Utils/__init__.py +0 -0
  215. multioptpy/Utils/bond_connectivity.py +264 -0
  216. multioptpy/Utils/calc_tools.py +884 -0
  217. multioptpy/Utils/oniom.py +96 -0
  218. multioptpy/Utils/pbc.py +48 -0
  219. multioptpy/Utils/riemann_curvature.py +208 -0
  220. multioptpy/Utils/symmetry_analyzer.py +482 -0
  221. multioptpy/Visualization/__init__.py +0 -0
  222. multioptpy/Visualization/visualization.py +156 -0
  223. multioptpy/WFAnalyzer/MO_analysis.py +104 -0
  224. multioptpy/WFAnalyzer/__init__.py +0 -0
  225. multioptpy/Wrapper/__init__.py +0 -0
  226. multioptpy/Wrapper/autots.py +1239 -0
  227. multioptpy/Wrapper/ieip_wrapper.py +93 -0
  228. multioptpy/Wrapper/md_wrapper.py +92 -0
  229. multioptpy/Wrapper/neb_wrapper.py +94 -0
  230. multioptpy/Wrapper/optimize_wrapper.py +76 -0
  231. multioptpy/__init__.py +5 -0
  232. multioptpy/entrypoints.py +916 -0
  233. multioptpy/fileio.py +660 -0
  234. multioptpy/ieip.py +340 -0
  235. multioptpy/interface.py +1086 -0
  236. multioptpy/irc.py +529 -0
  237. multioptpy/moleculardynamics.py +432 -0
  238. multioptpy/neb.py +1267 -0
  239. multioptpy/optimization.py +1553 -0
  240. multioptpy/optimizer.py +709 -0
  241. multioptpy-1.20.2.dist-info/METADATA +438 -0
  242. multioptpy-1.20.2.dist-info/RECORD +246 -0
  243. multioptpy-1.20.2.dist-info/WHEEL +5 -0
  244. multioptpy-1.20.2.dist-info/entry_points.txt +9 -0
  245. multioptpy-1.20.2.dist-info/licenses/LICENSE +674 -0
  246. multioptpy-1.20.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1239 @@
1
+
2
+ import os
3
+ import shutil
4
+ import numpy as np
5
+ import sys
6
+ import copy
7
+ import traceback
8
+
9
+ # Try importing Matplotlib, but make it optional
10
+ try:
11
+ import matplotlib
12
+ matplotlib.use('Agg') # Use non-interactive backend
13
+ import matplotlib.pyplot as plt
14
+ MATPLOTLIB_AVAILABLE = True
15
+ except ImportError:
16
+ MATPLOTLIB_AVAILABLE = False
17
+ print("Warning: matplotlib not found. Energy profile plots will not be generated.")
18
+ print("Please run 'pip install matplotlib' to enable plotting.")
19
+
20
+ # Use relative imports as this file is inside the 'Wrapper' package
21
+ try:
22
+ from multioptpy.Wrapper.optimize_wrapper import OptimizationJob
23
+ from multioptpy.Wrapper.neb_wrapper import NEBJob
24
+ except ImportError:
25
+ print("Error: Could not import OptimizationJob or NEBJob from relative paths.")
26
+ print("Ensure autots.py and the wrappers are in the same 'Wrapper' directory.")
27
+ sys.exit(1)
28
+
29
+
30
+ class AutoTSWorkflow:
31
+ """
32
+ Manages the 4-step (AFIR -> NEB -> TS -> IRC) automated workflow.
33
+ """
34
+ def __init__(self, config):
35
+ self.config = config
36
+ self.work_dir = config.get("work_dir", "autots_workflow")
37
+ self.initial_mol_file = config.get("initial_mol_file")
38
+ self.conf_file_source = config.get("software_path_file_source")
39
+
40
+ self.top_n_candidates = config.get("top_n_candidates", 3)
41
+ self.skip_step1 = config.get("skip_step1", False)
42
+ self.run_step4 = config.get("run_step4", False)
43
+ self.skip_to_step4 = config.get("skip_to_step4", False)
44
+
45
+ self.input_base_name = os.path.splitext(os.path.basename(self.initial_mol_file))[0]
46
+ # This will be populated by Step 3
47
+ self.ts_final_files = []
48
+
49
+ def setup_workspace(self):
50
+ """Prepares the working directory and copies necessary input files."""
51
+ if os.path.exists(self.work_dir):
52
+ print(f"Warning: Working directory '{self.work_dir}' already exists.")
53
+ os.makedirs(self.work_dir, exist_ok=True)
54
+
55
+ try:
56
+ abs_initial_path = os.path.abspath(self.initial_mol_file)
57
+ local_mol_name = os.path.basename(self.initial_mol_file)
58
+ shutil.copy(abs_initial_path, os.path.join(self.work_dir, local_mol_name))
59
+ self.initial_mol_file = local_mol_name
60
+ except shutil.Error as e:
61
+ print(f"Warning: Could not copy initial file (may be the same file): {e}")
62
+ except FileNotFoundError:
63
+ print(f"Error: Initial molecule file not found: {self.initial_mol_file}")
64
+ raise
65
+
66
+ if not self.conf_file_source or not os.path.exists(self.conf_file_source):
67
+ raise FileNotFoundError(
68
+ f"Software config file not found at: {self.conf_file_source}"
69
+ )
70
+ try:
71
+ local_conf_name = os.path.basename(self.conf_file_source)
72
+ shutil.copy(self.conf_file_source, os.path.join(self.work_dir, local_conf_name))
73
+ print(f"Copied '{self.conf_file_source}' to '{self.work_dir}'")
74
+ except shutil.Error as e:
75
+ print(f"Warning: Could not copy software_path.conf (may be the same file): {e}")
76
+
77
+ os.chdir(self.work_dir)
78
+ print(f"Changed directory to: {os.getcwd()}")
79
+
80
+
81
+ def _run_step1_afir_scan(self):
82
+ """Runs the AFIR scan and copies the resulting trajectory."""
83
+ print("\n--- 1. STARTING STEP 1: AFIR SCAN ---")
84
+ job1_settings = self.config.get("step1_settings", {})
85
+
86
+ if not job1_settings.get("manual_AFIR"):
87
+ raise ValueError("Step 1 settings must contain 'manual_AFIR' (-ma) options.")
88
+
89
+ job = OptimizationJob(input_file=self.initial_mol_file)
90
+ job.set_options(**job1_settings)
91
+ job.run()
92
+
93
+ optimizer_instance = job.get_results()
94
+ if optimizer_instance is None:
95
+ raise RuntimeError("Step 1 failed to produce an optimizer instance.")
96
+
97
+ optimizer_instance.get_result_file_path()
98
+
99
+ source_traj_path = optimizer_instance.traj_file
100
+ if not source_traj_path or not os.path.exists(source_traj_path):
101
+ raise FileNotFoundError(f"Step 1 finished, but 'traj_file' was not found at: {source_traj_path}")
102
+
103
+ local_traj_name = f"{self.input_base_name}_step1_traj.xyz"
104
+ shutil.copy(source_traj_path, local_traj_name)
105
+
106
+ print(f"Copied AFIR trajectory to: {os.path.abspath(local_traj_name)}")
107
+ print("--- STEP 1: AFIR SCAN COMPLETE ---")
108
+ return local_traj_name
109
+
110
+ def _run_step2_neb_optimization(self, afir_traj_path):
111
+ """Runs NEB, filters candidates by energy, and copies the top N."""
112
+ print("\n--- 2. STARTING STEP 2: NEB OPTIMIZATION ---")
113
+
114
+ job = NEBJob(input_files=[afir_traj_path])
115
+ job2_settings = self.config.get("step2_settings", {})
116
+ job.set_options(**job2_settings)
117
+ job.run()
118
+
119
+ neb_instance = job.get_results()
120
+ if neb_instance is None:
121
+ raise RuntimeError("Step 2 failed to produce an NEB instance.")
122
+
123
+ neb_instance.get_result_file()
124
+ source_ts_paths = neb_instance.ts_guess_file_list
125
+
126
+ if not source_ts_paths:
127
+ print("Step 2 (NEB) did not find any TS candidate files (ts_guess_file_list is empty).")
128
+ return []
129
+
130
+ energy_csv_path = os.path.join(neb_instance.config.NEB_FOLDER_DIRECTORY, "energy_plot.csv")
131
+ selected_paths = self._filter_candidates_by_energy(source_ts_paths, energy_csv_path)
132
+
133
+ refinement_dir = f"{self.input_base_name}_step3_TS_Opt_Inputs"
134
+ os.makedirs(refinement_dir, exist_ok=True)
135
+ local_ts_paths = []
136
+
137
+ print(f"Copying {len(selected_paths)} highest energy candidates for refinement...")
138
+ for i, source_path in enumerate(selected_paths):
139
+ if not os.path.exists(source_path):
140
+ print(f"Warning: Source file not found, skipping: {source_path}")
141
+ continue
142
+
143
+ local_guess_name = f"{self.input_base_name}_ts_guess_{i+1}.xyz"
144
+ local_path = os.path.join(refinement_dir, local_guess_name)
145
+ shutil.copy(source_path, local_path)
146
+ print(f" Copied {os.path.basename(source_path)} to {local_path}")
147
+ local_ts_paths.append(local_path)
148
+
149
+ print("--- STEP 2: NEB OPTIMIZATION COMPLETE ---")
150
+ return local_ts_paths
151
+
152
+ def _filter_candidates_by_energy(self, file_paths, energy_csv_path):
153
+ """
154
+ Parses the energy_plot.csv, correlates it with candidate file paths,
155
+ and returns the paths for the Top N highest energy candidates.
156
+ """
157
+ print(f"Filtering {len(file_paths)} candidates down to a max of {self.top_n_candidates} by energy...")
158
+
159
+ try:
160
+ with open(energy_csv_path, 'r') as f:
161
+ lines = f.readlines()
162
+ if not lines:
163
+ raise ValueError(f"{energy_csv_path} is empty.")
164
+ last_line = lines[-1].strip()
165
+ energies = np.array([float(e) for e in last_line.split(',') if e.strip()])
166
+ except Exception as e:
167
+ print(f"Warning: Could not read or parse energy file '{energy_csv_path}': {e}")
168
+ print("Proceeding with all found candidates (unsorted).")
169
+ return file_paths[:self.top_n_candidates]
170
+
171
+ candidates = []
172
+ for path in file_paths:
173
+ try:
174
+ base_name = os.path.splitext(os.path.basename(path))[0]
175
+ index_str = base_name.split('_')[-1]
176
+ z = int(index_str)
177
+ if z >= len(energies):
178
+ print(f"Warning: Index {z} from file '{path}' is out of bounds for energy list (len {len(energies)}).")
179
+ continue
180
+ candidates.append((energies[z], path))
181
+ except Exception as e:
182
+ print(f"Warning: Could not parse index from '{path}': {e}. Skipping.")
183
+
184
+ candidates.sort(key=lambda x: x[0], reverse=True)
185
+ top_n_list = candidates[:self.top_n_candidates]
186
+
187
+ print(f"Identified {len(candidates)} candidates, selecting top {len(top_n_list)}:")
188
+ for (energy, path) in top_n_list:
189
+ print(f" - Path: {os.path.basename(path)}, Energy: {energy:.6f} Hartree")
190
+
191
+ return [path for energy, path in top_n_list]
192
+
193
+ def _run_step3_ts_refinement(self, local_ts_guess_paths):
194
+ """Runs saddle_order=1 OptimizationJob on all local candidates."""
195
+ print("\n--- 3. STARTING STEP 3: TS REFINEMENT ---")
196
+
197
+ if not local_ts_guess_paths:
198
+ print("No TS candidates provided. Skipping refinement.")
199
+ return []
200
+
201
+ job3_settings = self.config.get("step3_settings", {})
202
+ final_ts_files = []
203
+
204
+ for i, guess_file_path in enumerate(local_ts_guess_paths):
205
+ print(f"\n--- Running TS refinement for candidate {i+1}/{len(local_ts_guess_paths)} ({guess_file_path}) ---")
206
+
207
+ job = OptimizationJob(input_file=guess_file_path)
208
+ current_job_settings = job3_settings.copy()
209
+ current_job_settings['saddle_order'] = 1
210
+ job.set_options(**current_job_settings)
211
+ job.run()
212
+
213
+ optimizer_instance = job.get_results()
214
+ if optimizer_instance is None:
215
+ print(f"Warning: Refinement for {guess_file_path} failed.")
216
+ continue
217
+
218
+ optimizer_instance.get_result_file_path()
219
+ source_final_ts_path = optimizer_instance.optimized_struct_file
220
+ if not source_final_ts_path or not os.path.exists(source_final_ts_path):
221
+ print(f"Warning: Refinement for {guess_file_path} finished, but 'optimized_struct_file' was not found.")
222
+ continue
223
+
224
+ if not optimizer_instance.optimized_flag:
225
+ print(f"Warning: Refinement for {guess_file_path} did not converge (optimized_flag=False). Skipping.")
226
+ continue
227
+
228
+ local_final_name = f"{self.input_base_name}_ts_final_{i+1}.xyz"
229
+ shutil.copy(source_final_ts_path, local_final_name)
230
+
231
+ print(f"Copied final TS structure to: {os.path.abspath(local_final_name)}")
232
+ final_ts_files.append(local_final_name)
233
+
234
+ print("--- STEP 3: TS REFINEMENT COMPLETE ---")
235
+ return final_ts_files
236
+
237
+ def _run_step4_irc_and_opt(self, ts_final_files):
238
+ """
239
+ Runs Step 4: IRC calculation, endpoint optimization, and visualization.
240
+ """
241
+ print("\n--- 4. STARTING STEP 4: IRC & EQ OPTIMIZATION ---")
242
+
243
+ if not ts_final_files:
244
+ print("No TS files provided from Step 3 (or --skip_to_step4). Skipping Step 4.")
245
+ return
246
+
247
+ step4_settings = self.config.get("step4_settings", {})
248
+ if "intrinsic_reaction_coordinates" not in step4_settings:
249
+ raise ValueError("Step 4 requires 'intrinsic_reaction_coordinates' settings in config.json")
250
+
251
+ for i, ts_path in enumerate(ts_final_files):
252
+ ts_index = i + 1
253
+ # If skipping, the base name might be long, so create a short ID
254
+ if self.skip_to_step4:
255
+ ts_name_base = f"{self.input_base_name}_TS_{ts_index}"
256
+ else:
257
+ ts_name_base = f"{self.input_base_name}_ts_final_{ts_index}"
258
+
259
+ print(f"\n--- Running Step 4 for TS Candidate {ts_index} ({ts_path}) ---")
260
+
261
+
262
+ step4_settings["saddle_order"] = 1
263
+
264
+ # --- 4A: Run IRC ---
265
+ print(f" 4A: Running IRC for {ts_path}...")
266
+ job_irc = OptimizationJob(input_file=ts_path)
267
+ # Pass the full Step 4 settings (including -irc)
268
+ job_irc.set_options(**step4_settings)
269
+
270
+ job_irc.run()
271
+
272
+ irc_instance = job_irc.get_results()
273
+ if irc_instance is None:
274
+ print(f" Warning: IRC job for {ts_path} failed.")
275
+ continue
276
+
277
+ # Get TS energy (as per Q2, this is set before IRC runs)
278
+ ts_e = irc_instance.final_energy
279
+ ts_bias_e = irc_instance.final_bias_energy
280
+
281
+ # Get IRC endpoint paths
282
+ endpoint_paths = irc_instance.irc_terminal_struct_paths
283
+ if not endpoint_paths or len(endpoint_paths) != 2:
284
+ print(f" Warning: IRC job for {ts_path} did not return 2 endpoint files. Aborting Step 4 for this candidate.")
285
+ continue
286
+
287
+ print(f" IRC found endpoints: {endpoint_paths}")
288
+
289
+ # --- 4B: Run Endpoint Optimization ---
290
+ endpoint_results = []
291
+ for j, end_path in enumerate(endpoint_paths):
292
+
293
+ shutil.copy(end_path, f"{ts_name_base}_IRC_Endpoint_{j+1}.xyz")
294
+ end_path = f"{ts_name_base}_IRC_Endpoint_{j+1}.xyz"
295
+
296
+
297
+ print(f" 4B: Optimizing endpoint {j+1} from {end_path}...")
298
+ job_opt = OptimizationJob(input_file=end_path)
299
+
300
+ # Prepare settings for endpoint optimization
301
+ opt_settings = copy.deepcopy(step4_settings)
302
+
303
+ # Use the new dedicated opt_method for step 4B
304
+ opt_settings["opt_method"] = opt_settings.get("step4b_opt_method", ["rsirfo_block_fsb"])
305
+
306
+ # Remove IRC flag (as per Q3)
307
+ opt_settings.pop("intrinsic_reaction_coordinates", None)
308
+
309
+ # Set saddle_order=0 (minimization)
310
+ opt_settings['saddle_order'] = 0
311
+
312
+ job_opt.set_options(**opt_settings)
313
+ job_opt.run()
314
+
315
+ opt_instance = job_opt.get_results()
316
+ if opt_instance is None:
317
+ print(f" Warning: Optimization for endpoint {end_path} failed.")
318
+ continue
319
+
320
+ opt_instance.get_result_file_path()
321
+ final_opt_path = opt_instance.optimized_struct_file
322
+ if not final_opt_path or not os.path.exists(final_opt_path):
323
+ print(f" Warning: Optimization for {end_path} finished, but 'optimized_struct_file' was not found.")
324
+ continue
325
+
326
+ endpoint_results.append({
327
+ "path": final_opt_path,
328
+ "e": opt_instance.final_energy,
329
+ "bias_e": opt_instance.final_bias_energy,
330
+ "label": f"Endpoint_{j+1}"
331
+ })
332
+
333
+ if not endpoint_results:
334
+ print(f" Warning: Failed to optimize any endpoints for {ts_path}. Aborting result collection.")
335
+ continue
336
+
337
+ if len(endpoint_results) > 2:
338
+ print(f" Warning: More than 2 optimized endpoints found.")
339
+ raise ValueError("More than 2 endpoints found after optimization.")
340
+
341
+ # --- 4C: Collect Results & Visualize ---
342
+ print(f" 4C: Collecting results for TS Candidate {ts_index}...")
343
+ # Prepare result directory
344
+ result_dir = f"{ts_name_base}_Step4_Profile"
345
+ os.makedirs(result_dir, exist_ok=True)
346
+
347
+ e_profile = {
348
+ "TS": {"e": ts_e, "bias_e": ts_bias_e, "path": ts_path},
349
+ }
350
+ if len(endpoint_results) >= 1:
351
+ e_profile["End1"] = endpoint_results[0]
352
+ if len(endpoint_results) >= 2:
353
+ e_profile["End2"] = endpoint_results[1]
354
+
355
+
356
+ # Create plot (as per Q1)
357
+ plot_path = os.path.join(result_dir, "energy_profile.png")
358
+ self._create_energy_profile_plot(e_profile, plot_path, ts_name_base)
359
+
360
+ # Write text file
361
+ text_path = os.path.join(result_dir, "energy_profile.txt")
362
+ self._write_energy_profile_text(e_profile, text_path, ts_name_base)
363
+
364
+ # Copy final XYZ files
365
+ shutil.copy(ts_path, os.path.join(result_dir, f"{ts_name_base}_ts_final.xyz"))
366
+ if "End1" in e_profile:
367
+ shutil.copy(e_profile["End1"]["path"], os.path.join(result_dir, "endpoint_1_opt.xyz"))
368
+ if "End2" in e_profile:
369
+ shutil.copy(e_profile["End2"]["path"], os.path.join(result_dir, "endpoint_2_opt.xyz"))
370
+
371
+ print(f" Successfully saved profile and structures to: {result_dir}")
372
+
373
+ print("\n--- STEP 4: IRC & EQ OPTIMIZATION COMPLETE ---")
374
+
375
+ def _create_energy_profile_plot(self, e_profile, output_path, title_name):
376
+ """Generates an energy profile plot using matplotlib."""
377
+ if not MATPLOTLIB_AVAILABLE:
378
+ print(f" Skipping plot generation: matplotlib not installed.")
379
+ return
380
+
381
+ try:
382
+ labels = []
383
+ energies = []
384
+ biased_energies = []
385
+
386
+
387
+ if "End1" in e_profile:
388
+ labels.append("End1")
389
+ energies.append(e_profile["End1"]["e"])
390
+ biased_energies.append(e_profile["End1"]["bias_e"])
391
+
392
+ labels.append("TS")
393
+ energies.append(e_profile["TS"]["e"])
394
+ biased_energies.append(e_profile["TS"]["bias_e"])
395
+
396
+ if "End2" in e_profile:
397
+ labels.append("End2")
398
+ energies.append(e_profile["End2"]["e"])
399
+ biased_energies.append(e_profile["End2"]["bias_e"])
400
+
401
+
402
+ if not energies:
403
+ print(" Warning: No energies found to plot.")
404
+ return
405
+
406
+ # Convert to relative kcal/mol
407
+ min_e = min(energies)
408
+ min_bias_e = min(biased_energies)
409
+
410
+ rel_energies = (np.array(energies) - min_e) * 627.509
411
+ rel_biased_energies = (np.array(biased_energies) - min_bias_e) * 627.509
412
+
413
+ x = list(range(len(labels)))
414
+ plt.figure(figsize=(8, 6))
415
+
416
+ # Plot both energies as requested in Q1
417
+ plt.plot(x, rel_energies, 'o-', c='blue', label='Energy (E_final)')
418
+ plt.plot(x, rel_biased_energies, 'o--', c='red', label='Bias Energy (E_bias_final)')
419
+
420
+ plt.xticks(x, labels)
421
+ plt.ylabel("Relative Energy (kcal/mol)")
422
+ plt.title(f"Reaction Profile for {title_name}")
423
+ plt.legend()
424
+ plt.grid(axis='y', linestyle='--', alpha=0.7)
425
+ plt.savefig(output_path, dpi=300)
426
+ plt.close()
427
+ print(f" Generated energy plot: {output_path}")
428
+
429
+ except Exception as e:
430
+ print(f" Warning: Failed to generate energy plot: {e}")
431
+
432
+ def _write_energy_profile_text(self, e_profile, output_path, title_name):
433
+ """Writes the final energies to a text file."""
434
+ try:
435
+ with open(output_path, 'w') as f:
436
+ f.write(f"# Energy Profile for {title_name}\n")
437
+ f.write("# All energies in Hartree\n")
438
+ f.write("# -------------------------------------------------------------------\n")
439
+ f.write(f"Structure, File_Path, Final_Energy, Final_Bias_Energy\n")
440
+
441
+
442
+ ts_total = e_profile['TS']['bias_e']
443
+ f.write(f"TS, {e_profile['TS']['path']}, {e_profile['TS']['e']:.12f}, {e_profile['TS']['bias_e']:.12f}\n")
444
+
445
+ end1_total = None
446
+ if "End1" in e_profile:
447
+ end1_total = e_profile['End1']['bias_e']
448
+ f.write(f"Endpoint_1, {e_profile['End1']['path']}, {e_profile['End1']['e']:.12f}, {e_profile['End1']['bias_e']:.12f}\n")
449
+
450
+ end2_total = None
451
+ if "End2" in e_profile:
452
+ end2_total = e_profile['End2']['bias_e']
453
+ f.write(f"Endpoint_2, {e_profile['End2']['path']}, {e_profile['End2']['e']:.12f}, {e_profile['End2']['bias_e']:.12f}\n")
454
+
455
+ f.write("# -------------------------------------------------------------------\n")
456
+ # Calculate and write barriers and reaction energies if possible
457
+ if end1_total is not None:
458
+ barrier_1 = (ts_total - end1_total) * 627.509
459
+ f.write(f"Activation Energy (End1 -> TS): {barrier_1: .2f} kcal/mol\n")
460
+
461
+ if end2_total is not None:
462
+ barrier_2 = (ts_total - end2_total) * 627.509
463
+ f.write(f"Activation Energy (End2 -> TS): {barrier_2: .2f} kcal/mol\n")
464
+
465
+ if end1_total is not None and end2_total is not None:
466
+ reaction_e = (end2_total - end1_total) * 627.509
467
+ f.write(f"Reaction Energy (End1 -> End2): {reaction_e: .2f} kcal/mol\n")
468
+ elif end1_total is not None:
469
+ pass
470
+
471
+
472
+ print(f" Generated energy text file: {output_path}")
473
+ except Exception as e:
474
+ print(f" Warning: Failed to write energy text file: {e}")
475
+
476
+
477
+ def run_workflow(self):
478
+ """Executes the full automated workflow."""
479
+ original_cwd = os.getcwd()
480
+ try:
481
+ if not os.path.exists(self.initial_mol_file):
482
+ raise FileNotFoundError(f"Initial molecule file not found: {self.initial_mol_file}")
483
+
484
+ self.setup_workspace()
485
+
486
+ if self.skip_to_step4:
487
+ # --- Run Step 4 Only ---
488
+ print("\n--- Skipping to Step 4 ---")
489
+ # The input file is the TS file
490
+ self.ts_final_files = [self.initial_mol_file]
491
+ # Ensure base name is from the TS file
492
+ self.input_base_name = os.path.splitext(os.path.basename(self.initial_mol_file))[0]
493
+
494
+ else:
495
+ # --- Run Steps 1-3 ---
496
+ if self.skip_step1:
497
+ print("\n--- 1. STEP 1 (AFIR) SKIPPED ---")
498
+ local_afir_traj = self.initial_mol_file
499
+ else:
500
+ local_afir_traj = self._run_step1_afir_scan()
501
+
502
+ local_ts_paths = self._run_step2_neb_optimization(local_afir_traj)
503
+
504
+ if not local_ts_paths:
505
+ print("Step 2 found 0 candidates. Workflow terminated.")
506
+ print(f"\n --- AUTO-TS WORKFLOW COMPLETED (NO TS FOUND) --- ")
507
+ return
508
+
509
+ self.ts_final_files = self._run_step3_ts_refinement(local_ts_paths)
510
+
511
+ # --- Run Step 4 (if flagged) ---
512
+ if (self.run_step4 or self.skip_to_step4) and self.ts_final_files:
513
+ self._run_step4_irc_and_opt(self.ts_final_files)
514
+
515
+ print(f"\n --- AUTO-TS WORKFLOW COMPLETED SUCCESSFULLY --- ")
516
+ print(f"All results are in: {os.path.realpath(os.getcwd())}")
517
+
518
+ except Exception as e:
519
+ print(f"\n --- AUTO-TS WORKFLOW FAILED --- ")
520
+ print(f"Error: {e}")
521
+
522
+ traceback.print_exc()
523
+ finally:
524
+ os.chdir(original_cwd)
525
+ print(f"Returned to directory: {original_cwd}")
526
+
527
+
528
+ class AutoTSWorkflow_v2(AutoTSWorkflow):
529
+ """
530
+ AutoTSWorkflow (v2) - (FIXED)
531
+
532
+ Manages a dynamic, repeatable, multi-step workflow based on a
533
+ "workflow" block in the configuration file.
534
+
535
+ """
536
+
537
+ def __init__(self, config):
538
+ """
539
+ Initializes the v2 workflow.
540
+ """
541
+ # Call the parent __init__ to load basic settings
542
+ super().__init__(config)
543
+
544
+ # v2-specific attributes
545
+ self.data_cache = {}
546
+ self.workflow_steps = self.config.get("workflow", [])
547
+
548
+ # Validate the workflow configuration immediately
549
+ try:
550
+ self._validate_workflow_config()
551
+ except ValueError as e:
552
+ print(f"Error: Workflow configuration is invalid.")
553
+ print(f"Details: {e}")
554
+ sys.exit(1)
555
+
556
+ def _validate_workflow_config(self):
557
+ """
558
+ Validates the 'workflow' block in config.json.
559
+ """
560
+ print("Validating workflow configuration...")
561
+ if not self.workflow_steps:
562
+ print("Warning: 'workflow' block is empty or missing. No steps will be run.")
563
+ return
564
+
565
+ for i, entry in enumerate(self.workflow_steps):
566
+ if "step" not in entry:
567
+ raise ValueError(f"Workflow entry {i} is missing required key 'step'.")
568
+
569
+ step_name = entry["step"]
570
+ if not hasattr(self, f"_run_{step_name}"):
571
+ raise ValueError(f"Workflow entry {i} specifies invalid step: '{step_name}'. No method '_run_{step_name}' found.")
572
+
573
+ repeat = entry.get("repeat", 1)
574
+ if not isinstance(repeat, int) or repeat < 1:
575
+ raise ValueError(f"Workflow entry {i} ({step_name}): 'repeat' must be a positive integer.")
576
+
577
+ repeat_settings = entry.get("repeat_settings", [])
578
+
579
+ if repeat_settings and len(repeat_settings) > repeat:
580
+ raise ValueError(f"Workflow entry {i} ({step_name}): 'repeat_settings' list (len {len(repeat_settings)}) is longer than 'repeat' value ({repeat}).")
581
+
582
+ base_key = entry.get("settings_key", f"{step_name}_settings")
583
+ if base_key not in self.config:
584
+ raise ValueError(f"Workflow entry {i} ({step_name}): Base settings key '{base_key}' (or default) not found in main config.")
585
+
586
+ print("Workflow validation successful.")
587
+
588
+ def run_workflow(self):
589
+ """
590
+ Overrides the parent 'run_workflow' to call the
591
+ v2 dynamic workflow engine.
592
+ """
593
+ original_cwd = os.getcwd()
594
+ try:
595
+ if not os.path.exists(self.initial_mol_file):
596
+ if not self.config.get("skip_to_step4"):
597
+ raise FileNotFoundError(f"Initial molecule file not found: {self.initial_mol_file}")
598
+
599
+ self.setup_workspace()
600
+ self.run_dynamic_workflow()
601
+
602
+ print(f"\n --- AUTO-TS WORKFLOW (V2) COMPLETED SUCCESSFULLY --- ")
603
+ print(f"All results are in: {os.path.realpath(os.getcwd())}")
604
+
605
+ except Exception as e:
606
+ print(f"\n --- AUTO-TS WORKFLOW (V2) FAILED --- ")
607
+ print(f"Error: {e}")
608
+ traceback.print_exc()
609
+ finally:
610
+ os.chdir(original_cwd)
611
+ print(f"Returned to directory: {original_cwd}")
612
+
613
+ def _get_settings_for_repeat(self, wf_entry, repeat_index):
614
+ """
615
+ Gets the correct settings dictionary for a specific repeat.
616
+ """
617
+ step_name = wf_entry["step"]
618
+ repeat_settings = wf_entry.get("repeat_settings", [])
619
+
620
+ base_settings_key = wf_entry.get("settings_key", f"{step_name}_settings")
621
+
622
+ if base_settings_key not in self.config:
623
+ raise ValueError(f"Failed to find base settings key '{base_settings_key}' in config for {step_name}, repeat {repeat_index+1}.")
624
+
625
+ param_override = {}
626
+ r_setting = None
627
+
628
+ if repeat_index < len(repeat_settings):
629
+ r_setting = repeat_settings[repeat_index]
630
+ elif repeat_settings:
631
+ r_setting = repeat_settings[-1]
632
+ if repeat_index == len(repeat_settings):
633
+ print(f" Info: 'repeat_settings' list (len {len(repeat_settings)}) is shorter than 'repeat' for {step_name}. Re-using last entry for repeat {repeat_index+1} and beyond.")
634
+
635
+ if r_setting:
636
+ param_override = r_setting.get("param_override", {})
637
+
638
+ final_settings = copy.deepcopy(self.config[base_settings_key])
639
+ final_settings.update(param_override)
640
+
641
+ return final_settings
642
+
643
+ def run_dynamic_workflow(self):
644
+ """
645
+ Executes the dynamic workflow defined in config['workflow'].
646
+ """
647
+ print("\n--- 🚀 STARTING DYNAMIC WORKFLOW (V2) ---")
648
+
649
+ for entry in self.workflow_steps:
650
+ self.data_cache[entry["step"]] = {"runs": []}
651
+
652
+ for wf_entry in self.workflow_steps:
653
+ if not wf_entry.get("enabled", True):
654
+ print(f"\n--- SKIPPING STEP: {wf_entry['step']} (disabled) ---")
655
+ continue
656
+
657
+
658
+ step_name = wf_entry["step"]
659
+ method = getattr(self, f"_run_{step_name}")
660
+ repeat = wf_entry.get("repeat", 1)
661
+
662
+ if step_name == "step4" and self.run_step4 is not True:
663
+ print(f"\n--- SKIPPING STEP: {step_name} (run_step4 flag not set) ---")
664
+ continue
665
+ if step_name == "step1" and self.skip_step1 is True:
666
+ print(f"\n--- SKIPPING STEP: {step_name} (skip_step1 flag set) ---")
667
+ continue
668
+
669
+ if step_name != "step4" and self.skip_to_step4 is True:
670
+ print(f"\n--- SKIPPING STEP: {step_name} (skip_to_step4 flag set) ---")
671
+ continue
672
+
673
+
674
+ print(f"\n--- 🏁 EXECUTING STEP: {step_name} (Repeat={repeat}) ---")
675
+
676
+ for i in range(repeat):
677
+ print(f" --- {step_name} | Run {i+1}/{repeat} ---")
678
+
679
+ try:
680
+ settings = self._get_settings_for_repeat(wf_entry, i)
681
+ input_data = self._determine_input_for_run(step_name, i, wf_entry)
682
+ result = method(settings, input_data, run_index=i)
683
+ self.data_cache[step_name]["runs"].append(result)
684
+ print(f" --- {step_name} | Run {i+1}/{repeat} COMPLETED ---")
685
+
686
+ except Exception as e:
687
+ print(f" --- ❌ {step_name} | Run {i+1}/{repeat} FAILED ---")
688
+ print(f" Error: {e}")
689
+ traceback.print_exc()
690
+ print(f" Aborting remaining repeats for {step_name}.")
691
+ break
692
+
693
+ if "runs" in self.data_cache[step_name] and self.data_cache[step_name]["runs"]:
694
+ self._run_post_processing(step_name, wf_entry)
695
+
696
+ print(f"--- ✅ STEP: {step_name} COMPLETE ---")
697
+
698
+ def _determine_input_for_run(self, step_name, run_index, wf_entry):
699
+ """
700
+ Determines the input for a specific run based on data dependency logic.
701
+ (FIXED: Now uses relative paths and explicit copy for step2 sequential)
702
+ """
703
+ previous_runs_this_step = self.data_cache[step_name]["runs"]
704
+
705
+ if step_name == "step1":
706
+ if run_index == 0:
707
+ print(f" Info: '{step_name}' run 1 using initial_mol_file.")
708
+ return {"input_file": self.initial_mol_file}
709
+ else:
710
+ if not previous_runs_this_step:
711
+ raise RuntimeError(f"Step 1, run {run_index+1}: Cannot start, previous run (0) failed or produced no output.")
712
+ prev_result = previous_runs_this_step[-1]
713
+ if "final_struct_file" not in prev_result:
714
+ raise RuntimeError(f"Step 1, run {run_index+1}: Previous run did not produce a 'final_struct_file'.")
715
+ print(f" Using previous run's output: {prev_result['final_struct_file']}")
716
+ # This path is already relative (fixed in _run_step1)
717
+ return {"input_file": prev_result['final_struct_file']}
718
+
719
+ elif step_name == "step2":
720
+ mode = wf_entry.get("mode", "sequential")
721
+
722
+ if "step1" not in self.data_cache or "combined_path" not in self.data_cache["step1"]:
723
+ if run_index == 0 or mode == "independent":
724
+ raise RuntimeError(f"Step 2 ({mode}): data_cache[\"step1\"][\"combined_path\"] not found. Did Step 1 run and post-process?")
725
+
726
+ if mode == "sequential":
727
+ if run_index == 0:
728
+ # --- FIX: Explicit copy for Run 1 ---
729
+ source_path = self.data_cache["step1"]["combined_path"]
730
+ new_input_path = f"{self.input_base_name}_step2_run1_init_path.xyz"
731
+ shutil.copy(source_path, new_input_path)
732
+ print(f" Copied '{source_path}' to '{new_input_path}' for Run 1.")
733
+ return {"input_files": [new_input_path]}
734
+ else:
735
+ # --- FIX: Explicit copy for Run 2 and beyond ---
736
+ if not previous_runs_this_step:
737
+ raise RuntimeError(f"Step 2 (sequential), run {run_index+1}: Cannot start, previous run (0) failed.")
738
+ prev_result = previous_runs_this_step[-1]
739
+
740
+ if "final_relaxed_path" not in prev_result or not prev_result["final_relaxed_path"]:
741
+ raise RuntimeError(f"Step 2 (sequential), run {run_index+1}: Previous run produced no 'final_relaxed_path' to refine.")
742
+
743
+ source_path = prev_result["final_relaxed_path"]
744
+ new_input_path = f"{self.input_base_name}_step2_run{run_index+1}_init_path.xyz"
745
+ shutil.copy(source_path, new_input_path)
746
+ print(f" Copied '{source_path}' to '{new_input_path}' for Run {run_index+1}.")
747
+ return {"input_files": [new_input_path]}
748
+
749
+ elif mode == "independent":
750
+ # Independent mode still uses the combined path directly
751
+ return {"input_files": [self.data_cache["step1"]["combined_path"]]}
752
+ else:
753
+ raise ValueError(f"Step 2: Unknown mode '{mode}'. Use 'sequential' or 'independent'.")
754
+
755
+ elif step_name == "step3":
756
+ if "step2" not in self.data_cache or "candidates" not in self.data_cache["step2"]:
757
+ raise RuntimeError("Step 3: data_cache[\"step2\"][\"candidates\"] not found. Did Step 2 run and post-process?")
758
+ return {"input_files": self.data_cache["step2"]["candidates"]}
759
+
760
+ elif step_name == "step4":
761
+ if self.skip_to_step4:
762
+ return {"input_files": [self.initial_mol_file]}
763
+
764
+ if "step3" not in self.data_cache or "ts_final" not in self.data_cache["step3"]:
765
+ raise RuntimeError("Step 4: data_cache[\"step3\"][\"ts_final\"] not found. Did Step 3 run and post-process?")
766
+
767
+ return {"input_files": self.data_cache["step3"]["ts_final"]}
768
+ else:
769
+ print(f"Warning: Input determination logic not implemented for {step_name}. Using 'initial_mol_file'.")
770
+ return {"input_file": self.initial_mol_file}
771
+
772
+ def _run_post_processing(self, step_name, wf_entry):
773
+ """
774
+ Runs the post-processing logic (merge, select, consolidate).
775
+ """
776
+ print(f" Post-processing results for {step_name}...")
777
+ runs_list = self.data_cache[step_name]["runs"]
778
+ mode = wf_entry.get("mode", "sequential")
779
+ if not runs_list:
780
+ print(f" No successful runs found for {step_name}. Skipping post-processing.")
781
+ return
782
+
783
+ if step_name == "step1":
784
+ traj_files = [run["traj_file"] for run in runs_list if "traj_file" in run]
785
+ if not traj_files:
786
+ print(" Step 1: No traj_files found in any run. Cannot create combined_path.")
787
+ return
788
+ combined_path = self.merge_paths(traj_files)
789
+ self.data_cache[step_name]["combined_path"] = combined_path
790
+ print(f" Step 1: Created combined path: {combined_path}")
791
+
792
+ elif step_name == "step2":
793
+ if mode == "sequential":
794
+ print(" Step 2 (Sequential mode): Using candidates from the *last* run only for Step 3.")
795
+ if not runs_list:
796
+ print(" Step 2 (Sequential mode): No runs found, candidates list is empty.")
797
+ all_candidates_flat = []
798
+ energy_csvs = []
799
+ else:
800
+ last_run_result = runs_list[-1]
801
+ all_candidates_flat = last_run_result.get("candidates", [])
802
+ energy_csvs = [last_run_result["energy_csv_path"]] if last_run_result.get("energy_csv_path") else []
803
+
804
+ else:# independent
805
+ all_candidates_flat = [
806
+ path for run in runs_list for path in run.get("candidates", [])
807
+ ]
808
+ energy_csvs = [
809
+ run["energy_csv_path"] for run in runs_list if run.get("energy_csv_path")
810
+ ]
811
+
812
+ if not all_candidates_flat:
813
+ print(" Step 2: No candidates found in any run. Final list is empty.")
814
+ self.data_cache[step_name]["candidates"] = []
815
+ return
816
+
817
+ top_n = self.config.get("top_n_candidates", 3)
818
+ final_candidates = self.select_candidates(all_candidates_flat, energy_csvs, top_n)
819
+
820
+ refinement_dir = f"{self.input_base_name}_step3_TS_Opt_Inputs"
821
+ os.makedirs(refinement_dir, exist_ok=True)
822
+ copied_candidates = []
823
+ for i, source_path in enumerate(final_candidates):
824
+ local_guess_name = f"{self.input_base_name}_ts_guess_{i+1}.xyz"
825
+ local_path = os.path.join(refinement_dir, local_guess_name)
826
+ shutil.copy(source_path, local_path)
827
+ copied_candidates.append(local_path)
828
+
829
+ self.data_cache[step_name]["candidates"] = copied_candidates
830
+ print(f" Step 2: Selected top {len(copied_candidates)} candidates -> {refinement_dir}")
831
+
832
+ elif step_name == "step3":
833
+ final_ts_list = self.consolidate_ts(runs_list)
834
+ self.ts_final_files = final_ts_list
835
+ self.data_cache[step_name]["ts_final"] = final_ts_list
836
+ print(f" Step 3: Consolidated results into {len(final_ts_list)} final TS files.")
837
+
838
+ elif step_name == "step4":
839
+ print(f" Step 4: Post-processing complete (results saved by each run).")
840
+
841
+ # --- 3. Helper Functions (Consolidation Logic) ---
842
+
843
+ def merge_paths(self, traj_files):
844
+ """
845
+ Merges results from Step 1 (sequential) runs.
846
+
847
+ MODIFIED: This now concatenates all trajectory files from the
848
+ sequential runs (run1_traj.xyz + run2_traj.xyz + ...) into a
849
+ single combined trajectory file.
850
+ """
851
+ if not traj_files:
852
+ raise ValueError("merge_paths called with no trajectory files.")
853
+
854
+ merged_path = f"{self.input_base_name}_step1_combined_traj.xyz"
855
+
856
+ print(f" (merge_paths): Concatenating {len(traj_files)} trajectory files into '{merged_path}'...")
857
+
858
+ try:
859
+ with open(merged_path, 'wb') as outfile:
860
+ for i, traj_file in enumerate(traj_files):
861
+ if not os.path.exists(traj_file):
862
+ print(f" Warning: Trajectory file not found, skipping: {traj_file}")
863
+ continue
864
+
865
+ print(f" -> Appending file {i+1}/{len(traj_files)}: {traj_file}")
866
+ with open(traj_file, 'rb') as infile:
867
+ shutil.copyfileobj(infile, outfile)
868
+
869
+ except IOError as e:
870
+ print(f" Error during trajectory concatenation: {e}")
871
+ raise RuntimeError(f"Failed to merge trajectory files into {merged_path}")
872
+
873
+ print(f" (merge_paths): Concatenation complete.")
874
+ # --- FIX: Return relative path ---
875
+ return merged_path
876
+
877
+ def select_candidates(self, all_candidates_flat, energy_csvs, top_n):
878
+ """
879
+ Selects the 'top_n' best candidates from all Step 2 runs.
880
+ """
881
+ print(f" (select_candidates): Filtering {len(all_candidates_flat)} total candidates down to {top_n}.")
882
+
883
+ if not energy_csvs:
884
+ print(f" Warning: No energy_plot.csv files found. Returning first {top_n} candidates (unsorted).")
885
+ return all_candidates_flat[:top_n]
886
+
887
+ best_csv_path = None
888
+ max_energies = -1
889
+ all_energies = []
890
+
891
+ for csv_path in energy_csvs:
892
+ try:
893
+ with open(csv_path, 'r') as f:
894
+ lines = f.readlines()
895
+ if not lines: continue
896
+ last_line = lines[-1].strip()
897
+ energies = np.array([float(e) for e in last_line.split(',') if e.strip()])
898
+ if len(energies) > max_energies:
899
+ max_energies = len(energies)
900
+ all_energies = energies
901
+ best_csv_path = csv_path
902
+ except Exception:
903
+ continue
904
+
905
+ if best_csv_path is None:
906
+ print(" Warning: Failed to parse any energy_plot.csv file. Returning unsorted candidates.")
907
+ return all_candidates_flat[:top_n]
908
+
909
+ print(f" Using energy reference: {best_csv_path} (found {max_energies} energies)")
910
+
911
+ candidates_with_energy = []
912
+ for path in all_candidates_flat:
913
+ try:
914
+ base_name = os.path.splitext(os.path.basename(path))[0]
915
+ index_str = base_name.split('_')[-1]
916
+ z = int(index_str) # 1-based
917
+ z_idx = z - 1 # 0-based index
918
+
919
+ if z_idx < 0 or z_idx >= len(all_energies):
920
+ print(f" Warning: Index {z} from '{path}' out of bounds for energy list (len {len(all_energies)}). Skipping.")
921
+ continue
922
+ candidates_with_energy.append((all_energies[z_idx], path))
923
+ except Exception as e:
924
+ print(f" Warning: Could not parse index from '{path}': {e}. Skipping.")
925
+
926
+ candidates_with_energy.sort(key=lambda x: x[0], reverse=True)
927
+ top_n_list = candidates_with_energy[:top_n]
928
+
929
+ selected_paths = [path for energy, path in top_n_list]
930
+ for energy, path in top_n_list:
931
+ print(f" - Selected: {os.path.basename(path)} (Energy: {energy:.6f} Hartree)")
932
+
933
+ return selected_paths
934
+
935
+ def consolidate_ts(self, runs_list):
936
+ """
937
+ Consolidates results from multiple Step 3 runs.
938
+ Adopts the results from the *last* run.
939
+ """
940
+ if not runs_list:
941
+ return []
942
+
943
+ last_run_results = runs_list[-1]
944
+ final_files = last_run_results.get("optimized_ts_files", [])
945
+
946
+ print(f" (consolidate_ts): Adopting {len(final_files)} TS files from the *last* Step 3 run.")
947
+
948
+ return final_files
949
+
950
+ # --- 4. V2 Adapter Methods for _run_stepX ---
951
+
952
+ def _run_step1(self, settings, input_data, run_index=0):
953
+ """
954
+ Runs a *single* Step 1 (AFIR) scan.
955
+ (FIXED: Sets WORK_DIR in settings before set_options)
956
+ """
957
+ input_file = input_data["input_file"]
958
+ print(f" Running Step 1 (AFIR) on: {input_file}")
959
+
960
+ if "manual_AFIR" not in settings:
961
+ raise ValueError(f"Step 1 settings (run {run_index+1}) must contain 'manual_AFIR'.")
962
+
963
+ job = OptimizationJob(input_file=input_file)
964
+
965
+ # **FIX**: Modify settings dict to create a unique WORK_DIR
966
+ base_work_dir = settings.get("WORK_DIR", ".")
967
+ settings["WORK_DIR"] = os.path.join(base_work_dir, f"step1_run_{run_index+1}")
968
+
969
+ job.set_options(**settings)
970
+ job.run()
971
+
972
+ optimizer_instance = job.get_results()
973
+ if optimizer_instance is None:
974
+ raise RuntimeError(f"Step 1 (run {run_index+1}) failed to produce an optimizer instance.")
975
+
976
+ optimizer_instance.get_result_file_path()
977
+ source_traj_path = optimizer_instance.traj_file
978
+ source_final_struct = optimizer_instance.optimized_struct_file
979
+
980
+ if not source_traj_path or not os.path.exists(source_traj_path):
981
+ raise FileNotFoundError(f"Step 1 (run {run_index+1}) 'traj_file' not found at '{source_traj_path}'")
982
+ if not source_final_struct or not os.path.exists(source_final_struct):
983
+ raise FileNotFoundError(f"Step 1 (run {run_index+1}) 'optimized_struct_file' not found at '{source_final_struct}'")
984
+
985
+ local_traj_name = f"{self.input_base_name}_step1_run{run_index+1}_traj.xyz"
986
+ shutil.copy(source_traj_path, local_traj_name)
987
+ local_final_name = f"{self.input_base_name}_step1_run{run_index+1}_final.xyz"
988
+ shutil.copy(source_final_struct, local_final_name)
989
+
990
+ print(f" Step 1 (run {run_index+1}) results saved.")
991
+ # --- FIX: Return relative paths ---
992
+ return {
993
+ "traj_file": local_traj_name,
994
+ "final_struct_file": local_final_name
995
+ }
996
+
997
+ def _run_step2(self, settings, input_data, run_index=0):
998
+ """
999
+ Runs a *single* Step 2 (NEB) optimization.
1000
+ (FIXED: Sets WORK_DIR in settings)
1001
+ (FIXED: Returns 'last_itr_traj_file_path' as 'final_relaxed_path')
1002
+ """
1003
+ input_files = input_data["input_files"]
1004
+ if not input_files:
1005
+ raise ValueError(f"Step 2 (run {run_index+1}) received no input files.")
1006
+ print(f" Running Step 2 (NEB) on {len(input_files)} input file(s).")
1007
+
1008
+ job = NEBJob(input_files=input_files)
1009
+
1010
+ # **FIX**: Modify settings dict to create a unique WORK_DIR
1011
+ base_work_dir = settings.get("WORK_DIR", ".")
1012
+ settings["WORK_DIR"] = os.path.join(base_work_dir, f"step2_run_{run_index+1}")
1013
+
1014
+ job.set_options(**settings)
1015
+ job.run()
1016
+
1017
+ neb_instance = job.get_results()
1018
+ if neb_instance is None:
1019
+ raise RuntimeError(f"Step 2 (run {run_index+1}) failed to produce an NEB instance.")
1020
+
1021
+ neb_instance.get_result_file()
1022
+ source_ts_paths = neb_instance.ts_guess_file_list
1023
+ energy_csv_path = os.path.join(neb_instance.config.NEB_FOLDER_DIRECTORY, "energy_plot.csv")
1024
+
1025
+ # **FIX**: Get the final relaxed path based on user's attribute name
1026
+ final_relaxed_path = getattr(neb_instance, 'last_itr_traj_file_path', None)
1027
+ local_final_path = None # Initialize
1028
+
1029
+ if not final_relaxed_path or not os.path.exists(final_relaxed_path):
1030
+ print(f" Warning: 'last_itr_traj_file_path' not found or invalid: '{final_relaxed_path}'. Sequential refinement may fail.")
1031
+ final_relaxed_path = None
1032
+ else:
1033
+ local_final_path_name = f"{self.input_base_name}_step2_run{run_index+1}_final_path.xyz"
1034
+ shutil.copy(final_relaxed_path, local_final_path_name)
1035
+ # --- FIX: Store relative path ---
1036
+ local_final_path = local_final_path_name
1037
+
1038
+
1039
+ if not source_ts_paths:
1040
+ print(f" Step 2 (run {run_index+1}) did not find any TS candidates.")
1041
+ return {
1042
+ "candidates": [],
1043
+ "energy_csv_path": None,
1044
+ "final_relaxed_path": local_final_path
1045
+ }
1046
+
1047
+ candidate_dir = f"{self.input_base_name}_step2_run{run_index+1}_candidates"
1048
+ os.makedirs(candidate_dir, exist_ok=True)
1049
+ local_ts_paths = []
1050
+
1051
+ for i, source_path in enumerate(source_ts_paths):
1052
+ if not os.path.exists(source_path):
1053
+ print(f" Warning: Source file not found, skipping: {source_path}")
1054
+ continue
1055
+ local_guess_name = f"{self.input_base_name}_s2_run{run_index+1}_guess_{i+1}.xyz"
1056
+ local_path = os.path.join(candidate_dir, local_guess_name)
1057
+ shutil.copy(source_path, local_path)
1058
+ # --- FIX: Store relative path ---
1059
+ local_ts_paths.append(local_path)
1060
+
1061
+ print(f" Step 2 (run {run_index+1}) found {len(local_ts_paths)} candidates.")
1062
+ return {
1063
+ "candidates": local_ts_paths,
1064
+ # --- FIX: Store relative path ---
1065
+ "energy_csv_path": energy_csv_path if os.path.exists(energy_csv_path) else None,
1066
+ "final_relaxed_path": local_final_path
1067
+ }
1068
+
1069
+ def _run_step3(self, settings, input_data, run_index=0):
1070
+ """
1071
+ Runs a *single* Step 3 (TS Refinement) pass.
1072
+ (FIXED: Sets WORK_DIR in settings)
1073
+ """
1074
+ candidate_files = input_data["input_files"]
1075
+ print(f" Running Step 3 (TS Refine) on {len(candidate_files)} candidates (Run {run_index+1}).")
1076
+
1077
+ if not candidate_files:
1078
+ print(" No candidates provided. Skipping refinement.")
1079
+ return {"optimized_ts_files": [], "energies": {}}
1080
+
1081
+ final_ts_files = []
1082
+ final_ts_energies = {}
1083
+ settings['saddle_order'] = 1
1084
+ base_work_dir = settings.get("WORK_DIR", ".") # Get base dir once
1085
+
1086
+ for i, guess_file_path in enumerate(candidate_files):
1087
+ print(f" Refining candidate {i+1}/{len(candidate_files)} ({os.path.basename(guess_file_path)})")
1088
+
1089
+ job = OptimizationJob(input_file=guess_file_path)
1090
+
1091
+ # **FIX**: Modify settings dict for this specific candidate
1092
+ current_settings = copy.deepcopy(settings)
1093
+ cand_basename = os.path.splitext(os.path.basename(guess_file_path))[0]
1094
+ current_settings["WORK_DIR"] = os.path.join(base_work_dir, f"step3_run_{run_index+1}", cand_basename)
1095
+
1096
+ job.set_options(**current_settings)
1097
+ job.run()
1098
+
1099
+ optimizer_instance = job.get_results()
1100
+ if optimizer_instance is None:
1101
+ print(f" Warning: Refinement for {guess_file_path} failed.")
1102
+ continue
1103
+
1104
+ optimizer_instance.get_result_file_path()
1105
+ source_final_ts_path = optimizer_instance.optimized_struct_file
1106
+ if not source_final_ts_path or not os.path.exists(source_final_ts_path):
1107
+ print(f" Warning: Refinement for {guess_file_path} finished, but 'optimized_struct_file' was not found.")
1108
+ continue
1109
+
1110
+ if not optimizer_instance.optimized_flag:
1111
+ print(f"Warning: Refinement for {guess_file_path} did not converge (optimized_flag=False). Skipping.")
1112
+ continue
1113
+
1114
+ local_final_name = f"{self.input_base_name}_s3_run{run_index+1}_ts_final_{i+1}.xyz"
1115
+ shutil.copy(source_final_ts_path, local_final_name)
1116
+
1117
+ # --- FIX: Store relative path ---
1118
+ rel_path = local_final_name
1119
+ final_ts_files.append(rel_path)
1120
+ final_ts_energies[rel_path] = optimizer_instance.final_energy
1121
+
1122
+ print(f" Step 3 (run {run_index+1}) successfully refined {len(final_ts_files)} structures.")
1123
+ return {
1124
+ "optimized_ts_files": final_ts_files,
1125
+ "energies": final_ts_energies
1126
+ }
1127
+
1128
+ def _run_step4(self, settings, input_data, run_index=0):
1129
+ """
1130
+ Runs a *single* Step 4 (IRC + Opt) pass.
1131
+ (FIXED: Sets WORK_DIR in settings)
1132
+ """
1133
+ ts_final_files = input_data["input_files"]
1134
+ print(f" Running Step 4 (IRC) on {len(ts_final_files)} TS files (Run {run_index+1}).")
1135
+
1136
+ if not ts_final_files:
1137
+ print(" No TS files provided. Skipping Step 4.")
1138
+ return {"profile_dirs": []}
1139
+
1140
+ if "intrinsic_reaction_coordinates" not in settings:
1141
+ raise ValueError(f"Step 4 (run {run_index+1}) requires 'intrinsic_reaction_coordinates' settings.")
1142
+
1143
+ profile_dirs = []
1144
+ base_work_dir = settings.get("WORK_DIR", ".")
1145
+
1146
+ for i, ts_path in enumerate(ts_final_files):
1147
+ ts_name_base = f"{self.input_base_name}_s4_run{run_index+1}_TS_{i+1}"
1148
+ print(f" Running Step 4 for TS {i+1}/{len(ts_final_files)} ({ts_path})")
1149
+
1150
+ # --- 4A: Run IRC ---
1151
+
1152
+
1153
+ relative_ts_path = os.path.relpath(ts_path)
1154
+ job_irc = OptimizationJob(input_file=relative_ts_path)
1155
+
1156
+ irc_settings = copy.deepcopy(settings)
1157
+ irc_settings["saddle_order"] = 1
1158
+ # **FIX**: Set WORK_DIR in settings
1159
+ irc_settings["WORK_DIR"] = os.path.join(base_work_dir, f"step4_run_{run_index+1}", f"ts_{i+1}_irc")
1160
+ job_irc.set_options(**irc_settings)
1161
+ job_irc.run()
1162
+
1163
+ irc_instance = job_irc.get_results()
1164
+ if irc_instance is None:
1165
+ print(f" Warning: IRC job for {ts_path} failed.")
1166
+ continue
1167
+
1168
+ ts_e = irc_instance.final_energy
1169
+ ts_bias_e = irc_instance.final_bias_energy
1170
+ endpoint_paths = irc_instance.irc_terminal_struct_paths
1171
+
1172
+ if not endpoint_paths or len(endpoint_paths) != 2:
1173
+ print(f" Warning: IRC job for {ts_path} did not return 2 endpoint files.")
1174
+ continue
1175
+
1176
+ # --- 4B: Run Endpoint Optimization ---
1177
+ endpoint_results = []
1178
+ for j, end_path in enumerate(endpoint_paths):
1179
+ opt_settings = copy.deepcopy(settings)
1180
+ opt_settings["opt_method"] = opt_settings.get("opt_method", ["rsirfo_block_fsb"])
1181
+ opt_settings.pop("intrinsic_reaction_coordinates", None)
1182
+ opt_settings['saddle_order'] = 0 # Minimization
1183
+
1184
+
1185
+ relative_end_path = os.path.relpath(end_path)
1186
+ base_end_path = os.path.basename(relative_end_path)
1187
+ shutil.copy(relative_end_path, base_end_path)
1188
+ job_opt = OptimizationJob(input_file=base_end_path)
1189
+
1190
+ # **FIX**: Set WORK_DIR in settings
1191
+ opt_settings["WORK_DIR"] = os.path.join(base_work_dir, f"step4_run_{run_index+1}", f"ts_{i+1}_endpt_{j+1}")
1192
+ job_opt.set_options(**opt_settings)
1193
+ job_opt.run()
1194
+
1195
+ opt_instance = job_opt.get_results()
1196
+ if opt_instance is None: continue
1197
+
1198
+ opt_instance.get_result_file_path()
1199
+ final_opt_path = opt_instance.optimized_struct_file
1200
+ if not final_opt_path or not os.path.exists(final_opt_path): continue
1201
+
1202
+ endpoint_results.append({
1203
+ "path": final_opt_path,
1204
+ "e": opt_instance.final_energy,
1205
+ "bias_e": opt_instance.final_bias_energy,
1206
+ })
1207
+
1208
+ if not endpoint_results:
1209
+ print(f" Warning: Failed to optimize any endpoints for {ts_path}.")
1210
+ continue
1211
+
1212
+ # --- 4C: Collect Results & Visualize (using parent methods) ---
1213
+ result_dir = f"{ts_name_base}_Step4_Profile"
1214
+ os.makedirs(result_dir, exist_ok=True)
1215
+
1216
+ e_profile = {"TS": {"e": ts_e, "bias_e": ts_bias_e, "path": ts_path}}
1217
+ if len(endpoint_results) >= 1: e_profile["End1"] = endpoint_results[0]
1218
+ if len(endpoint_results) >= 2: e_profile["End2"] = endpoint_results[1]
1219
+
1220
+ plot_path = os.path.join(result_dir, "energy_profile.png")
1221
+ self._create_energy_profile_plot(e_profile, plot_path, ts_name_base)
1222
+
1223
+ text_path = os.path.join(result_dir, "energy_profile.txt")
1224
+ self._write_energy_profile_text(e_profile, text_path, ts_name_base)
1225
+
1226
+
1227
+ shutil.copy(os.path.relpath(ts_path), os.path.join(result_dir, f"{ts_name_base}_ts_final.xyz"))
1228
+ if "End1" in e_profile:
1229
+ shutil.copy(os.path.relpath(e_profile["End1"]["path"]), os.path.join(result_dir, "endpoint_1_opt.xyz"))
1230
+ if "End2" in e_profile:
1231
+ shutil.copy(os.path.relpath(e_profile["End2"]["path"]), os.path.join(result_dir, "endpoint_2_opt.xyz"))
1232
+
1233
+ print(f" Successfully saved profile to: {result_dir}")
1234
+ # --- FIX: Store relative path ---
1235
+ profile_dirs.append(result_dir)
1236
+
1237
+ return {"profile_dirs": profile_dirs}
1238
+
1239
+