mlmm-toolkit 0.2.2.dev0__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 (372) hide show
  1. hessian_ff/__init__.py +50 -0
  2. hessian_ff/analytical_hessian.py +609 -0
  3. hessian_ff/constants.py +46 -0
  4. hessian_ff/forcefield.py +339 -0
  5. hessian_ff/loaders.py +608 -0
  6. hessian_ff/native/Makefile +8 -0
  7. hessian_ff/native/__init__.py +28 -0
  8. hessian_ff/native/analytical_hessian.py +88 -0
  9. hessian_ff/native/analytical_hessian_ext.cpp +258 -0
  10. hessian_ff/native/bonded.py +82 -0
  11. hessian_ff/native/bonded_ext.cpp +640 -0
  12. hessian_ff/native/loader.py +349 -0
  13. hessian_ff/native/nonbonded.py +118 -0
  14. hessian_ff/native/nonbonded_ext.cpp +1150 -0
  15. hessian_ff/prmtop_parmed.py +23 -0
  16. hessian_ff/system.py +107 -0
  17. hessian_ff/terms/__init__.py +14 -0
  18. hessian_ff/terms/angle.py +73 -0
  19. hessian_ff/terms/bond.py +44 -0
  20. hessian_ff/terms/cmap.py +406 -0
  21. hessian_ff/terms/dihedral.py +141 -0
  22. hessian_ff/terms/nonbonded.py +209 -0
  23. hessian_ff/tests/__init__.py +0 -0
  24. hessian_ff/tests/conftest.py +75 -0
  25. hessian_ff/tests/data/small/complex.parm7 +1346 -0
  26. hessian_ff/tests/data/small/complex.pdb +125 -0
  27. hessian_ff/tests/data/small/complex.rst7 +63 -0
  28. hessian_ff/tests/test_coords_input.py +44 -0
  29. hessian_ff/tests/test_energy_force.py +49 -0
  30. hessian_ff/tests/test_hessian.py +137 -0
  31. hessian_ff/tests/test_smoke.py +18 -0
  32. hessian_ff/tests/test_validation.py +40 -0
  33. hessian_ff/workflows.py +889 -0
  34. mlmm/__init__.py +36 -0
  35. mlmm/__main__.py +7 -0
  36. mlmm/_version.py +34 -0
  37. mlmm/add_elem_info.py +374 -0
  38. mlmm/advanced_help.py +91 -0
  39. mlmm/align_freeze_atoms.py +601 -0
  40. mlmm/all.py +3535 -0
  41. mlmm/bond_changes.py +231 -0
  42. mlmm/bool_compat.py +223 -0
  43. mlmm/cli.py +574 -0
  44. mlmm/cli_utils.py +166 -0
  45. mlmm/default_group.py +337 -0
  46. mlmm/defaults.py +467 -0
  47. mlmm/define_layer.py +526 -0
  48. mlmm/dft.py +1041 -0
  49. mlmm/energy_diagram.py +253 -0
  50. mlmm/extract.py +2213 -0
  51. mlmm/fix_altloc.py +464 -0
  52. mlmm/freq.py +1406 -0
  53. mlmm/harmonic_constraints.py +140 -0
  54. mlmm/hessian_cache.py +44 -0
  55. mlmm/hessian_calc.py +174 -0
  56. mlmm/irc.py +638 -0
  57. mlmm/mlmm_calc.py +2262 -0
  58. mlmm/mm_parm.py +945 -0
  59. mlmm/oniom_export.py +1983 -0
  60. mlmm/oniom_import.py +457 -0
  61. mlmm/opt.py +1742 -0
  62. mlmm/path_opt.py +1353 -0
  63. mlmm/path_search.py +2299 -0
  64. mlmm/preflight.py +88 -0
  65. mlmm/py.typed +1 -0
  66. mlmm/pysis_runner.py +45 -0
  67. mlmm/scan.py +1047 -0
  68. mlmm/scan2d.py +1226 -0
  69. mlmm/scan3d.py +1265 -0
  70. mlmm/scan_common.py +184 -0
  71. mlmm/summary_log.py +736 -0
  72. mlmm/trj2fig.py +448 -0
  73. mlmm/tsopt.py +2871 -0
  74. mlmm/utils.py +2309 -0
  75. mlmm/xtb_embedcharge_correction.py +475 -0
  76. mlmm_toolkit-0.2.2.dev0.dist-info/METADATA +1159 -0
  77. mlmm_toolkit-0.2.2.dev0.dist-info/RECORD +372 -0
  78. mlmm_toolkit-0.2.2.dev0.dist-info/WHEEL +5 -0
  79. mlmm_toolkit-0.2.2.dev0.dist-info/entry_points.txt +2 -0
  80. mlmm_toolkit-0.2.2.dev0.dist-info/licenses/LICENSE +674 -0
  81. mlmm_toolkit-0.2.2.dev0.dist-info/top_level.txt +4 -0
  82. pysisyphus/Geometry.py +1667 -0
  83. pysisyphus/LICENSE +674 -0
  84. pysisyphus/TableFormatter.py +63 -0
  85. pysisyphus/TablePrinter.py +74 -0
  86. pysisyphus/__init__.py +12 -0
  87. pysisyphus/calculators/AFIR.py +452 -0
  88. pysisyphus/calculators/AnaPot.py +20 -0
  89. pysisyphus/calculators/AnaPot2.py +48 -0
  90. pysisyphus/calculators/AnaPot3.py +12 -0
  91. pysisyphus/calculators/AnaPot4.py +20 -0
  92. pysisyphus/calculators/AnaPotBase.py +337 -0
  93. pysisyphus/calculators/AnaPotCBM.py +25 -0
  94. pysisyphus/calculators/AtomAtomTransTorque.py +154 -0
  95. pysisyphus/calculators/CFOUR.py +250 -0
  96. pysisyphus/calculators/Calculator.py +844 -0
  97. pysisyphus/calculators/CerjanMiller.py +24 -0
  98. pysisyphus/calculators/Composite.py +123 -0
  99. pysisyphus/calculators/ConicalIntersection.py +171 -0
  100. pysisyphus/calculators/DFTBp.py +430 -0
  101. pysisyphus/calculators/DFTD3.py +66 -0
  102. pysisyphus/calculators/DFTD4.py +84 -0
  103. pysisyphus/calculators/Dalton.py +61 -0
  104. pysisyphus/calculators/Dimer.py +681 -0
  105. pysisyphus/calculators/Dummy.py +20 -0
  106. pysisyphus/calculators/EGO.py +76 -0
  107. pysisyphus/calculators/EnergyMin.py +224 -0
  108. pysisyphus/calculators/ExternalPotential.py +264 -0
  109. pysisyphus/calculators/FakeASE.py +35 -0
  110. pysisyphus/calculators/FourWellAnaPot.py +28 -0
  111. pysisyphus/calculators/FreeEndNEBPot.py +39 -0
  112. pysisyphus/calculators/Gaussian09.py +18 -0
  113. pysisyphus/calculators/Gaussian16.py +726 -0
  114. pysisyphus/calculators/HardSphere.py +159 -0
  115. pysisyphus/calculators/IDPPCalculator.py +49 -0
  116. pysisyphus/calculators/IPIClient.py +133 -0
  117. pysisyphus/calculators/IPIServer.py +234 -0
  118. pysisyphus/calculators/LEPSBase.py +24 -0
  119. pysisyphus/calculators/LEPSExpr.py +139 -0
  120. pysisyphus/calculators/LennardJones.py +80 -0
  121. pysisyphus/calculators/MOPAC.py +219 -0
  122. pysisyphus/calculators/MullerBrownSympyPot.py +51 -0
  123. pysisyphus/calculators/MultiCalc.py +85 -0
  124. pysisyphus/calculators/NFK.py +45 -0
  125. pysisyphus/calculators/OBabel.py +87 -0
  126. pysisyphus/calculators/ONIOMv2.py +1129 -0
  127. pysisyphus/calculators/ORCA.py +893 -0
  128. pysisyphus/calculators/ORCA5.py +6 -0
  129. pysisyphus/calculators/OpenMM.py +88 -0
  130. pysisyphus/calculators/OpenMolcas.py +281 -0
  131. pysisyphus/calculators/OverlapCalculator.py +908 -0
  132. pysisyphus/calculators/Psi4.py +218 -0
  133. pysisyphus/calculators/PyPsi4.py +37 -0
  134. pysisyphus/calculators/PySCF.py +341 -0
  135. pysisyphus/calculators/PyXTB.py +73 -0
  136. pysisyphus/calculators/QCEngine.py +106 -0
  137. pysisyphus/calculators/Rastrigin.py +22 -0
  138. pysisyphus/calculators/Remote.py +76 -0
  139. pysisyphus/calculators/Rosenbrock.py +15 -0
  140. pysisyphus/calculators/SocketCalc.py +97 -0
  141. pysisyphus/calculators/TIP3P.py +111 -0
  142. pysisyphus/calculators/TransTorque.py +161 -0
  143. pysisyphus/calculators/Turbomole.py +965 -0
  144. pysisyphus/calculators/VRIPot.py +37 -0
  145. pysisyphus/calculators/WFOWrapper.py +333 -0
  146. pysisyphus/calculators/WFOWrapper2.py +341 -0
  147. pysisyphus/calculators/XTB.py +418 -0
  148. pysisyphus/calculators/__init__.py +81 -0
  149. pysisyphus/calculators/cosmo_data.py +139 -0
  150. pysisyphus/calculators/parser.py +150 -0
  151. pysisyphus/color.py +19 -0
  152. pysisyphus/config.py +133 -0
  153. pysisyphus/constants.py +65 -0
  154. pysisyphus/cos/AdaptiveNEB.py +230 -0
  155. pysisyphus/cos/ChainOfStates.py +725 -0
  156. pysisyphus/cos/FreeEndNEB.py +25 -0
  157. pysisyphus/cos/FreezingString.py +103 -0
  158. pysisyphus/cos/GrowingChainOfStates.py +71 -0
  159. pysisyphus/cos/GrowingNT.py +309 -0
  160. pysisyphus/cos/GrowingString.py +508 -0
  161. pysisyphus/cos/NEB.py +189 -0
  162. pysisyphus/cos/SimpleZTS.py +64 -0
  163. pysisyphus/cos/__init__.py +22 -0
  164. pysisyphus/cos/stiffness.py +199 -0
  165. pysisyphus/drivers/__init__.py +17 -0
  166. pysisyphus/drivers/afir.py +855 -0
  167. pysisyphus/drivers/barriers.py +271 -0
  168. pysisyphus/drivers/birkholz.py +138 -0
  169. pysisyphus/drivers/cluster.py +318 -0
  170. pysisyphus/drivers/diabatization.py +133 -0
  171. pysisyphus/drivers/merge.py +368 -0
  172. pysisyphus/drivers/merge_mol2.py +322 -0
  173. pysisyphus/drivers/opt.py +375 -0
  174. pysisyphus/drivers/perf.py +91 -0
  175. pysisyphus/drivers/pka.py +52 -0
  176. pysisyphus/drivers/precon_pos_rot.py +669 -0
  177. pysisyphus/drivers/rates.py +480 -0
  178. pysisyphus/drivers/replace.py +219 -0
  179. pysisyphus/drivers/scan.py +212 -0
  180. pysisyphus/drivers/spectrum.py +166 -0
  181. pysisyphus/drivers/thermo.py +31 -0
  182. pysisyphus/dynamics/Gaussian.py +103 -0
  183. pysisyphus/dynamics/__init__.py +20 -0
  184. pysisyphus/dynamics/colvars.py +136 -0
  185. pysisyphus/dynamics/driver.py +297 -0
  186. pysisyphus/dynamics/helpers.py +256 -0
  187. pysisyphus/dynamics/lincs.py +105 -0
  188. pysisyphus/dynamics/mdp.py +364 -0
  189. pysisyphus/dynamics/rattle.py +121 -0
  190. pysisyphus/dynamics/thermostats.py +128 -0
  191. pysisyphus/dynamics/wigner.py +266 -0
  192. pysisyphus/elem_data.py +3473 -0
  193. pysisyphus/exceptions.py +2 -0
  194. pysisyphus/filtertrj.py +69 -0
  195. pysisyphus/helpers.py +623 -0
  196. pysisyphus/helpers_pure.py +649 -0
  197. pysisyphus/init_logging.py +50 -0
  198. pysisyphus/intcoords/Bend.py +69 -0
  199. pysisyphus/intcoords/Bend2.py +25 -0
  200. pysisyphus/intcoords/BondedFragment.py +32 -0
  201. pysisyphus/intcoords/Cartesian.py +41 -0
  202. pysisyphus/intcoords/CartesianCoords.py +140 -0
  203. pysisyphus/intcoords/Coords.py +56 -0
  204. pysisyphus/intcoords/DLC.py +197 -0
  205. pysisyphus/intcoords/DistanceFunction.py +34 -0
  206. pysisyphus/intcoords/DummyImproper.py +70 -0
  207. pysisyphus/intcoords/DummyTorsion.py +72 -0
  208. pysisyphus/intcoords/LinearBend.py +105 -0
  209. pysisyphus/intcoords/LinearDisplacement.py +80 -0
  210. pysisyphus/intcoords/OutOfPlane.py +59 -0
  211. pysisyphus/intcoords/PrimTypes.py +286 -0
  212. pysisyphus/intcoords/Primitive.py +137 -0
  213. pysisyphus/intcoords/RedundantCoords.py +659 -0
  214. pysisyphus/intcoords/RobustTorsion.py +59 -0
  215. pysisyphus/intcoords/Rotation.py +147 -0
  216. pysisyphus/intcoords/Stretch.py +31 -0
  217. pysisyphus/intcoords/Torsion.py +101 -0
  218. pysisyphus/intcoords/Torsion2.py +25 -0
  219. pysisyphus/intcoords/Translation.py +45 -0
  220. pysisyphus/intcoords/__init__.py +61 -0
  221. pysisyphus/intcoords/augment_bonds.py +126 -0
  222. pysisyphus/intcoords/derivatives.py +10512 -0
  223. pysisyphus/intcoords/eval.py +80 -0
  224. pysisyphus/intcoords/exceptions.py +37 -0
  225. pysisyphus/intcoords/findiffs.py +48 -0
  226. pysisyphus/intcoords/generate_derivatives.py +414 -0
  227. pysisyphus/intcoords/helpers.py +235 -0
  228. pysisyphus/intcoords/logging_conf.py +10 -0
  229. pysisyphus/intcoords/mp_derivatives.py +10836 -0
  230. pysisyphus/intcoords/setup.py +962 -0
  231. pysisyphus/intcoords/setup_fast.py +176 -0
  232. pysisyphus/intcoords/update.py +272 -0
  233. pysisyphus/intcoords/valid.py +89 -0
  234. pysisyphus/interpolate/Geodesic.py +93 -0
  235. pysisyphus/interpolate/IDPP.py +55 -0
  236. pysisyphus/interpolate/Interpolator.py +116 -0
  237. pysisyphus/interpolate/LST.py +70 -0
  238. pysisyphus/interpolate/Redund.py +152 -0
  239. pysisyphus/interpolate/__init__.py +9 -0
  240. pysisyphus/interpolate/helpers.py +34 -0
  241. pysisyphus/io/__init__.py +22 -0
  242. pysisyphus/io/aomix.py +178 -0
  243. pysisyphus/io/cjson.py +24 -0
  244. pysisyphus/io/crd.py +101 -0
  245. pysisyphus/io/cube.py +220 -0
  246. pysisyphus/io/fchk.py +184 -0
  247. pysisyphus/io/hdf5.py +49 -0
  248. pysisyphus/io/hessian.py +72 -0
  249. pysisyphus/io/mol2.py +146 -0
  250. pysisyphus/io/molden.py +293 -0
  251. pysisyphus/io/orca.py +189 -0
  252. pysisyphus/io/pdb.py +269 -0
  253. pysisyphus/io/psf.py +79 -0
  254. pysisyphus/io/pubchem.py +31 -0
  255. pysisyphus/io/qcschema.py +34 -0
  256. pysisyphus/io/sdf.py +29 -0
  257. pysisyphus/io/xyz.py +61 -0
  258. pysisyphus/io/zmat.py +175 -0
  259. pysisyphus/irc/DWI.py +108 -0
  260. pysisyphus/irc/DampedVelocityVerlet.py +134 -0
  261. pysisyphus/irc/Euler.py +22 -0
  262. pysisyphus/irc/EulerPC.py +345 -0
  263. pysisyphus/irc/GonzalezSchlegel.py +187 -0
  264. pysisyphus/irc/IMKMod.py +164 -0
  265. pysisyphus/irc/IRC.py +878 -0
  266. pysisyphus/irc/IRCDummy.py +10 -0
  267. pysisyphus/irc/Instanton.py +307 -0
  268. pysisyphus/irc/LQA.py +53 -0
  269. pysisyphus/irc/ModeKill.py +136 -0
  270. pysisyphus/irc/ParamPlot.py +53 -0
  271. pysisyphus/irc/RK4.py +36 -0
  272. pysisyphus/irc/__init__.py +31 -0
  273. pysisyphus/irc/initial_displ.py +219 -0
  274. pysisyphus/linalg.py +411 -0
  275. pysisyphus/line_searches/Backtracking.py +88 -0
  276. pysisyphus/line_searches/HagerZhang.py +184 -0
  277. pysisyphus/line_searches/LineSearch.py +232 -0
  278. pysisyphus/line_searches/StrongWolfe.py +108 -0
  279. pysisyphus/line_searches/__init__.py +9 -0
  280. pysisyphus/line_searches/interpol.py +15 -0
  281. pysisyphus/modefollow/NormalMode.py +40 -0
  282. pysisyphus/modefollow/__init__.py +10 -0
  283. pysisyphus/modefollow/davidson.py +199 -0
  284. pysisyphus/modefollow/lanczos.py +95 -0
  285. pysisyphus/optimizers/BFGS.py +99 -0
  286. pysisyphus/optimizers/BacktrackingOptimizer.py +113 -0
  287. pysisyphus/optimizers/ConjugateGradient.py +98 -0
  288. pysisyphus/optimizers/CubicNewton.py +75 -0
  289. pysisyphus/optimizers/FIRE.py +113 -0
  290. pysisyphus/optimizers/HessianOptimizer.py +1176 -0
  291. pysisyphus/optimizers/LBFGS.py +228 -0
  292. pysisyphus/optimizers/LayerOpt.py +411 -0
  293. pysisyphus/optimizers/MicroOptimizer.py +169 -0
  294. pysisyphus/optimizers/NCOptimizer.py +90 -0
  295. pysisyphus/optimizers/Optimizer.py +1084 -0
  296. pysisyphus/optimizers/PreconLBFGS.py +260 -0
  297. pysisyphus/optimizers/PreconSteepestDescent.py +7 -0
  298. pysisyphus/optimizers/QuickMin.py +74 -0
  299. pysisyphus/optimizers/RFOptimizer.py +181 -0
  300. pysisyphus/optimizers/RSA.py +99 -0
  301. pysisyphus/optimizers/StabilizedQNMethod.py +248 -0
  302. pysisyphus/optimizers/SteepestDescent.py +23 -0
  303. pysisyphus/optimizers/StringOptimizer.py +173 -0
  304. pysisyphus/optimizers/__init__.py +41 -0
  305. pysisyphus/optimizers/closures.py +301 -0
  306. pysisyphus/optimizers/cls_map.py +58 -0
  307. pysisyphus/optimizers/exceptions.py +6 -0
  308. pysisyphus/optimizers/gdiis.py +280 -0
  309. pysisyphus/optimizers/guess_hessians.py +311 -0
  310. pysisyphus/optimizers/hessian_updates.py +355 -0
  311. pysisyphus/optimizers/poly_fit.py +285 -0
  312. pysisyphus/optimizers/precon.py +153 -0
  313. pysisyphus/optimizers/restrict_step.py +24 -0
  314. pysisyphus/pack.py +172 -0
  315. pysisyphus/peakdetect.py +948 -0
  316. pysisyphus/plot.py +1031 -0
  317. pysisyphus/run.py +2106 -0
  318. pysisyphus/socket_helper.py +74 -0
  319. pysisyphus/stocastic/FragmentKick.py +132 -0
  320. pysisyphus/stocastic/Kick.py +81 -0
  321. pysisyphus/stocastic/Pipeline.py +303 -0
  322. pysisyphus/stocastic/__init__.py +21 -0
  323. pysisyphus/stocastic/align.py +127 -0
  324. pysisyphus/testing.py +96 -0
  325. pysisyphus/thermo.py +156 -0
  326. pysisyphus/trj.py +824 -0
  327. pysisyphus/tsoptimizers/RSIRFOptimizer.py +56 -0
  328. pysisyphus/tsoptimizers/RSPRFOptimizer.py +182 -0
  329. pysisyphus/tsoptimizers/TRIM.py +59 -0
  330. pysisyphus/tsoptimizers/TSHessianOptimizer.py +463 -0
  331. pysisyphus/tsoptimizers/__init__.py +23 -0
  332. pysisyphus/wavefunction/Basis.py +239 -0
  333. pysisyphus/wavefunction/DIIS.py +76 -0
  334. pysisyphus/wavefunction/__init__.py +25 -0
  335. pysisyphus/wavefunction/build_ext.py +42 -0
  336. pysisyphus/wavefunction/cart2sph.py +190 -0
  337. pysisyphus/wavefunction/diabatization.py +304 -0
  338. pysisyphus/wavefunction/excited_states.py +435 -0
  339. pysisyphus/wavefunction/gen_ints.py +1811 -0
  340. pysisyphus/wavefunction/helpers.py +104 -0
  341. pysisyphus/wavefunction/ints/__init__.py +0 -0
  342. pysisyphus/wavefunction/ints/boys.py +193 -0
  343. pysisyphus/wavefunction/ints/boys_table_N_64_xasym_27.1_step_0.01.npy +0 -0
  344. pysisyphus/wavefunction/ints/cart_gto3d.py +176 -0
  345. pysisyphus/wavefunction/ints/coulomb3d.py +25928 -0
  346. pysisyphus/wavefunction/ints/diag_quadrupole3d.py +10036 -0
  347. pysisyphus/wavefunction/ints/dipole3d.py +8762 -0
  348. pysisyphus/wavefunction/ints/int2c2e3d.py +7198 -0
  349. pysisyphus/wavefunction/ints/int3c2e3d_sph.py +65040 -0
  350. pysisyphus/wavefunction/ints/kinetic3d.py +8240 -0
  351. pysisyphus/wavefunction/ints/ovlp3d.py +3777 -0
  352. pysisyphus/wavefunction/ints/quadrupole3d.py +15054 -0
  353. pysisyphus/wavefunction/ints/self_ovlp3d.py +198 -0
  354. pysisyphus/wavefunction/localization.py +458 -0
  355. pysisyphus/wavefunction/multipole.py +159 -0
  356. pysisyphus/wavefunction/normalization.py +36 -0
  357. pysisyphus/wavefunction/pop_analysis.py +134 -0
  358. pysisyphus/wavefunction/shells.py +1171 -0
  359. pysisyphus/wavefunction/wavefunction.py +504 -0
  360. pysisyphus/wrapper/__init__.py +11 -0
  361. pysisyphus/wrapper/exceptions.py +2 -0
  362. pysisyphus/wrapper/jmol.py +120 -0
  363. pysisyphus/wrapper/mwfn.py +169 -0
  364. pysisyphus/wrapper/packmol.py +71 -0
  365. pysisyphus/xyzloader.py +168 -0
  366. pysisyphus/yaml_mods.py +45 -0
  367. thermoanalysis/LICENSE +674 -0
  368. thermoanalysis/QCData.py +244 -0
  369. thermoanalysis/__init__.py +0 -0
  370. thermoanalysis/config.py +3 -0
  371. thermoanalysis/constants.py +20 -0
  372. thermoanalysis/thermo.py +1011 -0
mlmm/summary_log.py ADDED
@@ -0,0 +1,736 @@
1
+ # mlmm/summary_log.py
2
+
3
+ """
4
+ User-friendly summary log writer used by ``path_search`` and ``all``.
5
+
6
+ The goal is to provide a compact, readable ``summary.log`` alongside the
7
+ ``summary.yaml``. The log aggregates MEP details, segment barriers,
8
+ post-processing energies, 3-layer system info, and key output paths.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import textwrap
14
+ from pathlib import Path
15
+ from typing import Any, Dict, Iterable, List, Optional, Sequence
16
+
17
+ from pysisyphus.constants import AU2KCALPERMOL
18
+ from . import __version__
19
+ from .defaults import MLMM_CALC_KW, BFACTOR_ML, BFACTOR_MOVABLE_MM, BFACTOR_FROZEN
20
+
21
+ REQUIRED_SUMMARY_PAYLOAD_KEYS: tuple[str, ...] = (
22
+ "root_out_dir",
23
+ "path_module_dir",
24
+ "pipeline_mode",
25
+ "segments",
26
+ "energy_diagrams",
27
+ )
28
+
29
+
30
+ def normalize_summary_payload(payload: Dict[str, Any] | None) -> Dict[str, Any]:
31
+ """Return a defensive payload with stable defaults for summary rendering."""
32
+ raw = payload if isinstance(payload, dict) else {}
33
+ out: Dict[str, Any] = dict(raw)
34
+ out.setdefault("root_out_dir", "-")
35
+ out.setdefault("path_module_dir", "-")
36
+ out.setdefault("pipeline_mode", "-")
37
+ out.setdefault("segments", [])
38
+ out.setdefault("post_segments", [])
39
+ out.setdefault("energy_diagrams", [])
40
+ out.setdefault("mep", {})
41
+ out.setdefault("key_files", {})
42
+ return out
43
+
44
+
45
+ def _fmt_bool(val: Optional[Any]) -> str:
46
+ if val is None:
47
+ return "-"
48
+ return "True" if bool(val) else "False"
49
+
50
+
51
+ def _shorten_path(path: Optional[Path], root_out: Optional[Path]) -> str:
52
+ """Return a path string, preferring a relative form to ``root_out`` or its parent."""
53
+ if not path:
54
+ return "(not available)"
55
+
56
+ path_obj = Path(path)
57
+
58
+ if root_out:
59
+ for base in (root_out, root_out.parent):
60
+ try:
61
+ return str(path_obj.relative_to(base))
62
+ except ValueError:
63
+ continue
64
+
65
+ return str(path_obj)
66
+
67
+
68
+ def _format_energy_rows(
69
+ labels: Sequence[str],
70
+ energies_au: Optional[Sequence[Optional[float]]],
71
+ energies_kcal: Optional[Sequence[Optional[float]]],
72
+ ) -> List[str]:
73
+ rows: List[str] = []
74
+ try:
75
+ energies_au_list = list(energies_au) if energies_au is not None else []
76
+ except Exception:
77
+ energies_au_list = []
78
+ try:
79
+ energies_kcal_list = list(energies_kcal) if energies_kcal is not None else []
80
+ except Exception:
81
+ energies_kcal_list = []
82
+ base_e = energies_au_list[0] if energies_au_list else None
83
+
84
+ for i, lab in enumerate(labels):
85
+ abs_e = energies_au_list[i] if i < len(energies_au_list) else None
86
+ rel_e = energies_kcal_list[i] if i < len(energies_kcal_list) else None
87
+ if rel_e is None and abs_e is not None and base_e is not None:
88
+ rel_e = (abs_e - base_e) * AU2KCALPERMOL
89
+
90
+ abs_txt = f"{abs_e:14.6f}" if abs_e is not None else f"{'n/a':>14}"
91
+ rel_txt = f"{rel_e:14.4f}" if rel_e is not None else f"{'n/a':>14}"
92
+ rows.append(f" {lab:<8}{abs_txt} {rel_txt}")
93
+ return rows
94
+
95
+
96
+ def _format_bond_changes(text: str, indent: int = 6) -> List[str]:
97
+ if not text:
98
+ return ["".rjust(indent) + "(no covalent changes detected)"]
99
+ blocks = [ln.rstrip() for ln in textwrap.dedent(text).splitlines() if ln.strip()]
100
+ return ["".rjust(indent) + ln for ln in blocks]
101
+
102
+
103
+ def _format_ts_imag_info(ts_info: Any) -> List[str]:
104
+ if ts_info is None:
105
+ return []
106
+
107
+ lines: List[str] = [" TS imaginary freq:"]
108
+ n_imag: Optional[int] = None
109
+ nu_imag: Optional[float] = None
110
+ min_abs: Optional[float] = None
111
+
112
+ if isinstance(ts_info, dict):
113
+ n_imag = ts_info.get("n_imag")
114
+ nu_imag = ts_info.get("nu_imag_max_cm") or ts_info.get("nu_imag_cm")
115
+ min_abs = ts_info.get("min_abs_imag_cm")
116
+ if nu_imag is None and ts_info.get("ts_imag_freq_cm"):
117
+ nu_imag = ts_info.get("ts_imag_freq_cm")
118
+ else:
119
+ try:
120
+ nu_imag = float(ts_info)
121
+ n_imag = 1 if nu_imag is not None else None
122
+ except Exception:
123
+ nu_imag = None
124
+
125
+ n_imag_txt = str(n_imag) if n_imag is not None else "-"
126
+ lines.append(f" n_imag : {n_imag_txt}")
127
+
128
+ nu_label = "\u03bd_imag (max)"
129
+ if nu_imag is not None:
130
+ lines.append(f" {nu_label} : {nu_imag:.1f} cm^-1")
131
+ else:
132
+ lines.append(f" {nu_label} : -")
133
+
134
+ magnitude = min_abs if min_abs is not None else (abs(nu_imag) if nu_imag is not None else None)
135
+ note: Optional[str] = None
136
+ if n_imag is not None:
137
+ if n_imag == 1:
138
+ if magnitude is not None and magnitude < 100.0:
139
+ note = "WARNING : Imaginary frequency magnitude is small; TS may be poorly optimized."
140
+ else:
141
+ note = "NOTE : OK (single imaginary mode)"
142
+ elif n_imag == 0:
143
+ note = "WARNING : No imaginary frequency; structure may not be a TS."
144
+ else:
145
+ note = "WARNING : Multiple imaginary frequencies; TS may be poorly optimized."
146
+ elif nu_imag is not None:
147
+ if magnitude is not None and magnitude < 100.0:
148
+ note = "WARNING : Imaginary frequency magnitude is small; TS may be poorly optimized."
149
+ else:
150
+ note = "NOTE : Single imaginary frequency (count unavailable)"
151
+
152
+ if note:
153
+ lines.append(f" {note}")
154
+
155
+ return lines
156
+
157
+
158
+ def _format_layer_info(payload: Dict[str, Any]) -> List[str]:
159
+ """Format 3-layer ML/MM system information with Hessian-target MM subset info."""
160
+ lines: List[str] = []
161
+
162
+ ml_atoms = payload.get("ml_atoms")
163
+ hess_mm_atoms = payload.get("hess_mm_atoms")
164
+ movable_mm_atoms = payload.get("movable_mm_atoms")
165
+ frozen_atoms = payload.get("frozen_atoms")
166
+
167
+ n_ml = len(ml_atoms) if ml_atoms else 0
168
+ n_hess = len(hess_mm_atoms) if hess_mm_atoms else 0
169
+ n_movable = len(movable_mm_atoms) if movable_mm_atoms else 0
170
+ n_frozen = len(frozen_atoms) if frozen_atoms else 0
171
+
172
+ n_movable_total = n_hess + n_movable
173
+ lines.append(" 3-Layer ML/MM System:")
174
+ lines.append(f" ML (B={BFACTOR_ML:.0f}) : {n_ml:6d} atoms")
175
+ lines.append(f" Movable MM (B={BFACTOR_MOVABLE_MM:.0f}) : {n_movable_total:6d} atoms")
176
+ lines.append(f" Hessian-target subset : {n_hess:6d} atoms")
177
+ lines.append(f" Frozen MM (B={BFACTOR_FROZEN:.0f}) : {n_frozen:6d} atoms")
178
+ lines.append(f" Total : {n_ml + n_movable_total + n_frozen:6d} atoms")
179
+
180
+ hess_cutoff = payload.get("hess_cutoff")
181
+ movable_cutoff = payload.get("movable_cutoff")
182
+ use_bfactor = payload.get("use_bfactor_layers")
183
+
184
+ if hess_cutoff is not None or movable_cutoff is not None:
185
+ lines.append(
186
+ f" hess_cutoff : {hess_cutoff} A"
187
+ if hess_cutoff is not None
188
+ else " hess_cutoff : -"
189
+ )
190
+ lines.append(
191
+ f" movable_cutoff : {movable_cutoff} A"
192
+ if movable_cutoff is not None
193
+ else " movable_cutoff : -"
194
+ )
195
+ elif use_bfactor:
196
+ lines.append(" Layer source : B-factor based")
197
+
198
+ return lines
199
+
200
+
201
+ def _emit_energy_block(
202
+ lines: List[str],
203
+ title: str,
204
+ payload: Optional[Dict[str, Any]],
205
+ root_out: Optional[Path],
206
+ ) -> None:
207
+ if not payload:
208
+ return
209
+ labels: Sequence[str] = payload.get("labels") or ["R", "TS", "P"]
210
+ energies_au = payload.get("energies_au")
211
+ energies_kcal = payload.get("energies_kcal")
212
+ lines.append(f" -- {title} --")
213
+ lines.append(" State Abs [Eh] Rel [kcal/mol]")
214
+ lines.extend(_format_energy_rows(labels, energies_au, energies_kcal))
215
+
216
+ diagram = payload.get("diagram") or payload.get("image")
217
+ if diagram:
218
+ lines.append(f" Diagram : {_shorten_path(diagram, root_out)}")
219
+ structs: Dict[str, Any] = payload.get("structures", {})
220
+ if structs:
221
+ lines.append(" Structures:")
222
+ for key in ("R", "TS", "P"):
223
+ if key in structs:
224
+ lines.append(f" {key}: {_shorten_path(structs.get(key), root_out)}")
225
+
226
+
227
+ def _tree_rel_path(root: Path, p: Path) -> str:
228
+ try:
229
+ return p.relative_to(root).as_posix()
230
+ except ValueError:
231
+ return p.name
232
+
233
+
234
+ def _tree_annotate(annotations: Dict[str, str], rel: str) -> str:
235
+ note = annotations.get(rel)
236
+ return f" # {note}" if note else ""
237
+
238
+
239
+ def _tree_leaf_files(dir_path: Path) -> Optional[List[str]]:
240
+ try:
241
+ inner_children = sorted(dir_path.iterdir(), key=lambda p: p.name.lower())
242
+ except Exception:
243
+ return None
244
+
245
+ if any(p.is_dir() for p in inner_children):
246
+ return None
247
+ return [p.name for p in inner_children if p.is_file()]
248
+
249
+
250
+ def _walk_directory_tree(
251
+ dir_path: Path,
252
+ prefix: str,
253
+ depth: int,
254
+ *,
255
+ root: Path,
256
+ annotations: Dict[str, str],
257
+ max_depth: int,
258
+ max_entries: int,
259
+ lines: List[str],
260
+ entries_seen_ref: List[int],
261
+ ) -> bool:
262
+ try:
263
+ children = sorted(
264
+ dir_path.iterdir(),
265
+ key=lambda p: (p.is_file(), p.name.lower()),
266
+ )
267
+ except Exception:
268
+ return False
269
+
270
+ for idx, child in enumerate(children):
271
+ connector = "\u2514\u2500" if idx == len(children) - 1 else "\u251c\u2500"
272
+ rel = _tree_rel_path(root, child)
273
+ if child.is_dir():
274
+ leaf_names = _tree_leaf_files(child) if depth < max_depth else None
275
+ if leaf_names is not None:
276
+ lines.append(f"{prefix}{connector} {child.name}/{_tree_annotate(annotations, rel)}")
277
+ entries_seen_ref[0] += 1
278
+ if entries_seen_ref[0] >= max_entries:
279
+ lines.append(
280
+ f"{prefix} ... (truncated after {max_entries} entries)"
281
+ )
282
+ return True
283
+
284
+ next_prefix = prefix + (" " if idx == len(children) - 1 else "\u2502 ")
285
+ grouped = ",".join(leaf_names)
286
+ lines.append(f"{next_prefix}\u2514\u2500 {{{grouped}}}")
287
+ entries_seen_ref[0] += 1
288
+ if entries_seen_ref[0] >= max_entries:
289
+ lines.append(
290
+ f"{next_prefix} ... (truncated after {max_entries} entries)"
291
+ )
292
+ return True
293
+ continue
294
+
295
+ name = child.name + ("/" if child.is_dir() else "")
296
+ lines.append(f"{prefix}{connector} {name}{_tree_annotate(annotations, rel)}")
297
+ entries_seen_ref[0] += 1
298
+ if entries_seen_ref[0] >= max_entries:
299
+ lines.append(f"{prefix} ... (truncated after {max_entries} entries)")
300
+ return True
301
+ if child.is_dir() and depth < max_depth:
302
+ next_prefix = prefix + (" " if idx == len(children) - 1 else "\u2502 ")
303
+ if _walk_directory_tree(
304
+ child,
305
+ next_prefix,
306
+ depth + 1,
307
+ root=root,
308
+ annotations=annotations,
309
+ max_depth=max_depth,
310
+ max_entries=max_entries,
311
+ lines=lines,
312
+ entries_seen_ref=entries_seen_ref,
313
+ ):
314
+ return True
315
+ return False
316
+
317
+
318
+ def _format_directory_tree(
319
+ root: Path,
320
+ annotations: Dict[str, str],
321
+ max_depth: int = 4,
322
+ max_entries: int = 200,
323
+ ) -> List[str]:
324
+ """Render a compact directory tree rooted at ``root``."""
325
+
326
+ lines: List[str] = []
327
+ entries_seen_ref = [0]
328
+ lines.append(f" {root.name}/" + _tree_annotate(annotations, "."))
329
+ _walk_directory_tree(
330
+ root,
331
+ " ",
332
+ 1,
333
+ root=root,
334
+ annotations=annotations,
335
+ max_depth=max_depth,
336
+ max_entries=max_entries,
337
+ lines=lines,
338
+ entries_seen_ref=entries_seen_ref,
339
+ )
340
+ return lines
341
+
342
+
343
+ def _segment_table_value(entry: Dict[str, Any], key: str, col_width: int) -> str:
344
+ if entry.get("kind") == "bridge" and not key.startswith("mep_"):
345
+ return "---".rjust(col_width)
346
+ val = entry.get(key)
347
+ if val is None:
348
+ return "---".rjust(col_width)
349
+ return f"{val:>{col_width}.2f}"
350
+
351
+
352
+ def _classify_diagram_method(diag: Dict[str, Any]) -> str:
353
+ name = str(diag.get("name", "")).lower()
354
+ ylabel_txt = str(diag.get("ylabel", "")).lower()
355
+
356
+ if "g_dft" in name or "gibbs_dft" in name or ("gibbs" in ylabel_txt and "dft" in name):
357
+ return "gibbs_dft_uma"
358
+ if "dft" in name:
359
+ return "dft"
360
+ if "g_uma" in name or "gibbs" in name or "gibbs" in ylabel_txt:
361
+ return "gibbs_uma"
362
+ if "uma" in name:
363
+ return "uma"
364
+ return "mep"
365
+
366
+
367
+ def _format_diag_row(
368
+ diag: Optional[Dict[str, Any]],
369
+ label: str,
370
+ col_width: int,
371
+ states: Sequence[str],
372
+ label_width: int,
373
+ ) -> str:
374
+ if not diag:
375
+ values = " ".join("---".rjust(col_width) for _ in states)
376
+ return f" {label:<{label_width}} {values}"
377
+
378
+ try:
379
+ labels_iter = list(diag.get("labels", []))
380
+ except Exception:
381
+ labels_iter = []
382
+ labels_map = {lab: i for i, lab in enumerate(labels_iter)}
383
+ energies_raw = diag.get("energies_kcal", [])
384
+ try:
385
+ energies = list(energies_raw) if energies_raw is not None else []
386
+ except Exception:
387
+ energies = []
388
+ row_vals: List[str] = []
389
+ for st in states:
390
+ idx = labels_map.get(st)
391
+ val = energies[idx] if idx is not None and idx < len(energies) else None
392
+ row_vals.append(f"{val:>{col_width}.2f}" if val is not None else "---".rjust(col_width))
393
+ return f" {label:<{label_width}} {' '.join(row_vals)}"
394
+
395
+
396
+ def write_summary_log(dest: Path, payload: Dict[str, Any]) -> None:
397
+ """Write a user-friendly summary.log at ``dest`` from a pre-collected payload."""
398
+ missing_required = [
399
+ key for key in REQUIRED_SUMMARY_PAYLOAD_KEYS if not isinstance(payload, dict) or key not in payload
400
+ ]
401
+ payload = normalize_summary_payload(payload)
402
+
403
+ root_out = payload.get("root_out_dir") or "-"
404
+ root_out_path = Path(root_out) if root_out not in (None, "-") else None
405
+ path_module = payload.get("path_module_dir") or "-"
406
+ pipeline_mode = payload.get("pipeline_mode") or "-"
407
+ charge = payload.get("charge")
408
+ spin = payload.get("spin")
409
+ command = payload.get("command") or payload.get("cli_command")
410
+
411
+ lines: List[str] = []
412
+ lines.append("========================================================================")
413
+ lines.append("mlmm summary.log")
414
+ lines.append("========================================================================")
415
+ if command:
416
+ lines.append(f"Input : {command}")
417
+ if missing_required:
418
+ lines.append(
419
+ "Preflight note : missing payload keys replaced with defaults -> "
420
+ + ", ".join(missing_required)
421
+ )
422
+ lines.append(f"Root out_dir : {root_out}")
423
+ path_module_disp = (
424
+ _shorten_path(path_module, root_out_path)
425
+ if path_module not in (None, "-")
426
+ else path_module
427
+ )
428
+ lines.append(f"Path module dir : {path_module_disp}")
429
+ lines.append(f"Pipeline mode : {pipeline_mode}")
430
+ lines.append(f"refine-path : {_fmt_bool(payload.get('refine_path'))}")
431
+ lines.append(f"TSOPT/IRC : {_fmt_bool(payload.get('tsopt'))}")
432
+ lines.append(f"Thermochemistry : {_fmt_bool(payload.get('thermo'))}")
433
+ lines.append(f"DFT single-point : {_fmt_bool(payload.get('dft'))}")
434
+ opt_mode_disp = payload.get("opt_mode") or "-"
435
+ lines.append(
436
+ f"Opt mode : {opt_mode_disp} (grad: lbfgs/dimer; hess: rfo/rsirfo)"
437
+ )
438
+ lines.append(f"MEP mode : {payload.get('mep_mode') or '-'}")
439
+
440
+ version_base = payload.get("code_version") or __version__
441
+ version_txt = f"mlmm {version_base}"
442
+ lines.append(f"Code version : {version_txt}")
443
+ uma_model = payload.get("uma_model") or MLMM_CALC_KW.get("uma_model") or "-"
444
+ lines.append(f"UMA model : {uma_model}")
445
+ lines.append(f"Total charge (ML) : {charge if charge is not None else '-'}")
446
+ lines.append(f"Multiplicity (2S+1): {spin if spin is not None else '-'}")
447
+
448
+ freeze_atoms_raw = payload.get("freeze_atoms")
449
+ if freeze_atoms_raw is None:
450
+ freeze_atoms_iter: List[Any] = []
451
+ else:
452
+ try:
453
+ freeze_atoms_iter = list(freeze_atoms_raw)
454
+ except Exception:
455
+ freeze_atoms_iter = []
456
+ try:
457
+ freeze_atoms_list = sorted({int(i) for i in freeze_atoms_iter})
458
+ except Exception:
459
+ freeze_atoms_list = []
460
+ if freeze_atoms_list:
461
+ lines.append(
462
+ "Freeze atoms (0-based): " + ",".join(map(str, freeze_atoms_list))
463
+ )
464
+ lines.append("")
465
+
466
+ # 3-layer ML/MM system info
467
+ if any(payload.get(k) for k in ["ml_atoms", "hess_mm_atoms", "movable_mm_atoms", "frozen_atoms"]):
468
+ lines.extend(_format_layer_info(payload))
469
+ lines.append("")
470
+
471
+ delta = "\u0394"
472
+ dagger = "\u2021"
473
+
474
+ mep = payload.get("mep", {}) or {}
475
+ diag = mep.get("diagram") or {}
476
+ lines.append("[1] Global MEP overview")
477
+ lines.append(f" Number of MEP images : {mep.get('n_images', '-')}")
478
+ lines.append(f" Number of segments : {mep.get('n_segments', '-')}")
479
+ if mep.get("traj_pdb"):
480
+ lines.append(
481
+ f" MEP trajectory (PDB) : {_shorten_path(mep.get('traj_pdb'), root_out_path)}"
482
+ )
483
+ if mep.get("mep_plot"):
484
+ lines.append(
485
+ f" MEP energy plot : {_shorten_path(mep.get('mep_plot'), root_out_path)}"
486
+ )
487
+ lines.append("")
488
+ lines.append(f" MEP energy diagram ({delta}E, kcal/mol)")
489
+ if diag:
490
+ if diag.get("image"):
491
+ lines.append(
492
+ f" Image : {_shorten_path(diag.get('image'), root_out_path)}"
493
+ )
494
+ lines.append(f" State {delta}E [kcal/mol]")
495
+ labels = diag.get("labels", [])
496
+ energies = diag.get("energies_kcal", [])
497
+ for i, lab in enumerate(labels):
498
+ rel = energies[i] if i < len(energies) else None
499
+ rel_txt = f"{rel:9.4f}" if rel is not None else " n/a"
500
+ lines.append(f" {lab:<8}{rel_txt}")
501
+ else:
502
+ lines.append(" (no diagram available)")
503
+
504
+ segments: Iterable[Dict[str, Any]] = payload.get("segments", []) or []
505
+ lines.append("")
506
+ lines.append("[2] Segment-level MEP summary (ML/MM path)")
507
+ if segments:
508
+ for seg in segments:
509
+ idx = int(seg.get("index", 0) or 0)
510
+ tag = seg.get("tag", f"seg_{idx:03d}")
511
+ kind = seg.get("kind", "seg")
512
+ lines.append(f" - Segment {idx:02d} [{kind}] tag={tag}")
513
+ barrier = seg.get("barrier_kcal")
514
+ delta_e = seg.get("delta_kcal")
515
+ b_txt = f"{barrier:7.2f}" if barrier is not None else " n/a"
516
+ d_txt = f"{delta_e:7.2f}" if delta_e is not None else " n/a"
517
+ lines.append(f" {delta}E{dagger} = {b_txt} kcal/mol, {delta}E = {d_txt} kcal/mol")
518
+ lines.append(" Bond changes:")
519
+ lines.extend(_format_bond_changes(str(seg.get("bond_changes", ""))))
520
+ else:
521
+ lines.append(" (no segment reports)")
522
+
523
+ post_segments: Iterable[Dict[str, Any]] = payload.get("post_segments", []) or []
524
+ segment_entries: Dict[int, Dict[str, Any]] = {}
525
+ for seg in segments:
526
+ idx = int(seg.get("index", 0) or 0)
527
+ tag = seg.get("tag", f"seg_{idx:03d}")
528
+ kind = seg.get("kind", "seg")
529
+ entry = segment_entries.setdefault(
530
+ idx, {"index": idx, "tag": tag, "kind": kind}
531
+ )
532
+ entry.setdefault("tag", tag)
533
+ entry.setdefault("kind", kind)
534
+ if seg.get("barrier_kcal") is not None:
535
+ entry["mep_barrier"] = seg.get("barrier_kcal")
536
+ if seg.get("delta_kcal") is not None:
537
+ entry["mep_delta"] = seg.get("delta_kcal")
538
+ lines.append("")
539
+ lines.append("[3] Per-segment post-processing (TSOPT / Thermo / DFT)")
540
+ if post_segments:
541
+ for seg in post_segments:
542
+ idx = int(seg.get("index", 0) or 0)
543
+ tag = seg.get("tag", f"seg_{idx:02d}")
544
+ kind = seg.get("kind", "seg")
545
+ lines.append(f" === Segment {idx:02d} ({kind}) tag={tag} ===")
546
+ if seg.get("post_dir"):
547
+ lines.append(
548
+ f" Post-process dir : {_shorten_path(seg.get('post_dir'), root_out_path)}"
549
+ )
550
+ ts_imag = seg.get("ts_imag") or seg.get("ts_imag_freq_cm")
551
+ lines.extend(_format_ts_imag_info(ts_imag))
552
+ if seg.get("irc_plot"):
553
+ lines.append(
554
+ f" IRC plot : {_shorten_path(seg.get('irc_plot'), root_out_path)}"
555
+ )
556
+ if seg.get("irc_traj"):
557
+ lines.append(
558
+ f" IRC trajectory : {_shorten_path(seg.get('irc_traj'), root_out_path)}"
559
+ )
560
+ _emit_energy_block(
561
+ lines, "ML/MM energies (TSOPT+IRC)", seg.get("uma"), root_out_path
562
+ )
563
+ _emit_energy_block(lines, "ML/MM Gibbs (thermo)", seg.get("gibbs_uma"), root_out_path)
564
+ _emit_energy_block(lines, "DFT single-point", seg.get("dft"), root_out_path)
565
+ _emit_energy_block(
566
+ lines, "DFT//ML/MM Gibbs", seg.get("gibbs_dft_uma"), root_out_path
567
+ )
568
+
569
+ entry = segment_entries.setdefault(
570
+ idx, {"index": idx, "tag": tag, "kind": kind}
571
+ )
572
+ entry.setdefault("tag", tag)
573
+ entry.setdefault("kind", kind)
574
+ if seg.get("mep_barrier_kcal") is not None:
575
+ entry["mep_barrier"] = seg.get("mep_barrier_kcal")
576
+ if seg.get("mep_delta_kcal") is not None:
577
+ entry["mep_delta"] = seg.get("mep_delta_kcal")
578
+ if seg.get("uma"):
579
+ uma_payload = seg.get("uma") or {}
580
+ if uma_payload.get("barrier_kcal") is not None:
581
+ entry["uma_barrier"] = uma_payload.get("barrier_kcal")
582
+ if uma_payload.get("delta_kcal") is not None:
583
+ entry["uma_delta"] = uma_payload.get("delta_kcal")
584
+ if seg.get("gibbs_uma"):
585
+ g_payload = seg.get("gibbs_uma") or {}
586
+ if g_payload.get("barrier_kcal") is not None:
587
+ entry["gibbs_uma_barrier"] = g_payload.get("barrier_kcal")
588
+ if g_payload.get("delta_kcal") is not None:
589
+ entry["gibbs_uma_delta"] = g_payload.get("delta_kcal")
590
+ if seg.get("dft"):
591
+ dft_payload = seg.get("dft") or {}
592
+ if dft_payload.get("barrier_kcal") is not None:
593
+ entry["dft_barrier"] = dft_payload.get("barrier_kcal")
594
+ if dft_payload.get("delta_kcal") is not None:
595
+ entry["dft_delta"] = dft_payload.get("delta_kcal")
596
+ if seg.get("gibbs_dft_uma"):
597
+ gd_payload = seg.get("gibbs_dft_uma") or {}
598
+ if gd_payload.get("barrier_kcal") is not None:
599
+ entry["gibbs_dft_uma_barrier"] = gd_payload.get("barrier_kcal")
600
+ if gd_payload.get("delta_kcal") is not None:
601
+ entry["gibbs_dft_uma_delta"] = gd_payload.get("delta_kcal")
602
+ else:
603
+ lines.append(" (no post-processing results)")
604
+
605
+ if segment_entries:
606
+ table_rows = [
607
+ (f"MEP {delta}E{dagger} [kcal/mol]", "mep_barrier"),
608
+ (f"MEP {delta}E [kcal/mol]", "mep_delta"),
609
+ (f"UMA {delta}E{dagger} [kcal/mol]", "uma_barrier"),
610
+ (f"UMA {delta}E [kcal/mol]", "uma_delta"),
611
+ (f"UMA {delta}G{dagger} [kcal/mol]", "gibbs_uma_barrier"),
612
+ (f"UMA {delta}G [kcal/mol]", "gibbs_uma_delta"),
613
+ (f"DFT//UMA {delta}E{dagger} [kcal/mol]", "dft_barrier"),
614
+ (f"DFT//UMA {delta}E [kcal/mol]", "dft_delta"),
615
+ (f"DFT//UMA {delta}G{dagger} [kcal/mol]", "gibbs_dft_uma_barrier"),
616
+ (f"DFT//UMA {delta}G [kcal/mol]", "gibbs_dft_uma_delta"),
617
+ ]
618
+ sorted_entries = [segment_entries[k] for k in sorted(segment_entries.keys())]
619
+ headers = [f"{int(e.get('index', 0)):d}({e.get('tag', '-')})" for e in sorted_entries]
620
+ label_width = max(len(label) for label, _ in table_rows) + 2
621
+ col_width = max(max(len(h) for h in headers), 8)
622
+
623
+ lines.append("")
624
+ lines.append(" Segment overview table")
625
+ lines.append(
626
+ " "
627
+ + f"{'Seg':<{label_width}} "
628
+ + " ".join(f"{h:>{col_width}}" for h in headers)
629
+ )
630
+ for label, key in table_rows:
631
+ values = " ".join(_segment_table_value(entry, key, col_width) for entry in sorted_entries)
632
+ lines.append(f" {label:<{label_width}} {values}")
633
+
634
+ lines.append("")
635
+ lines.append("[4] Energy diagrams (overview)")
636
+ diagrams: Iterable[Dict[str, Any]] = payload.get("energy_diagrams", []) or []
637
+ diag_by_method: Dict[str, Dict[str, Any]] = {}
638
+ state_order: List[str] = []
639
+
640
+ if diagrams:
641
+ for diag_payload in diagrams:
642
+ image_path = diag_payload.get("image") or diag_payload.get("diagram")
643
+ if image_path and ("post_seg" in str(image_path) or "tsopt_seg_" in str(image_path)):
644
+ continue
645
+
646
+ name = diag_payload.get("name", "diagram")
647
+ ylabel = diag_payload.get("ylabel", f"{delta}E (kcal/mol)")
648
+ lines.append(f" {name} (ylabel: {ylabel})")
649
+ labels = diag_payload.get("labels", [])
650
+ energies = diag_payload.get("energies_kcal", [])
651
+ energy_label = f"{delta}G [kcal/mol]" if f"{delta}G" in str(ylabel) else f"{delta}E [kcal/mol]"
652
+ lines.append(f" State {energy_label}")
653
+ for i, lab in enumerate(labels):
654
+ rel = energies[i] if i < len(energies) else None
655
+ rel_txt = f"{rel:7.3f}" if rel is not None else " n/a"
656
+ lines.append(f" {lab:<8}{rel_txt}")
657
+ if diag_payload.get("image"):
658
+ lines.append(
659
+ f" Image : {_shorten_path(diag_payload.get('image'), root_out_path)}"
660
+ )
661
+
662
+ method_key = _classify_diagram_method(diag_payload)
663
+ diag_by_method.setdefault(method_key, diag_payload)
664
+ if not state_order and labels:
665
+ state_order = list(labels)
666
+ else:
667
+ lines.append(" (no energy diagrams recorded)")
668
+
669
+ if state_order and diag_by_method:
670
+ lines.append("")
671
+ lines.append(" Energy diagram overview table")
672
+
673
+ table_rows = [
674
+ (f"MEP {delta}E [kcal/mol]", "mep"),
675
+ (f"UMA {delta}E [kcal/mol]", "uma"),
676
+ (f"UMA {delta}G [kcal/mol]", "gibbs_uma"),
677
+ (f"DFT//UMA {delta}E [kcal/mol]", "dft"),
678
+ (f"DFT//UMA {delta}G [kcal/mol]", "gibbs_dft_uma"),
679
+ ]
680
+
681
+ label_width = max(len(label) for label, _ in table_rows) + 2
682
+ col_width = max(max(len(st) for st in state_order), 7)
683
+
684
+ lines.append(
685
+ " "
686
+ + f"{'State':<{label_width}} "
687
+ + " ".join(f"{st:>{col_width}}" for st in state_order)
688
+ )
689
+
690
+ for label, method in table_rows:
691
+ diag_payload = diag_by_method.get(method)
692
+ lines.append(_format_diag_row(diag_payload, label, col_width, state_order, label_width))
693
+
694
+ lines.append("")
695
+ lines.append("[5] Output directory structure")
696
+
697
+ key_files = payload.get("key_files") or {}
698
+ annotations: Dict[str, str] = {Path(k).as_posix(): v for k, v in key_files.items()}
699
+
700
+ default_notes = {
701
+ "pockets": "Extracted pocket PDBs",
702
+ "scan": "Staged scan outputs",
703
+ "path_search": "Recursive GSM outputs",
704
+ "path_opt": "Single-pass GSM outputs",
705
+ "tsopt_single": "Single-structure TSOPT-only outputs",
706
+ "mep_plot.png": "ML/MM MEP energy plot",
707
+ "energy_diagram_MEP.png": "Compressed MEP diagram",
708
+ "energy_diagram_UMA_all.png": "UMA R-TS-P energies (all segments)",
709
+ "energy_diagram_G_UMA_all.png": "UMA Gibbs R-TS-P (all segments)",
710
+ "energy_diagram_DFT_all.png": "DFT R-TS-P (all segments)",
711
+ "energy_diagram_G_DFT_plus_UMA_all.png": "DFT//UMA Gibbs R-TS-P (all segments)",
712
+ "irc_plot_all.png": "Aggregated IRC plot",
713
+ }
714
+
715
+ if root_out_path:
716
+ path_dir = payload.get("path_dir")
717
+ if path_dir:
718
+ try:
719
+ rel = Path(path_dir).relative_to(root_out_path).as_posix()
720
+ annotations.setdefault(rel, "Primary path module outputs")
721
+ except ValueError:
722
+ pass
723
+
724
+ for rel, desc in default_notes.items():
725
+ if (root_out_path / rel).exists():
726
+ annotations.setdefault(rel, desc)
727
+
728
+ if root_out_path.exists():
729
+ lines.extend(_format_directory_tree(root_out_path, annotations))
730
+ else:
731
+ lines.append(" (root output directory not found on disk)")
732
+ else:
733
+ lines.append(" (root output directory unknown)")
734
+
735
+ dest.parent.mkdir(parents=True, exist_ok=True)
736
+ dest.write_text("\n".join(lines) + "\n", encoding="utf-8")