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/scan2d.py ADDED
@@ -0,0 +1,1226 @@
1
+ """ML/MM two-distance (d1, d2) grid scan with harmonic restraints.
2
+
3
+ Example:
4
+ mlmm scan2d -i input.pdb --parm real.parm7 --model-pdb ml_region.pdb \
5
+ -q 0 --scan-lists "[(12,45,1.30,3.10),(10,55,1.20,3.20)]"
6
+
7
+ For detailed documentation, see: docs/scan2d.md
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import functools
13
+ from copy import deepcopy
14
+ from pathlib import Path
15
+ from typing import Any, Dict, List, Optional, Sequence
16
+
17
+ import gc
18
+ import logging
19
+ import math
20
+ import shutil
21
+ import sys
22
+ import textwrap
23
+ import traceback
24
+ import tempfile
25
+ import time
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ import click
30
+ import numpy as np
31
+ import torch
32
+ import pandas as pd
33
+ from scipy.interpolate import Rbf
34
+ import plotly.graph_objects as go
35
+
36
+ from pysisyphus.helpers import geom_loader
37
+ from pysisyphus.optimizers.LBFGS import LBFGS
38
+ from pysisyphus.optimizers.exceptions import OptimizationError, ZeroStepLength
39
+ from pysisyphus.constants import ANG2BOHR, AU2KCALPERMOL
40
+
41
+ from .mlmm_calc import mlmm
42
+ from .defaults import BIAS_KW as _BIAS_KW_DEFAULT
43
+ from .opt import (
44
+ GEOM_KW as _OPT_GEOM_KW,
45
+ CALC_KW as _OPT_CALC_KW,
46
+ OPT_BASE_KW as _OPT_BASE_KW,
47
+ LBFGS_KW as _OPT_LBFGS_KW,
48
+ HarmonicBiasCalculator,
49
+ _parse_freeze_atoms,
50
+ _normalize_geom_freeze,
51
+ )
52
+ from .utils import (
53
+ apply_ref_pdb_override,
54
+ apply_layer_freeze_constraints,
55
+ set_convert_file_enabled,
56
+ deep_update,
57
+ load_yaml_dict,
58
+ apply_yaml_overrides,
59
+ pretty_block,
60
+ strip_inherited_keys,
61
+ filter_calc_for_echo,
62
+ format_freeze_atoms_for_echo,
63
+ format_elapsed,
64
+ merge_freeze_atom_indices,
65
+ prepare_input_structure,
66
+ resolve_charge_spin_or_raise,
67
+ convert_xyz_to_pdb,
68
+ load_pdb_atom_metadata,
69
+ parse_scan_list_quads,
70
+ parse_scan_spec_quads,
71
+ is_scan_spec_file,
72
+ axis_label_csv,
73
+ axis_label_html,
74
+ PDB_ATOM_META_HEADER,
75
+ format_pdb_atom_metadata,
76
+ parse_indices_string,
77
+ build_model_pdb_from_bfactors,
78
+ build_model_pdb_from_indices,
79
+ ensure_dir,
80
+ distance_A_from_coords,
81
+ distance_tag,
82
+ values_from_bounds,
83
+ unbiased_energy_hartree,
84
+ snapshot_geometry,
85
+ convert_and_annotate_xyz_to_pdb,
86
+ )
87
+ from .cli_utils import resolve_yaml_sources, load_merged_yaml_cfg, make_is_param_explicit
88
+
89
+ # Shared defaults (copied from opt.py to keep ML/MM behaviour consistent)
90
+ GEOM_KW: Dict[str, Any] = deepcopy(_OPT_GEOM_KW)
91
+ CALC_KW: Dict[str, Any] = deepcopy(_OPT_CALC_KW)
92
+ OPT_BASE_KW: Dict[str, Any] = deepcopy(_OPT_BASE_KW)
93
+ OPT_BASE_KW.update(
94
+ {
95
+ "out_dir": "./result_scan2d/",
96
+ "dump": False, # Keep LBFGS runs light; per-grid TRJs are handled separately via --dump
97
+ "max_cycles": 10000, # Overridden per relaxation through --relax-max-cycles
98
+ }
99
+ )
100
+ LBFGS_KW: Dict[str, Any] = deepcopy(_OPT_LBFGS_KW)
101
+ LBFGS_KW.update({"out_dir": "./result_scan2d/"})
102
+ BIAS_KW: Dict[str, Any] = deepcopy(_BIAS_KW_DEFAULT)
103
+
104
+
105
+ _snapshot_geometry = functools.partial(snapshot_geometry, coord_type_default="cart")
106
+
107
+
108
+ def _select_closest_state(
109
+ states: Sequence[Dict[str, Any]],
110
+ d1_target: float,
111
+ d2_target: float,
112
+ ):
113
+ """
114
+ Return the Geometry from `states` whose (d1_A, d2_A) is closest to the target.
115
+
116
+ Each state is a dict with at least the keys "d1_A", "d2_A" and "geom".
117
+ """
118
+ if not states:
119
+ return None
120
+ best_state = min(
121
+ states,
122
+ key=lambda st: math.hypot(st["d1_A"] - d1_target, st["d2_A"] - d2_target),
123
+ )
124
+ return best_state.get("geom")
125
+
126
+
127
+ def _select_closest_state_1d(
128
+ states: Sequence[Dict[str, Any]],
129
+ d1_target: float,
130
+ ):
131
+ """
132
+ Return the Geometry from `states` whose d1_A is closest to the target.
133
+
134
+ Used for choosing the initial structure for the d1-only biased relaxation.
135
+ """
136
+ if not states:
137
+ return None
138
+ best_state = min(
139
+ states,
140
+ key=lambda st: abs(st["d1_A"] - d1_target),
141
+ )
142
+ return best_state.get("geom")
143
+
144
+
145
+ def _make_lbfgs(
146
+ geom,
147
+ lbfgs_cfg: Dict[str, Any],
148
+ opt_cfg: Dict[str, Any],
149
+ *,
150
+ max_step_bohr: float,
151
+ relax_max_cycles: int,
152
+ out_dir: Path,
153
+ prefix: str,
154
+ ) -> LBFGS:
155
+ common = dict(opt_cfg)
156
+ common["out_dir"] = str(out_dir)
157
+ common["prefix"] = prefix
158
+ args = {**lbfgs_cfg, **common}
159
+ args["max_step"] = min(float(lbfgs_cfg.get("max_step", 0.30)), max_step_bohr)
160
+ args["max_cycles"] = int(relax_max_cycles)
161
+ return LBFGS(geom, **args)
162
+
163
+
164
+ @click.command(
165
+ help="2D distance scan with harmonic restraints using the ML/MM calculator.",
166
+ context_settings={"help_option_names": ["-h", "--help"]},
167
+ )
168
+ @click.option(
169
+ "-i",
170
+ "--input",
171
+ "input_path",
172
+ type=click.Path(path_type=Path, exists=True, dir_okay=False),
173
+ required=True,
174
+ help="Input enzyme complex PDB (required).",
175
+ )
176
+ @click.option(
177
+ "--parm",
178
+ "real_parm7",
179
+ type=click.Path(path_type=Path, exists=True, dir_okay=False),
180
+ required=True,
181
+ help="Amber parm7 topology for the enzyme (required).",
182
+ )
183
+ @click.option(
184
+ "--model-pdb",
185
+ type=click.Path(path_type=Path, exists=True, dir_okay=False),
186
+ required=False,
187
+ help="PDB defining the ML region. Optional when --detect-layer is enabled.",
188
+ )
189
+ @click.option(
190
+ "--model-indices",
191
+ "model_indices_str",
192
+ type=str,
193
+ default=None,
194
+ show_default=False,
195
+ help="Comma-separated atom indices for the ML region (ranges allowed like 1-5). "
196
+ "Used when --model-pdb is omitted.",
197
+ )
198
+ @click.option(
199
+ "--model-indices-one-based/--model-indices-zero-based",
200
+ "model_indices_one_based",
201
+ default=True,
202
+ show_default=True,
203
+ help="Interpret --model-indices as 1-based (default) or 0-based.",
204
+ )
205
+ @click.option(
206
+ "--detect-layer/--no-detect-layer",
207
+ "detect_layer",
208
+ default=True,
209
+ show_default=True,
210
+ help="Detect ML/MM layers from input PDB B-factors (B=0/10/20). "
211
+ "If disabled, you must provide --model-pdb or --model-indices.",
212
+ )
213
+ @click.option("-q", "--charge", type=int, required=False,
214
+ help="ML-region total charge. Required unless --ligand-charge is provided.")
215
+ @click.option("-l", "--ligand-charge", type=str, default=None, show_default=False,
216
+ help="Total charge or per-resname mapping (e.g., GPP:-3,SAM:1) used to derive "
217
+ "charge when -q is omitted (requires PDB input or --ref-pdb).")
218
+ @click.option(
219
+ "-m",
220
+ "--multiplicity",
221
+ "spin",
222
+ type=int,
223
+ default=None,
224
+ show_default=False,
225
+ help="Spin multiplicity (2S+1) for the ML region. Defaults to 1 when omitted.",
226
+ )
227
+ @click.option(
228
+ "--freeze-atoms",
229
+ "freeze_atoms_cli",
230
+ type=str,
231
+ default=None,
232
+ show_default=False,
233
+ help='Comma-separated 1-based atom indices to freeze (e.g., "1,3,5").',
234
+ )
235
+ @click.option(
236
+ "--hess-cutoff",
237
+ "hess_cutoff",
238
+ type=float,
239
+ default=None,
240
+ show_default=False,
241
+ help="Distance cutoff (Å) from ML region for MM atoms to include in Hessian calculation. "
242
+ "Applied to movable MM atoms and can be combined with --detect-layer.",
243
+ )
244
+ @click.option(
245
+ "--movable-cutoff",
246
+ "movable_cutoff",
247
+ type=float,
248
+ default=None,
249
+ show_default=False,
250
+ help="Distance cutoff (Å) from ML region for movable MM atoms. MM atoms beyond this are frozen. "
251
+ "Providing --movable-cutoff disables --detect-layer.",
252
+ )
253
+ @click.option(
254
+ "-s", "--scan-lists",
255
+ "scan_list_raw",
256
+ type=str,
257
+ required=False,
258
+ help="Scan targets: inline Python literal or a YAML/JSON spec file path.",
259
+ )
260
+ @click.option(
261
+ "--one-based/--zero-based",
262
+ "one_based",
263
+ default=True,
264
+ show_default=True,
265
+ help="Interpret (i,j) indices in --scan-lists as 1-based (default) or 0-based.",
266
+ )
267
+ @click.option(
268
+ "--print-parsed/--no-print-parsed",
269
+ "print_parsed",
270
+ default=False,
271
+ show_default=True,
272
+ help="Print parsed scan targets after resolving --scan-lists.",
273
+ )
274
+ @click.option(
275
+ "--max-step-size",
276
+ type=float,
277
+ default=0.20,
278
+ show_default=True,
279
+ help="Maximum spacing between successive distance targets [Å].",
280
+ )
281
+ @click.option("--bias-k", type=float, default=300.0, show_default=True, help="Harmonic well strength k [eV/Å^2].")
282
+ @click.option(
283
+ "--relax-max-cycles",
284
+ type=int,
285
+ default=10000,
286
+ show_default=True,
287
+ help="Maximum LBFGS cycles per biased relaxation (also used for preopt).",
288
+ )
289
+ @click.option(
290
+ "--dump/--no-dump",
291
+ "dump",
292
+ default=False,
293
+ show_default=True,
294
+ help="Write inner d2 scan TRJs per d1 slice.",
295
+ )
296
+ @click.option(
297
+ "-o", "--out-dir",
298
+ type=str,
299
+ default="./result_scan2d/",
300
+ show_default=True,
301
+ help="Base output directory.",
302
+ )
303
+ @click.option(
304
+ "--thresh",
305
+ type=click.Choice(["gau_loose", "gau", "gau_tight", "gau_vtight", "baker", "never"], case_sensitive=False),
306
+ default="baker",
307
+ show_default=True,
308
+ help="Convergence preset.",
309
+ )
310
+ @click.option(
311
+ "--config",
312
+ "config_yaml",
313
+ type=click.Path(path_type=Path, exists=True, dir_okay=False),
314
+ default=None,
315
+ show_default=False,
316
+ help="Base YAML configuration file applied before explicit CLI options.",
317
+ )
318
+ @click.option(
319
+ "--ref-pdb",
320
+ type=click.Path(path_type=Path, exists=True, dir_okay=False),
321
+ default=None,
322
+ help="Reference PDB topology to use when --input is XYZ (keeps XYZ coordinates).",
323
+ )
324
+ @click.option(
325
+ "--preopt/--no-preopt",
326
+ "preopt",
327
+ default=False,
328
+ show_default=True,
329
+ help="Run an unbiased pre-optimization.",
330
+ )
331
+ @click.option(
332
+ "--baseline",
333
+ type=click.Choice(["min", "first"]),
334
+ default="min",
335
+ show_default=True,
336
+ help="Reference for relative energy (kcal/mol): 'min' or 'first' (i=0,j=0).",
337
+ )
338
+ @click.option(
339
+ "--zmin",
340
+ type=float,
341
+ default=None,
342
+ show_default=False,
343
+ help="Lower bound of the contour color scale (kcal/mol).",
344
+ )
345
+ @click.option(
346
+ "--zmax",
347
+ type=float,
348
+ default=None,
349
+ show_default=False,
350
+ help="Upper bound of the contour color scale (kcal/mol).",
351
+ )
352
+ @click.option(
353
+ "--convert-files/--no-convert-files",
354
+ "convert_files",
355
+ default=True,
356
+ show_default=True,
357
+ help="Convert XYZ/TRJ outputs into PDB companions based on the input format.",
358
+ )
359
+ @click.option(
360
+ "-b", "--backend",
361
+ type=click.Choice(["uma", "orb", "mace", "aimnet2"], case_sensitive=False),
362
+ default=None,
363
+ show_default=False,
364
+ help="ML backend for the ONIOM high-level region (default: uma).",
365
+ )
366
+ @click.option(
367
+ "--embedcharge/--no-embedcharge",
368
+ "embedcharge",
369
+ default=False,
370
+ show_default=True,
371
+ help="Enable xTB point-charge embedding correction for MM→ML environmental effects.",
372
+ )
373
+ @click.option(
374
+ "--embedcharge-cutoff",
375
+ "embedcharge_cutoff",
376
+ type=float,
377
+ default=None,
378
+ show_default=False,
379
+ help="Distance cutoff (Å) from ML region for MM point charges in xTB embedding. "
380
+ "Default: 12.0 Å when --embedcharge is enabled.",
381
+ )
382
+ @click.pass_context
383
+ def cli(
384
+ ctx: click.Context,
385
+ input_path: Path,
386
+ real_parm7: Path,
387
+ model_pdb: Optional[Path],
388
+ model_indices_str: Optional[str],
389
+ model_indices_one_based: bool,
390
+ detect_layer: bool,
391
+ charge: Optional[int],
392
+ ligand_charge: Optional[str],
393
+ spin: Optional[int],
394
+ freeze_atoms_cli: Optional[str],
395
+ hess_cutoff: Optional[float],
396
+ movable_cutoff: Optional[float],
397
+ scan_list_raw: Optional[str],
398
+ one_based: bool,
399
+ print_parsed: bool,
400
+ max_step_size: float,
401
+ bias_k: float,
402
+ relax_max_cycles: int,
403
+ dump: bool,
404
+ out_dir: str,
405
+ thresh: Optional[str],
406
+ config_yaml: Optional[Path],
407
+ ref_pdb: Optional[Path],
408
+ preopt: bool,
409
+ baseline: str,
410
+ zmin: Optional[float],
411
+ zmax: Optional[float],
412
+ convert_files: bool,
413
+ backend: Optional[str],
414
+ embedcharge: bool,
415
+ embedcharge_cutoff: Optional[float],
416
+ ) -> None:
417
+ _is_param_explicit = make_is_param_explicit(ctx)
418
+
419
+ set_convert_file_enabled(convert_files)
420
+ time_start = time.perf_counter()
421
+ config_yaml, override_yaml, used_legacy_yaml = resolve_yaml_sources(
422
+ config_yaml=config_yaml,
423
+ override_yaml=None,
424
+ args_yaml_legacy=None,
425
+ )
426
+
427
+ # Validate input format: PDB directly, or XYZ with --ref-pdb
428
+ suffix = input_path.suffix.lower()
429
+ if suffix not in (".pdb", ".xyz"):
430
+ click.echo("ERROR: --input must be a PDB or XYZ file.", err=True)
431
+ sys.exit(1)
432
+ if suffix == ".xyz" and ref_pdb is None:
433
+ click.echo("ERROR: --ref-pdb is required when --input is an XYZ file.", err=True)
434
+ sys.exit(1)
435
+
436
+ tmp_root = None
437
+ try:
438
+ with prepare_input_structure(input_path) as prepared_input:
439
+ try:
440
+ apply_ref_pdb_override(prepared_input, ref_pdb)
441
+ except click.BadParameter as e:
442
+ click.echo(f"ERROR: {e}", err=True)
443
+ sys.exit(1)
444
+ geom_input_path = prepared_input.geom_path
445
+ source_path = prepared_input.source_path
446
+ charge, spin = resolve_charge_spin_or_raise(
447
+ prepared_input, charge, spin,
448
+ ligand_charge=ligand_charge, prefix="[scan2d]",
449
+ )
450
+
451
+ try:
452
+ freeze_atoms_list = _parse_freeze_atoms(freeze_atoms_cli)
453
+ except click.BadParameter as exc:
454
+ click.echo(f"ERROR: {exc}", err=True)
455
+ sys.exit(1)
456
+
457
+ model_indices: Optional[List[int]] = None
458
+ if model_indices_str:
459
+ try:
460
+ model_indices = parse_indices_string(model_indices_str, one_based=model_indices_one_based)
461
+ except click.BadParameter as exc:
462
+ click.echo(f"ERROR: {exc}", err=True)
463
+ sys.exit(1)
464
+
465
+ yaml_cfg, _, _ = load_merged_yaml_cfg(
466
+ config_yaml=config_yaml,
467
+ override_yaml=None,
468
+ )
469
+
470
+ geom_cfg = dict(GEOM_KW)
471
+ calc_cfg = dict(CALC_KW)
472
+ opt_cfg = dict(OPT_BASE_KW)
473
+ lbfgs_cfg = dict(LBFGS_KW)
474
+ bias_cfg = dict(BIAS_KW)
475
+
476
+ apply_yaml_overrides(
477
+ yaml_cfg,
478
+ [
479
+ (geom_cfg, (("geom",),)),
480
+ (calc_cfg, (("calc",), ("mlmm",))),
481
+ (opt_cfg, (("opt",),)),
482
+ (lbfgs_cfg, (("lbfgs",), ("opt", "lbfgs"))),
483
+ (bias_cfg, (("bias",),)),
484
+ ],
485
+ )
486
+
487
+ try:
488
+ geom_freeze = _normalize_geom_freeze(geom_cfg.get("freeze_atoms"))
489
+ except click.BadParameter as exc:
490
+ click.echo(f"ERROR: {exc}", err=True)
491
+ sys.exit(1)
492
+ geom_cfg["freeze_atoms"] = geom_freeze
493
+ if freeze_atoms_list:
494
+ merge_freeze_atom_indices(geom_cfg, freeze_atoms_list)
495
+ freeze_atoms_final = list(geom_cfg.get("freeze_atoms") or [])
496
+ calc_cfg["freeze_atoms"] = freeze_atoms_final
497
+
498
+ opt_cfg["out_dir"] = out_dir
499
+ opt_cfg["dump"] = False
500
+ opt_cfg["max_cycles"] = int(relax_max_cycles)
501
+ if thresh is not None:
502
+ opt_cfg["thresh"] = str(thresh)
503
+ lbfgs_cfg["max_cycles"] = int(relax_max_cycles)
504
+ if bias_k is not None:
505
+ bias_cfg["k"] = float(bias_k)
506
+
507
+ out_dir_path = Path(opt_cfg["out_dir"]).resolve()
508
+
509
+ calc_cfg["model_charge"] = int(charge)
510
+ calc_cfg["model_mult"] = int(spin)
511
+ calc_cfg["input_pdb"] = str(source_path)
512
+ calc_cfg["real_parm7"] = str(real_parm7)
513
+ if backend is not None:
514
+ calc_cfg["backend"] = str(backend).lower()
515
+ if _is_param_explicit("embedcharge"):
516
+ calc_cfg["embedcharge"] = bool(embedcharge)
517
+ if _is_param_explicit("embedcharge_cutoff"):
518
+ calc_cfg["embedcharge_cutoff"] = embedcharge_cutoff
519
+
520
+ # movable_cutoff implies full distance-based layer assignment.
521
+ # hess_cutoff alone can be combined with --detect-layer.
522
+ if movable_cutoff is not None:
523
+ if detect_layer:
524
+ click.echo("[layer] --movable-cutoff provided; disabling --detect-layer.", err=True)
525
+ detect_layer = False
526
+
527
+ layer_source_pdb = source_path
528
+ if detect_layer and layer_source_pdb.suffix.lower() != ".pdb":
529
+ click.echo("ERROR: --detect-layer requires a PDB input (or --ref-pdb).", err=True)
530
+ sys.exit(1)
531
+
532
+ model_pdb_path: Optional[Path] = None
533
+ layer_info: Optional[Dict[str, List[int]]] = None
534
+
535
+ if detect_layer:
536
+ try:
537
+ model_pdb_path, layer_info = build_model_pdb_from_bfactors(layer_source_pdb, out_dir_path)
538
+ calc_cfg["use_bfactor_layers"] = True
539
+ click.echo(
540
+ f"[layer] Detected B-factor layers: ML={len(layer_info.get('ml_indices', []))}, "
541
+ f"MovableMM={len(layer_info.get('movable_mm_indices', []))}, "
542
+ f"FrozenMM={len(layer_info.get('frozen_indices', []))}"
543
+ )
544
+ except Exception as e:
545
+ if model_pdb is None and not model_indices:
546
+ click.echo(f"ERROR: {e}", err=True)
547
+ sys.exit(1)
548
+ click.echo(f"[layer] WARNING: {e} Falling back to explicit ML region.", err=True)
549
+ detect_layer = False
550
+
551
+ if not detect_layer:
552
+ if model_pdb is None and not model_indices:
553
+ click.echo("ERROR: Provide --model-pdb or --model-indices when --no-detect-layer.", err=True)
554
+ sys.exit(1)
555
+ if model_pdb is not None:
556
+ model_pdb_path = Path(model_pdb)
557
+ else:
558
+ if layer_source_pdb.suffix.lower() != ".pdb":
559
+ click.echo("ERROR: --model-indices requires a PDB input (or --ref-pdb).", err=True)
560
+ sys.exit(1)
561
+ try:
562
+ model_pdb_path = build_model_pdb_from_indices(layer_source_pdb, out_dir_path, model_indices or [])
563
+ except Exception as e:
564
+ click.echo(f"ERROR: {e}", err=True)
565
+ sys.exit(1)
566
+ calc_cfg["use_bfactor_layers"] = False
567
+
568
+ if model_pdb_path is None:
569
+ click.echo("ERROR: Failed to resolve model PDB for the ML region.", err=True)
570
+ sys.exit(1)
571
+
572
+ calc_cfg["model_pdb"] = str(model_pdb_path)
573
+ freeze_atoms_final = apply_layer_freeze_constraints(
574
+ geom_cfg,
575
+ calc_cfg,
576
+ layer_info,
577
+ echo_fn=click.echo,
578
+ )
579
+
580
+ # Distance-based overrides for Hessian-target and movable MM selection.
581
+ if hess_cutoff is not None:
582
+ calc_cfg["hess_cutoff"] = hess_cutoff
583
+ if movable_cutoff is not None:
584
+ calc_cfg["movable_cutoff"] = movable_cutoff
585
+ calc_cfg["use_bfactor_layers"] = False
586
+
587
+ for key in ("input_pdb", "real_parm7", "model_pdb", "mm_fd_dir"):
588
+ val = calc_cfg.get(key)
589
+ if val:
590
+ calc_cfg[key] = str(Path(val).expanduser().resolve())
591
+ ensure_dir(out_dir_path)
592
+
593
+ ref_pdb_resolve = source_path.resolve()
594
+
595
+ click.echo(pretty_block("geom", format_freeze_atoms_for_echo(geom_cfg, key="freeze_atoms")))
596
+ echo_calc = format_freeze_atoms_for_echo(filter_calc_for_echo(calc_cfg), key="freeze_atoms")
597
+ click.echo(pretty_block("calc", echo_calc))
598
+ echo_opt = strip_inherited_keys({**opt_cfg, "out_dir": str(out_dir_path)}, OPT_BASE_KW, mode="same")
599
+ click.echo(pretty_block("opt", echo_opt))
600
+ # Show only lbfgs-specific settings, not inherited from opt_cfg
601
+ echo_lbfgs = strip_inherited_keys(lbfgs_cfg, opt_cfg)
602
+ click.echo(pretty_block("lbfgs", echo_lbfgs))
603
+ click.echo(pretty_block("bias", bias_cfg))
604
+
605
+ pdb_atom_meta: List[Dict[str, Any]] = []
606
+ if source_path.suffix.lower() == ".pdb":
607
+ pdb_atom_meta = load_pdb_atom_metadata(source_path)
608
+
609
+ if scan_list_raw is None:
610
+ raise click.BadParameter("--scan-lists is required.")
611
+ scan_one_based = bool(one_based)
612
+ scan_source = "--scan-lists"
613
+ if is_scan_spec_file(scan_list_raw):
614
+ spec_path = Path(scan_list_raw)
615
+ parsed, raw_pairs, scan_one_based = parse_scan_spec_quads(
616
+ spec_path,
617
+ expected_len=2,
618
+ one_based_default=one_based,
619
+ atom_meta=pdb_atom_meta,
620
+ option_name="--scan-lists",
621
+ )
622
+ scan_source = f"--scan-lists ({spec_path})"
623
+ else:
624
+ parsed, raw_pairs = parse_scan_list_quads(
625
+ scan_list_raw,
626
+ expected_len=2,
627
+ one_based=scan_one_based,
628
+ atom_meta=pdb_atom_meta,
629
+ option_name="--scan-lists",
630
+ )
631
+ (i1, j1, low1, high1), (i2, j2, low2, high2) = parsed
632
+ d1_label_csv = axis_label_csv("d1", i1, j1, scan_one_based, pdb_atom_meta, raw_pairs[0])
633
+ d2_label_csv = axis_label_csv("d2", i2, j2, scan_one_based, pdb_atom_meta, raw_pairs[1])
634
+ d1_label_html = axis_label_html(d1_label_csv)
635
+ d2_label_html = axis_label_html(d2_label_csv)
636
+ if print_parsed:
637
+ click.echo(
638
+ pretty_block(
639
+ "scan-parsed",
640
+ {
641
+ "source": scan_source,
642
+ "one_based": bool(scan_one_based),
643
+ "pairs_0based": parsed,
644
+ },
645
+ )
646
+ )
647
+ click.echo(
648
+ pretty_block(
649
+ "scan-list (0-based)",
650
+ {"d1": (i1, j1, low1, high1), "d2": (i2, j2, low2, high2)},
651
+ )
652
+ )
653
+ if pdb_atom_meta:
654
+ click.echo("[scan2d] PDB atom details for scanned pairs:")
655
+ legend = PDB_ATOM_META_HEADER
656
+ click.echo(f" legend: {legend}")
657
+ click.echo(f" d1 i: {format_pdb_atom_metadata(pdb_atom_meta, i1)}")
658
+ click.echo(f" j: {format_pdb_atom_metadata(pdb_atom_meta, j1)}")
659
+ click.echo(f" d2 i: {format_pdb_atom_metadata(pdb_atom_meta, i2)}")
660
+ click.echo(f" j: {format_pdb_atom_metadata(pdb_atom_meta, j2)}")
661
+
662
+ # Directory layout: final outputs under out_dir/, optimizer scratch in a temporary directory
663
+ tmp_root = Path(tempfile.mkdtemp(prefix="scan2d_tmp_"))
664
+ grid_dir = out_dir_path / "grid"
665
+ tmp_opt_dir = tmp_root / "opt"
666
+ ensure_dir(grid_dir)
667
+ ensure_dir(tmp_opt_dir)
668
+ final_dir = out_dir_path
669
+
670
+ coord_type = geom_cfg.get("coord_type", "cart")
671
+ geom_outer = geom_loader(geom_input_path, coord_type=coord_type)
672
+ freeze = list(geom_cfg.get("freeze_atoms") or [])
673
+ if freeze:
674
+ try:
675
+ geom_outer.freeze_atoms = np.array(freeze, dtype=int)
676
+ except Exception:
677
+ logger.debug("Failed to set freeze_atoms on geometry", exc_info=True)
678
+
679
+ base_calc = mlmm(**calc_cfg)
680
+ biased = HarmonicBiasCalculator(base_calc, k=float(bias_cfg["k"]))
681
+
682
+ if preopt:
683
+ click.echo("[preopt] Unbiased relaxation of the initial structure ...")
684
+ geom_outer.set_calculator(base_calc)
685
+ optimizer0 = _make_lbfgs(
686
+ geom_outer,
687
+ lbfgs_cfg,
688
+ opt_cfg,
689
+ max_step_bohr=float(max_step_size) * ANG2BOHR,
690
+ relax_max_cycles=relax_max_cycles,
691
+ out_dir=tmp_opt_dir,
692
+ prefix="preopt",
693
+ )
694
+ try:
695
+ optimizer0.run()
696
+ except ZeroStepLength:
697
+ click.echo("[preopt] ZeroStepLength — continuing.", err=True)
698
+ except OptimizationError as exc:
699
+ click.echo(f"[preopt] OptimizationError — {exc}", err=True)
700
+
701
+ records: List[Dict[str, Any]] = []
702
+ # Keep track of previously visited structures (preopt + biased scans)
703
+ # so that we can always start each new scan from the closest one in
704
+ # terms of the scanned distances.
705
+ grid_states: List[Dict[str, Any]] = []
706
+
707
+ # Measure reference distances on the (pre)optimized structure
708
+ d1_ref = distance_A_from_coords(np.asarray(geom_outer.coords).reshape(-1, 3), i1, j1)
709
+ d2_ref = distance_A_from_coords(np.asarray(geom_outer.coords).reshape(-1, 3), i2, j2)
710
+ if math.isfinite(d1_ref) and math.isfinite(d2_ref):
711
+ click.echo(
712
+ f"[center] reference distances from (pre)optimized structure: "
713
+ f"d1 = {d1_ref:.3f} Å, d2 = {d2_ref:.3f} Å"
714
+ )
715
+
716
+ # Write preoptimized structure into the grid directory with distance-based name
717
+ d1_ref_tag = distance_tag(d1_ref)
718
+ d2_ref_tag = distance_tag(d2_ref)
719
+ preopt_xyz_path = grid_dir / f"preopt_i{d1_ref_tag}_j{d2_ref_tag}.xyz"
720
+ try:
721
+ xyz_pre = geom_outer.as_xyz()
722
+ if not xyz_pre.endswith("\n"):
723
+ xyz_pre += "\n"
724
+ with open(preopt_xyz_path, "w") as handle:
725
+ handle.write(xyz_pre)
726
+
727
+ convert_and_annotate_xyz_to_pdb(
728
+ preopt_xyz_path,
729
+ ref_pdb_resolve,
730
+ preopt_xyz_path.with_suffix(".pdb"),
731
+ model_pdb_path,
732
+ freeze_atoms_final,
733
+ )
734
+ except Exception as exc:
735
+ click.echo(
736
+ f"[write] WARNING: failed to write or convert {preopt_xyz_path.name}: {exc}",
737
+ err=True,
738
+ )
739
+
740
+ # Unbiased energy of the (pre)optimized structure for inclusion in the PES
741
+ preopt_energy_h = unbiased_energy_hartree(geom_outer, base_calc)
742
+ records.append(
743
+ {
744
+ "i": -1,
745
+ "j": -1,
746
+ "d1_A": float(d1_ref),
747
+ "d2_A": float(d2_ref),
748
+ "energy_hartree": preopt_energy_h,
749
+ "bias_converged": True,
750
+ "is_preopt": True,
751
+ }
752
+ )
753
+ # Also store a snapshot of this structure as the first candidate
754
+ # starting point for subsequent biased scans.
755
+ grid_states.append(
756
+ {
757
+ "d1_A": float(d1_ref),
758
+ "d2_A": float(d2_ref),
759
+ "geom": _snapshot_geometry(geom_outer),
760
+ }
761
+ )
762
+ else:
763
+ click.echo(
764
+ "[center] WARNING: failed to determine reference distances; using grid order as-is.",
765
+ err=True,
766
+ )
767
+ d1_ref_tag = None
768
+ d2_ref_tag = None
769
+
770
+ # Build distance grids and reorder so that scanning starts near the reference structure
771
+ d1_values = values_from_bounds(low1, high1, float(max_step_size))
772
+ d2_values = values_from_bounds(low2, high2, float(max_step_size))
773
+
774
+ if math.isfinite(d1_ref):
775
+ d1_values = np.array(
776
+ sorted(d1_values, key=lambda v: abs(v - d1_ref)),
777
+ dtype=float,
778
+ )
779
+ if math.isfinite(d2_ref):
780
+ d2_values = np.array(
781
+ sorted(d2_values, key=lambda v: abs(v - d2_ref)),
782
+ dtype=float,
783
+ )
784
+
785
+ N1, N2 = len(d1_values), len(d2_values)
786
+ click.echo(f"[grid] d1 steps = {N1} values(A)={list(map(lambda x: f'{x:.3f}', d1_values))}")
787
+ click.echo(f"[grid] d2 steps = {N2} values(A)={list(map(lambda x: f'{x:.3f}', d2_values))}")
788
+ click.echo(f"[grid] total grid points = {N1 * N2}")
789
+
790
+ max_step_bohr = float(max_step_size) * ANG2BOHR
791
+
792
+ for i_idx, d1_target in enumerate(d1_values):
793
+ d1_tag = distance_tag(d1_target)
794
+ click.echo(f"\n--- d1 step {i_idx + 1}/{N1} : target = {d1_target:.3f} Å ---")
795
+
796
+ # Choose the closest previously visited structure (in d1) as the
797
+ # starting point for the d1-biased relaxation.
798
+ start_outer = _select_closest_state_1d(grid_states, float(d1_target))
799
+ if start_outer is None:
800
+ start_outer = geom_outer
801
+ geom_outer = _snapshot_geometry(start_outer)
802
+
803
+ geom_outer.set_calculator(biased)
804
+ biased.set_pairs([(i1, j1, float(d1_target))])
805
+ geom_outer.set_calculator(biased)
806
+
807
+ opt1 = _make_lbfgs(
808
+ geom_outer,
809
+ lbfgs_cfg,
810
+ opt_cfg,
811
+ max_step_bohr=max_step_bohr,
812
+ relax_max_cycles=relax_max_cycles,
813
+ out_dir=tmp_opt_dir,
814
+ prefix=f"d1_{d1_tag}",
815
+ )
816
+ try:
817
+ opt1.run()
818
+ except ZeroStepLength:
819
+ click.echo(f"[d1 {i_idx}] ZeroStepLength — continuing to d2 scan.", err=True)
820
+ except OptimizationError as exc:
821
+ click.echo(f"[d1 {i_idx}] OptimizationError — {exc}", err=True)
822
+
823
+ # Record the relaxed (d1-biased) structure as another candidate
824
+ # starting point for subsequent grid points.
825
+ d1_cur_outer = distance_A_from_coords(np.asarray(geom_outer.coords).reshape(-1, 3), i1, j1)
826
+ d2_cur_outer = distance_A_from_coords(np.asarray(geom_outer.coords).reshape(-1, 3), i2, j2)
827
+ if math.isfinite(d1_cur_outer) and math.isfinite(d2_cur_outer):
828
+ grid_states.append(
829
+ {
830
+ "d1_A": float(d1_cur_outer),
831
+ "d2_A": float(d2_cur_outer),
832
+ "geom": _snapshot_geometry(geom_outer),
833
+ }
834
+ )
835
+
836
+ trj_blocks = [] if dump else None
837
+
838
+ for j_idx, d2_target in enumerate(d2_values):
839
+ d2_tag = distance_tag(d2_target)
840
+
841
+ # For each (d1, d2) grid point, choose as initial structure the
842
+ # previously visited geometry whose scanned distances are closest
843
+ # to the current targets.
844
+ start_inner = _select_closest_state(grid_states, float(d1_target), float(d2_target))
845
+ if start_inner is None:
846
+ # Fallback: use the d1-relaxed structure for this slice.
847
+ start_inner = geom_outer
848
+ geom_inner = _snapshot_geometry(start_inner)
849
+ geom_inner.set_calculator(biased)
850
+
851
+ biased.set_pairs([(i1, j1, float(d1_target)), (i2, j2, float(d2_target))])
852
+
853
+ opt2 = _make_lbfgs(
854
+ geom_inner,
855
+ lbfgs_cfg,
856
+ opt_cfg,
857
+ max_step_bohr=max_step_bohr,
858
+ relax_max_cycles=relax_max_cycles,
859
+ out_dir=tmp_opt_dir,
860
+ prefix=f"d1_{d1_tag}_d2_{d2_tag}",
861
+ )
862
+ try:
863
+ opt2.run()
864
+ converged = True
865
+ except ZeroStepLength:
866
+ click.echo(
867
+ f"[d1 {i_idx}, d2 {j_idx}] ZeroStepLength — recorded anyway.",
868
+ err=True,
869
+ )
870
+ converged = False
871
+ except OptimizationError as exc:
872
+ click.echo(f"[d1 {i_idx}, d2 {j_idx}] OptimizationError — {exc}", err=True)
873
+ converged = False
874
+
875
+ energy_h = unbiased_energy_hartree(geom_inner, base_calc)
876
+
877
+ # Record this grid point as a new candidate starting structure
878
+ # for subsequent scans.
879
+ d1_cur = distance_A_from_coords(np.asarray(geom_inner.coords).reshape(-1, 3), i1, j1)
880
+ d2_cur = distance_A_from_coords(np.asarray(geom_inner.coords).reshape(-1, 3), i2, j2)
881
+ if math.isfinite(d1_cur) and math.isfinite(d2_cur):
882
+ grid_states.append(
883
+ {
884
+ "d1_A": float(d1_cur),
885
+ "d2_A": float(d2_cur),
886
+ "geom": _snapshot_geometry(geom_inner),
887
+ }
888
+ )
889
+
890
+ # Distance-based filenames: e.g., point_i125_j324.xyz for d1=1.25 Å, d2=3.24 Å
891
+ xyz_path = grid_dir / f"point_i{d1_tag}_j{d2_tag}.xyz"
892
+ try:
893
+ xyz = geom_inner.as_xyz()
894
+ if not xyz.endswith("\n"):
895
+ xyz += "\n"
896
+ with open(xyz_path, "w") as handle:
897
+ handle.write(xyz)
898
+
899
+ # Convert grid-point XYZ to PDB (with B-factor annotation)
900
+ convert_and_annotate_xyz_to_pdb(
901
+ xyz_path,
902
+ ref_pdb_resolve,
903
+ xyz_path.with_suffix(".pdb"),
904
+ model_pdb_path,
905
+ freeze_atoms_final,
906
+ )
907
+ except Exception as exc:
908
+ click.echo(
909
+ f"[write] WARNING: failed to write or convert {xyz_path.name}: {exc}",
910
+ err=True,
911
+ )
912
+
913
+ if dump and trj_blocks is not None:
914
+ block = geom_inner.as_xyz()
915
+ if not block.endswith("\n"):
916
+ block += "\n"
917
+ trj_blocks.append(block)
918
+
919
+ records.append(
920
+ {
921
+ "i": int(i_idx),
922
+ "j": int(j_idx),
923
+ "d1_A": float(d1_target),
924
+ "d2_A": float(d2_target),
925
+ "energy_hartree": energy_h,
926
+ "bias_converged": bool(converged),
927
+ "is_preopt": False,
928
+ }
929
+ )
930
+
931
+ if dump and trj_blocks:
932
+ # Distance-based filename for inner path as well
933
+ trj_path = grid_dir / f"inner_path_d1_{d1_tag}_trj.xyz"
934
+ try:
935
+ with open(trj_path, "w") as handle:
936
+ handle.write("".join(trj_blocks))
937
+ click.echo(f"[write] Wrote '{trj_path}'.")
938
+
939
+ # Convert inner-path TRJ to multi-model PDB (with B-factor annotation)
940
+ convert_and_annotate_xyz_to_pdb(
941
+ trj_path,
942
+ ref_pdb_resolve,
943
+ trj_path.with_suffix(".pdb"),
944
+ model_pdb_path,
945
+ freeze_atoms_final,
946
+ )
947
+ except Exception as exc:
948
+ click.echo(
949
+ f"[write] WARNING: failed to write or convert '{trj_path}': {exc}",
950
+ err=True,
951
+ )
952
+
953
+ df = pd.DataFrame.from_records(records)
954
+ if df.empty:
955
+ click.echo("No grid records produced; aborting.", err=True)
956
+ sys.exit(1)
957
+
958
+ if baseline == "first":
959
+ mask = (df["i"] == 0) & (df["j"] == 0)
960
+ if mask.sum() == 0:
961
+ click.echo("WARNING: baseline='first' but grid point (0,0) not found; falling back to min.", err=True)
962
+ ref = float(df["energy_hartree"].min())
963
+ else:
964
+ ref = float(df.loc[mask, "energy_hartree"].iloc[0])
965
+ else:
966
+ ref = float(df["energy_hartree"].min())
967
+ df["energy_kcal"] = (df["energy_hartree"] - ref) * AU2KCALPERMOL
968
+ df["d1_label"] = d1_label_csv
969
+ df["d2_label"] = d2_label_csv
970
+
971
+ surface_csv = final_dir / "surface.csv"
972
+ df.to_csv(surface_csv, index=False)
973
+ click.echo(f"[write] Wrote '{surface_csv}'.")
974
+
975
+ # ===== Plots (RBF on a fixed 50×50 grid, unified layout, placed under final_dir) =====
976
+ d1_points = df["d1_A"].to_numpy(dtype=float)
977
+ d2_points = df["d2_A"].to_numpy(dtype=float)
978
+ z_points = df["energy_kcal"].to_numpy(dtype=float)
979
+ mask = np.isfinite(d1_points) & np.isfinite(d2_points) & np.isfinite(z_points)
980
+ if not np.any(mask):
981
+ click.echo("[plot] No finite data for plotting.", err=True)
982
+ sys.exit(1)
983
+
984
+ x_min, x_max = float(np.min(d1_points[mask])), float(np.max(d1_points[mask]))
985
+ y_min, y_max = float(np.min(d2_points[mask])), float(np.max(d2_points[mask]))
986
+
987
+ xi = np.linspace(x_min, x_max, 50)
988
+ yi = np.linspace(y_min, y_max, 50)
989
+ XI, YI = np.meshgrid(xi, yi)
990
+
991
+ rbf = Rbf(d1_points[mask], d2_points[mask], z_points[mask], function="multiquadric")
992
+ ZI = rbf(XI, YI)
993
+
994
+ vmin = float(np.nanmin(ZI)) if zmin is None else float(zmin)
995
+ vmax = float(np.nanmax(ZI)) if zmax is None else float(zmax)
996
+ if not np.isfinite(vmin) or not np.isfinite(vmax) or vmax <= vmin:
997
+ vmin, vmax = float(np.nanmin(ZI)), float(np.nanmax(ZI))
998
+
999
+ # Choose neat contour/tick steps
1000
+ def _nice_step(span: float) -> float:
1001
+ if span <= 0:
1002
+ return 1.0
1003
+ raw = span / 6.0
1004
+ mag = 10 ** math.floor(math.log10(raw))
1005
+ candidates = (0.5, 1, 2, 5, 10, 20)
1006
+ # start with the first candidate
1007
+ best = candidates[0] * mag
1008
+ best_err = abs(best - raw)
1009
+ for m in candidates[1:]:
1010
+ s = m * mag
1011
+ err = abs(s - raw)
1012
+ if err < best_err:
1013
+ best, best_err = s, err
1014
+ return best
1015
+
1016
+ c_step = _nice_step(vmax - vmin)
1017
+ c_start = math.floor(vmin / c_step) * c_step
1018
+ c_end = math.ceil(vmax / c_step) * c_step
1019
+
1020
+ # ---- 2D contour plot (PNG with explicit size) ----
1021
+ fig2d = go.Figure(
1022
+ data=go.Contour(
1023
+ z=ZI,
1024
+ x=xi,
1025
+ y=yi,
1026
+ contours=dict(start=c_start, end=c_end, size=c_step),
1027
+ zmin=vmin,
1028
+ zmax=vmax,
1029
+ contours_coloring="heatmap",
1030
+ colorscale="plasma",
1031
+ colorbar=dict(
1032
+ title=dict(text="(kcal/mol)", side="top", font=dict(size=16, color="#1C1C1C")),
1033
+ tickfont=dict(size=14, color="#1C1C1C"),
1034
+ ticks="inside",
1035
+ ticklen=10,
1036
+ tickcolor="#1C1C1C",
1037
+ outlinecolor="#1C1C1C",
1038
+ outlinewidth=2,
1039
+ lenmode="fraction",
1040
+ len=1.11,
1041
+ x=1.05,
1042
+ y=0.53,
1043
+ xanchor="left",
1044
+ yanchor="middle",
1045
+ ),
1046
+ )
1047
+ )
1048
+ fig2d.update_layout(
1049
+ width=640,
1050
+ height=600,
1051
+ xaxis_title=d1_label_html,
1052
+ yaxis_title=d2_label_html,
1053
+ plot_bgcolor="white",
1054
+ xaxis=dict(
1055
+ range=[x_min, x_max],
1056
+ showline=True,
1057
+ linewidth=3,
1058
+ linecolor="#1C1C1C",
1059
+ mirror=True,
1060
+ tickson="boundaries",
1061
+ ticks="inside",
1062
+ tickwidth=3,
1063
+ tickcolor="#1C1C1C",
1064
+ title_font=dict(size=18, color="#1C1C1C"),
1065
+ tickfont=dict(size=18, color="#1C1C1C"),
1066
+ tickvals=list(np.linspace(x_min, x_max, 6)),
1067
+ tickformat=".2f",
1068
+ ),
1069
+ yaxis=dict(
1070
+ range=[y_min, y_max],
1071
+ showline=True,
1072
+ linewidth=3,
1073
+ linecolor="#1C1C1C",
1074
+ mirror=True,
1075
+ tickson="boundaries",
1076
+ ticks="inside",
1077
+ tickwidth=3,
1078
+ tickcolor="#1C1C1C",
1079
+ title_font=dict(size=18, color="#1C1C1C"),
1080
+ tickfont=dict(size=18, color="#1C1C1C"),
1081
+ tickvals=list(np.linspace(y_min, y_max, 6)),
1082
+ tickformat=".2f",
1083
+ ),
1084
+ margin=dict(l=10, r=10, b=10, t=40),
1085
+ )
1086
+ png2d = final_dir / "scan2d_map.png"
1087
+ fig2d.write_image(str(png2d), scale=2, engine="kaleido", width=680, height=600)
1088
+ click.echo(f"[plot] Wrote '{png2d}'.")
1089
+
1090
+ # ---- 3D surface plus base-plane projection ----
1091
+ spread = vmax - vmin if (vmax > vmin) else 1.0
1092
+ z_bottom = vmin - spread
1093
+ z_top = vmax
1094
+
1095
+ # Avoid ticks below zmin (= vmin) and snap to sensible values
1096
+ z_step = _nice_step(vmax - vmin)
1097
+ z_start_tick = math.ceil(vmin / z_step) * z_step # First tick must be ≥ vmin
1098
+ z_ticks = np.arange(z_start_tick, z_top + 0.5 * z_step, z_step).tolist()
1099
+
1100
+ surface3d = go.Surface(
1101
+ x=XI,
1102
+ y=YI,
1103
+ z=ZI,
1104
+ colorscale="plasma",
1105
+ cmin=vmin,
1106
+ cmax=vmax,
1107
+ colorbar=dict(
1108
+ title=dict(text="(kcal/mol)", side="top", font=dict(size=16, color="#1C1C1C")),
1109
+ tickfont=dict(size=14, color="#1C1C1C"),
1110
+ ticks="inside",
1111
+ ticklen=10,
1112
+ tickcolor="#1C1C1C",
1113
+ outlinecolor="#1C1C1C",
1114
+ outlinewidth=2,
1115
+ lenmode="fraction",
1116
+ len=1.11,
1117
+ x=1.05,
1118
+ y=0.53,
1119
+ xanchor="left",
1120
+ yanchor="middle",
1121
+ ),
1122
+ contours={
1123
+ "z": {
1124
+ "show": True,
1125
+ "start": c_start,
1126
+ "end": c_end,
1127
+ "size": c_step,
1128
+ "color": "black",
1129
+ "project": {"z": True},
1130
+ }
1131
+ },
1132
+ name="3D Surface",
1133
+ )
1134
+
1135
+ plane_proj = go.Surface(
1136
+ x=XI,
1137
+ y=YI,
1138
+ z=np.full_like(ZI, z_bottom),
1139
+ surfacecolor=ZI,
1140
+ colorscale="plasma",
1141
+ cmin=vmin,
1142
+ cmax=vmax,
1143
+ showscale=False,
1144
+ opacity=1.0,
1145
+ name="2D Contour Projection (Bottom)",
1146
+ )
1147
+
1148
+ fig3d = go.Figure(data=[surface3d, plane_proj])
1149
+ fig3d.update_layout(
1150
+ title="Energy Landscape with 2D PES Scan",
1151
+ width=800,
1152
+ height=700,
1153
+ scene=dict(
1154
+ bgcolor="rgba(0,0,0,0)",
1155
+ xaxis=dict(
1156
+ title=d1_label_html,
1157
+ range=[x_min, x_max],
1158
+ showline=True,
1159
+ linewidth=4,
1160
+ linecolor="#1C1C1C",
1161
+ mirror=True,
1162
+ ticks="inside",
1163
+ tickwidth=4,
1164
+ tickcolor="#1C1C1C",
1165
+ gridcolor="rgba(0,0,0,0.1)",
1166
+ zerolinecolor="rgba(0,0,0,0.1)",
1167
+ showbackground=False,
1168
+ ),
1169
+ yaxis=dict(
1170
+ title=d2_label_html,
1171
+ range=[y_min, y_max],
1172
+ showline=True,
1173
+ linewidth=4,
1174
+ linecolor="#1C1C1C",
1175
+ mirror=True,
1176
+ ticks="inside",
1177
+ tickwidth=4,
1178
+ tickcolor="#1C1C1C",
1179
+ gridcolor="rgba(0,0,0,0.1)",
1180
+ zerolinecolor="rgba(0,0,0,0.1)",
1181
+ showbackground=False,
1182
+ ),
1183
+ zaxis=dict(
1184
+ title="Potential Energy (kcal/mol)",
1185
+ range=[z_bottom, z_top],
1186
+ tickmode="array",
1187
+ tickvals=z_ticks,
1188
+ showline=True,
1189
+ linewidth=4,
1190
+ linecolor="#1C1C1C",
1191
+ mirror=True,
1192
+ ticks="inside",
1193
+ tickwidth=4,
1194
+ tickcolor="#1C1C1C",
1195
+ showgrid=True,
1196
+ gridcolor="rgba(0,0,0,0.1)",
1197
+ zerolinecolor="rgba(0,0,0,0.1)",
1198
+ showbackground=False,
1199
+ ),
1200
+ ),
1201
+ margin=dict(l=10, r=20, b=10, t=40),
1202
+ paper_bgcolor="white",
1203
+ )
1204
+
1205
+ html3d = final_dir / "scan2d_landscape.html"
1206
+ fig3d.write_html(str(html3d))
1207
+ click.echo(f"[plot] Wrote '{html3d}'.")
1208
+
1209
+ click.echo("\n=== 2D Scan finished ===\n")
1210
+ click.echo(format_elapsed("[time] Elapsed Time for 2D Scan", time_start))
1211
+
1212
+ except KeyboardInterrupt:
1213
+ click.echo("\nInterrupted by user.", err=True)
1214
+ sys.exit(130)
1215
+ except Exception as exc:
1216
+ tb = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
1217
+ click.echo("Unhandled exception during 2D scan:\n" + textwrap.indent(tb, " "), err=True)
1218
+ sys.exit(1)
1219
+ finally:
1220
+ if tmp_root is not None:
1221
+ shutil.rmtree(tmp_root, ignore_errors=True)
1222
+ # Release GPU memory so subsequent pipeline stages don't OOM
1223
+ base_calc = geom_outer = geom_inner = optimizer0 = None
1224
+ gc.collect() # break cyclic refs inside torch.nn.Module
1225
+ if torch.cuda.is_available():
1226
+ torch.cuda.empty_cache()