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/scan.py ADDED
@@ -0,0 +1,1047 @@
1
+ # mlmm/scan.py
2
+
3
+ """
4
+ ML/MM staged bond-length scan with harmonic restraints.
5
+
6
+ Example:
7
+ mlmm scan -i pocket.pdb --parm real.parm7 --model-pdb ml_region.pdb -q 0 --scan-lists "[(12,45,2.20)]"
8
+
9
+ For detailed documentation, see: docs/scan.md
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from copy import deepcopy
15
+ from pathlib import Path
16
+ from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
17
+
18
+ import gc
19
+ import logging
20
+ import math
21
+ import sys
22
+ import textwrap
23
+ import traceback
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ import click
28
+ import numpy as np
29
+ import time
30
+ import torch
31
+
32
+ from pysisyphus.helpers import geom_loader
33
+ from pysisyphus.optimizers.LBFGS import LBFGS
34
+ from pysisyphus.optimizers.exceptions import OptimizationError, ZeroStepLength
35
+ from pysisyphus.constants import BOHR2ANG, ANG2BOHR
36
+
37
+ from .mlmm_calc import mlmm
38
+ from .defaults import (
39
+ BIAS_KW as _BIAS_KW_DEFAULT,
40
+ BOND_KW as _BOND_KW_DEFAULT,
41
+ )
42
+ from .opt import (
43
+ GEOM_KW as _OPT_GEOM_KW,
44
+ CALC_KW as _OPT_CALC_KW,
45
+ OPT_BASE_KW as _OPT_BASE_KW,
46
+ LBFGS_KW as _OPT_LBFGS_KW,
47
+ HarmonicBiasCalculator,
48
+ _parse_freeze_atoms,
49
+ _normalize_geom_freeze,
50
+ )
51
+ from .utils import (
52
+ apply_ref_pdb_override,
53
+ apply_layer_freeze_constraints,
54
+ convert_xyz_to_pdb,
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
+ collect_single_option_values,
68
+ load_pdb_atom_metadata,
69
+ parse_scan_list_triples,
70
+ parse_scan_spec_stages,
71
+ is_scan_spec_file,
72
+ PDB_ATOM_META_HEADER,
73
+ format_pdb_atom_metadata,
74
+ parse_indices_string,
75
+ build_model_pdb_from_bfactors,
76
+ build_model_pdb_from_indices,
77
+ snapshot_geometry,
78
+ )
79
+ from .bond_changes import compare_structures, summarize_changes
80
+ from .cli_utils import resolve_yaml_sources, load_merged_yaml_cfg, make_is_param_explicit
81
+
82
+
83
+ # --------------------------------------------------------------------------------------
84
+ # Defaults (merge order: defaults ← YAML ← CLI)
85
+ # --------------------------------------------------------------------------------------
86
+
87
+ # Geometry handling (Cartesian recommended for scans)
88
+ GEOM_KW: Dict[str, Any] = deepcopy(_OPT_GEOM_KW)
89
+
90
+ # ML/MM calculator defaults (shared with opt/path_*)
91
+ CALC_KW: Dict[str, Any] = deepcopy(_OPT_CALC_KW)
92
+
93
+ # Optimizer base (convergence, dumping, etc.)
94
+ OPT_BASE_KW: Dict[str, Any] = deepcopy(_OPT_BASE_KW)
95
+ OPT_BASE_KW.update({
96
+ "out_dir": "./result_scan/",
97
+ })
98
+
99
+ # LBFGS specifics
100
+ LBFGS_KW: Dict[str, Any] = deepcopy(_OPT_LBFGS_KW)
101
+ LBFGS_KW.update({
102
+ "out_dir": "./result_scan/",
103
+ })
104
+
105
+ # Bias (harmonic well) defaults; can be overridden via YAML: section "bias"
106
+ BIAS_KW: Dict[str, Any] = deepcopy(_BIAS_KW_DEFAULT)
107
+
108
+ # Bond-change detection (as in path_search)
109
+ BOND_KW: Dict[str, Any] = deepcopy(_BOND_KW_DEFAULT)
110
+
111
+
112
+ def _coords3d_to_xyz_string(geom, energy: Optional[float] = None) -> str:
113
+ s = geom.as_xyz()
114
+ lines = s.splitlines()
115
+ if energy is not None and len(lines) >= 2 and lines[0].strip().isdigit():
116
+ lines[1] = f"{energy:.12f}"
117
+ s = "\n".join(lines)
118
+ if not s.endswith("\n"):
119
+ s += "\n"
120
+ return s
121
+
122
+
123
+ def _pair_distances(coords_ang: np.ndarray, pairs: Iterable[Tuple[int, int]]) -> List[float]:
124
+ """
125
+ coords_ang: (N,3) in Å; returns a list of distances (Å) for the given pairs.
126
+ """
127
+ dists: List[float] = []
128
+ for i, j in pairs:
129
+ v = coords_ang[i] - coords_ang[j]
130
+ d = float(np.linalg.norm(v))
131
+ dists.append(d)
132
+ return dists
133
+
134
+
135
+ def _schedule_for_stage(
136
+ coords_ang: np.ndarray,
137
+ tuples: List[Tuple[int, int, float]],
138
+ max_step_size_ang: float,
139
+ ) -> Tuple[int, List[float], List[float], List[float]]:
140
+ """
141
+ Given current *Å* coords and stage tuples, compute:
142
+ N: number of steps
143
+ r0: initial distances per tuple (Å)
144
+ rT: target distances per tuple (Å)
145
+ step_widths: δ_k per tuple (Å, signed)
146
+ """
147
+ pairs = [(i, j) for (i, j, _) in tuples]
148
+ r0 = _pair_distances(coords_ang, pairs)
149
+ rT = [t for (_, _, t) in tuples]
150
+ deltas = [RT - R0 for (R0, RT) in zip(r0, rT)]
151
+ d_max = max((abs(d) for d in deltas), default=0.0)
152
+ if d_max <= 0.0:
153
+ return 0, r0, rT, [0.0] * len(tuples)
154
+ if max_step_size_ang <= 0.0:
155
+ raise click.BadParameter("--max-step-size must be > 0.")
156
+ N = int(math.ceil(d_max / max_step_size_ang))
157
+ step_widths = [d / N for d in deltas]
158
+ return N, r0, rT, step_widths
159
+
160
+
161
+ # --------------------------------------------------------------------------------------
162
+ # Bond‑change helpers
163
+ # --------------------------------------------------------------------------------------
164
+
165
+ def _has_bond_change(x, y, bond_cfg: Dict[str, Any]) -> Tuple[bool, str]:
166
+ """
167
+ Return for covalent bonds forming/breaking between `x` and `y`.
168
+ """
169
+ res = compare_structures(
170
+ x, y,
171
+ device=bond_cfg.get("device", "cuda"),
172
+ bond_factor=float(bond_cfg.get("bond_factor", 1.20)),
173
+ margin_fraction=float(bond_cfg.get("margin_fraction", 0.05)),
174
+ delta_fraction=float(bond_cfg.get("delta_fraction", 0.05)),
175
+ )
176
+ formed = len(getattr(res, "formed_covalent", [])) > 0
177
+ broken = len(getattr(res, "broken_covalent", [])) > 0
178
+ summary = summarize_changes(x, res, one_based=True)
179
+ return (formed or broken), summary
180
+
181
+
182
+ def _snapshot_geometry(g) -> Any:
183
+ """Create an independent pysisyphus Geometry snapshot from the given Geometry."""
184
+ return snapshot_geometry(g, coord_type_default="cart")
185
+
186
+
187
+ @click.command(
188
+ help="Bond-length driven scan with staged harmonic restraints and relaxation (ML/MM).",
189
+ context_settings={
190
+ "help_option_names": ["-h", "--help"],
191
+ "ignore_unknown_options": True,
192
+ "allow_extra_args": True,
193
+ },
194
+ )
195
+ @click.option(
196
+ "-i", "--input",
197
+ "input_path",
198
+ type=click.Path(path_type=Path, exists=True, dir_okay=False),
199
+ required=True,
200
+ help="Full-enzyme PDB used by the ML/MM calculator and as reference for conversions.",
201
+ )
202
+ @click.option(
203
+ "--parm",
204
+ "real_parm7",
205
+ type=click.Path(path_type=Path, exists=True, dir_okay=False),
206
+ required=True,
207
+ help="Amber parm7 topology covering the entire enzyme complex.",
208
+ )
209
+ @click.option(
210
+ "--model-pdb",
211
+ type=click.Path(path_type=Path, exists=True, dir_okay=False),
212
+ required=False,
213
+ help="PDB defining the ML-region atoms for ML/MM. Optional when --detect-layer is enabled.",
214
+ )
215
+ @click.option(
216
+ "--model-indices",
217
+ "model_indices_str",
218
+ type=str,
219
+ default=None,
220
+ show_default=False,
221
+ help="Comma-separated atom indices for the ML region (ranges allowed like 1-5). "
222
+ "Used when --model-pdb is omitted.",
223
+ )
224
+ @click.option(
225
+ "--model-indices-one-based/--model-indices-zero-based",
226
+ "model_indices_one_based",
227
+ default=True,
228
+ show_default=True,
229
+ help="Interpret --model-indices as 1-based (default) or 0-based.",
230
+ )
231
+ @click.option(
232
+ "--detect-layer/--no-detect-layer",
233
+ "detect_layer",
234
+ default=True,
235
+ show_default=True,
236
+ help="Detect ML/MM layers from input PDB B-factors (B=0/10/20). "
237
+ "If disabled, you must provide --model-pdb or --model-indices.",
238
+ )
239
+ @click.option("-q", "--charge", type=int, required=False,
240
+ help="ML region charge. Required unless --ligand-charge is provided.")
241
+ @click.option("-l", "--ligand-charge", type=str, default=None, show_default=False,
242
+ help="Total charge or per-resname mapping (e.g., GPP:-3,SAM:1) used to derive "
243
+ "charge when -q is omitted (requires PDB input or --ref-pdb).")
244
+ @click.option(
245
+ "-m",
246
+ "--multiplicity",
247
+ "spin",
248
+ type=int,
249
+ default=None,
250
+ show_default=False,
251
+ help="Spin multiplicity (2S+1) for the ML region. Defaults to 1 when omitted.",
252
+ )
253
+ @click.option(
254
+ "--freeze-atoms",
255
+ "freeze_atoms_cli",
256
+ type=str,
257
+ default=None,
258
+ show_default=False,
259
+ help="Comma-separated 1-based atom indices to freeze (e.g., '1,3,5').",
260
+ )
261
+ @click.option(
262
+ "--hess-cutoff",
263
+ "hess_cutoff",
264
+ type=float,
265
+ default=None,
266
+ show_default=False,
267
+ help="Distance cutoff (Å) from ML region for MM atoms to include in Hessian calculation. "
268
+ "Applied to movable MM atoms and can be combined with --detect-layer.",
269
+ )
270
+ @click.option(
271
+ "--movable-cutoff",
272
+ "movable_cutoff",
273
+ type=float,
274
+ default=None,
275
+ show_default=False,
276
+ help="Distance cutoff (Å) from ML region for movable MM atoms. MM atoms beyond this are frozen. "
277
+ "Providing --movable-cutoff disables --detect-layer.",
278
+ )
279
+ @click.option(
280
+ "-s", "--scan-lists",
281
+ "scan_lists_raw",
282
+ type=str,
283
+ multiple=True,
284
+ required=False,
285
+ help="Scan targets: inline Python literal (e.g. '[(1,5,1.4)]') or a YAML/JSON spec file path. "
286
+ "Multiple inline literals define sequential stages.",
287
+ )
288
+ @click.option("--one-based/--zero-based", "one_based", default=True, show_default=True,
289
+ help="Interpret (i,j) indices in --scan-lists as 1-based (default) or 0-based.")
290
+ @click.option(
291
+ "--print-parsed/--no-print-parsed",
292
+ "print_parsed",
293
+ default=False,
294
+ show_default=True,
295
+ help="Print parsed scan targets after resolving -s/--scan-lists.",
296
+ )
297
+ @click.option("--max-step-size", type=float, default=0.20, show_default=True,
298
+ help="Maximum change in any scanned bond length per step [Å].")
299
+ @click.option("--bias-k", type=float, default=300, show_default=True,
300
+ help="Harmonic well strength k [eV/Å^2].")
301
+ @click.option(
302
+ "--opt-mode",
303
+ type=click.Choice(["grad", "hess", "lbfgs", "rfo", "light", "heavy"], case_sensitive=False),
304
+ default=None,
305
+ show_default=False,
306
+ help="Compatibility option for mlmm all forwarding. "
307
+ "scan relaxations currently use LBFGS regardless of this value.",
308
+ )
309
+ @click.option(
310
+ "--max-cycles",
311
+ type=int,
312
+ default=10000,
313
+ show_default=True,
314
+ help="Maximum LBFGS cycles per biased step and per (pre|end)opt stage.",
315
+ )
316
+ @click.option(
317
+ "--relax-max-cycles",
318
+ type=int,
319
+ default=None,
320
+ show_default=False,
321
+ help="Compatibility alias of --max-cycles (overrides it when provided).",
322
+ )
323
+ @click.option(
324
+ "--dump/--no-dump",
325
+ "dump",
326
+ default=False,
327
+ show_default=True,
328
+ help="Write per-step optimizer trajectory files. "
329
+ "scan_trj.xyz and scan.pdb are always written to out-dir regardless of this flag.",
330
+ )
331
+ @click.option("-o", "--out-dir", type=str, default="./result_scan/", show_default=True,
332
+ help="Base output directory.")
333
+ @click.option(
334
+ "--thresh",
335
+ type=click.Choice(["gau_loose", "gau", "gau_tight", "gau_vtight", "baker", "never"], case_sensitive=False),
336
+ default=None,
337
+ help="Convergence preset for relaxations.",
338
+ )
339
+ @click.option(
340
+ "--config",
341
+ "config_yaml",
342
+ type=click.Path(path_type=Path, exists=True, dir_okay=False),
343
+ default=None,
344
+ help="Base YAML configuration file applied before explicit CLI options.",
345
+ )
346
+ @click.option(
347
+ "--ref-pdb",
348
+ type=click.Path(path_type=Path, exists=True, dir_okay=False),
349
+ default=None,
350
+ help="Reference PDB topology to use when --input is XYZ (keeps XYZ coordinates).",
351
+ )
352
+ @click.option(
353
+ "--preopt/--no-preopt",
354
+ "preopt",
355
+ default=False,
356
+ show_default=True,
357
+ help="Pre-optimize initial structure without bias before the scan.",
358
+ )
359
+ @click.option(
360
+ "--endopt/--no-endopt",
361
+ "endopt",
362
+ default=False,
363
+ show_default=True,
364
+ help="After each stage, run an additional unbiased optimization of the stage result.",
365
+ )
366
+ @click.option(
367
+ "--dry-run/--no-dry-run",
368
+ "dry_run",
369
+ default=False,
370
+ show_default=True,
371
+ help="Validate options and print the execution plan without running the scan.",
372
+ )
373
+ @click.option(
374
+ "--convert-files/--no-convert-files",
375
+ "convert_files",
376
+ default=True,
377
+ show_default=True,
378
+ help="Convert XYZ/TRJ outputs into PDB companions based on the input format.",
379
+ )
380
+ @click.option(
381
+ "-b", "--backend",
382
+ type=click.Choice(["uma", "orb", "mace", "aimnet2"], case_sensitive=False),
383
+ default=None,
384
+ show_default=False,
385
+ help="ML backend for the ONIOM high-level region (default: uma).",
386
+ )
387
+ @click.option(
388
+ "--embedcharge/--no-embedcharge",
389
+ "embedcharge",
390
+ default=False,
391
+ show_default=True,
392
+ help="Enable xTB point-charge embedding correction for MM→ML environmental effects.",
393
+ )
394
+ @click.option(
395
+ "--embedcharge-cutoff",
396
+ "embedcharge_cutoff",
397
+ type=float,
398
+ default=None,
399
+ show_default=False,
400
+ help="Distance cutoff (Å) from ML region for MM point charges in xTB embedding. "
401
+ "Default: 12.0 Å when --embedcharge is enabled.",
402
+ )
403
+ @click.pass_context
404
+ def cli(
405
+ ctx: click.Context,
406
+ input_path: Path,
407
+ real_parm7: Path,
408
+ model_pdb: Optional[Path],
409
+ model_indices_str: Optional[str],
410
+ model_indices_one_based: bool,
411
+ detect_layer: bool,
412
+ charge: Optional[int],
413
+ ligand_charge: Optional[str],
414
+ spin: Optional[int],
415
+ freeze_atoms_cli: Optional[str],
416
+ hess_cutoff: Optional[float],
417
+ movable_cutoff: Optional[float],
418
+ scan_lists_raw: Sequence[str],
419
+ one_based: bool,
420
+ print_parsed: bool,
421
+ max_step_size: float,
422
+ bias_k: Optional[float],
423
+ opt_mode: Optional[str],
424
+ max_cycles: int,
425
+ relax_max_cycles: Optional[int],
426
+ dump: bool,
427
+ out_dir: str,
428
+ thresh: Optional[str],
429
+ config_yaml: Optional[Path],
430
+ ref_pdb: Optional[Path],
431
+ preopt: bool,
432
+ endopt: bool,
433
+ dry_run: bool,
434
+ convert_files: bool,
435
+ backend: Optional[str],
436
+ embedcharge: bool,
437
+ embedcharge_cutoff: Optional[float],
438
+ ) -> None:
439
+ _is_param_explicit = make_is_param_explicit(ctx)
440
+
441
+ set_convert_file_enabled(convert_files)
442
+ time_start = time.perf_counter()
443
+ config_yaml, override_yaml, used_legacy_yaml = resolve_yaml_sources(
444
+ config_yaml=config_yaml,
445
+ override_yaml=None,
446
+ args_yaml_legacy=None,
447
+ )
448
+
449
+ if relax_max_cycles is not None:
450
+ max_cycles = int(relax_max_cycles)
451
+ if max_cycles <= 0:
452
+ raise click.BadParameter("--max-cycles must be > 0.")
453
+ if opt_mode is not None and str(opt_mode).lower() not in {"lbfgs", "light", "grad"}:
454
+ click.echo(
455
+ f"[scan] NOTE: --opt-mode={opt_mode} is accepted for compatibility, "
456
+ "but scan relaxations use LBFGS.",
457
+ err=True,
458
+ )
459
+
460
+ # Validate input format: PDB directly, or XYZ with --ref-pdb
461
+ suffix = input_path.suffix.lower()
462
+ if suffix not in (".pdb", ".xyz"):
463
+ click.echo("ERROR: --input must be a PDB or XYZ file.", err=True)
464
+ sys.exit(1)
465
+ if suffix == ".xyz" and ref_pdb is None:
466
+ click.echo("ERROR: --ref-pdb is required when --input is an XYZ file.", err=True)
467
+ sys.exit(1)
468
+
469
+ try:
470
+ with prepare_input_structure(input_path) as prepared_input:
471
+ try:
472
+ apply_ref_pdb_override(prepared_input, ref_pdb)
473
+ except click.BadParameter as e:
474
+ click.echo(f"ERROR: {e}", err=True)
475
+ sys.exit(1)
476
+ geom_input_path = prepared_input.geom_path
477
+ source_path = prepared_input.source_path
478
+ charge, spin = resolve_charge_spin_or_raise(
479
+ prepared_input, charge, spin,
480
+ ligand_charge=ligand_charge, prefix="[scan]",
481
+ )
482
+
483
+ try:
484
+ freeze_atoms_list = _parse_freeze_atoms(freeze_atoms_cli)
485
+ except click.BadParameter as e:
486
+ click.echo(f"ERROR: {e}", err=True)
487
+ sys.exit(1)
488
+
489
+ model_indices: Optional[List[int]] = None
490
+ if model_indices_str:
491
+ try:
492
+ model_indices = parse_indices_string(model_indices_str, one_based=model_indices_one_based)
493
+ except click.BadParameter as e:
494
+ click.echo(f"ERROR: {e}", err=True)
495
+ sys.exit(1)
496
+
497
+ yaml_cfg, _, _ = load_merged_yaml_cfg(
498
+ config_yaml=config_yaml,
499
+ override_yaml=None,
500
+ )
501
+
502
+ geom_cfg = dict(GEOM_KW)
503
+ calc_cfg = dict(CALC_KW)
504
+ opt_cfg = dict(OPT_BASE_KW)
505
+ lbfgs_cfg = dict(LBFGS_KW)
506
+ bias_cfg = dict(BIAS_KW)
507
+ bond_cfg = dict(BOND_KW)
508
+
509
+ apply_yaml_overrides(
510
+ yaml_cfg,
511
+ [
512
+ (geom_cfg, (("geom",),)),
513
+ (calc_cfg, (("calc",), ("mlmm",))),
514
+ (opt_cfg, (("opt",),)),
515
+ (lbfgs_cfg, (("lbfgs",), ("opt", "lbfgs"))),
516
+ (bias_cfg, (("bias",),)),
517
+ (bond_cfg, (("bond",),)),
518
+ ],
519
+ )
520
+
521
+ try:
522
+ geom_freeze = _normalize_geom_freeze(geom_cfg.get("freeze_atoms"))
523
+ except click.BadParameter as e:
524
+ click.echo(f"ERROR: {e}", err=True)
525
+ sys.exit(1)
526
+ geom_cfg["freeze_atoms"] = geom_freeze
527
+ if freeze_atoms_list:
528
+ merge_freeze_atom_indices(geom_cfg, freeze_atoms_list)
529
+ freeze_atoms_final = list(geom_cfg.get("freeze_atoms") or [])
530
+ calc_cfg["freeze_atoms"] = freeze_atoms_final
531
+
532
+ opt_cfg["out_dir"] = out_dir
533
+ opt_cfg["dump"] = False
534
+ opt_cfg["max_cycles"] = int(max_cycles)
535
+ if thresh is not None:
536
+ opt_cfg["thresh"] = str(thresh)
537
+ lbfgs_cfg["max_cycles"] = int(max_cycles)
538
+
539
+ if bias_k is not None:
540
+ bias_cfg["k"] = float(bias_k)
541
+
542
+ out_dir_path = Path(out_dir).resolve()
543
+
544
+ calc_cfg["model_charge"] = int(charge)
545
+ calc_cfg["model_mult"] = int(spin)
546
+ calc_cfg["input_pdb"] = str(source_path)
547
+ calc_cfg["real_parm7"] = str(real_parm7)
548
+ if backend is not None:
549
+ calc_cfg["backend"] = str(backend).lower()
550
+ if _is_param_explicit("embedcharge"):
551
+ calc_cfg["embedcharge"] = bool(embedcharge)
552
+ if _is_param_explicit("embedcharge_cutoff"):
553
+ calc_cfg["embedcharge_cutoff"] = embedcharge_cutoff
554
+
555
+ # movable_cutoff implies full distance-based layer assignment.
556
+ # hess_cutoff alone can be combined with --detect-layer.
557
+ if movable_cutoff is not None:
558
+ if detect_layer:
559
+ click.echo("[layer] --movable-cutoff provided; disabling --detect-layer.", err=True)
560
+ detect_layer = False
561
+
562
+ layer_source_pdb = source_path
563
+ if detect_layer and layer_source_pdb.suffix.lower() != ".pdb":
564
+ click.echo("ERROR: --detect-layer requires a PDB input (or --ref-pdb).", err=True)
565
+ sys.exit(1)
566
+
567
+ model_pdb_path: Optional[Path] = None
568
+ layer_info: Optional[Dict[str, List[int]]] = None
569
+
570
+ if detect_layer:
571
+ try:
572
+ model_pdb_path, layer_info = build_model_pdb_from_bfactors(layer_source_pdb, out_dir_path)
573
+ calc_cfg["use_bfactor_layers"] = True
574
+ click.echo(
575
+ f"[layer] Detected B-factor layers: ML={len(layer_info.get('ml_indices', []))}, "
576
+ f"MovableMM={len(layer_info.get('movable_mm_indices', []))}, "
577
+ f"FrozenMM={len(layer_info.get('frozen_indices', []))}"
578
+ )
579
+ except Exception as e:
580
+ if model_pdb is None and not model_indices:
581
+ click.echo(f"ERROR: {e}", err=True)
582
+ sys.exit(1)
583
+ click.echo(f"[layer] WARNING: {e} Falling back to explicit ML region.", err=True)
584
+ detect_layer = False
585
+
586
+ if not detect_layer:
587
+ if model_pdb is None and not model_indices:
588
+ click.echo("ERROR: Provide --model-pdb or --model-indices when --no-detect-layer.", err=True)
589
+ sys.exit(1)
590
+ if model_pdb is not None:
591
+ model_pdb_path = Path(model_pdb)
592
+ else:
593
+ if layer_source_pdb.suffix.lower() != ".pdb":
594
+ click.echo("ERROR: --model-indices requires a PDB input (or --ref-pdb).", err=True)
595
+ sys.exit(1)
596
+ try:
597
+ model_pdb_path = build_model_pdb_from_indices(layer_source_pdb, out_dir_path, model_indices or [])
598
+ except Exception as e:
599
+ click.echo(f"ERROR: {e}", err=True)
600
+ sys.exit(1)
601
+ calc_cfg["use_bfactor_layers"] = False
602
+
603
+ if model_pdb_path is None:
604
+ click.echo("ERROR: Failed to resolve model PDB for the ML region.", err=True)
605
+ sys.exit(1)
606
+
607
+ calc_cfg["model_pdb"] = str(model_pdb_path)
608
+ freeze_atoms_final = apply_layer_freeze_constraints(
609
+ geom_cfg,
610
+ calc_cfg,
611
+ layer_info,
612
+ echo_fn=click.echo,
613
+ )
614
+
615
+ # Distance-based overrides for Hessian-target and movable MM selection.
616
+ if hess_cutoff is not None:
617
+ calc_cfg["hess_cutoff"] = hess_cutoff
618
+ if movable_cutoff is not None:
619
+ calc_cfg["movable_cutoff"] = movable_cutoff
620
+ calc_cfg["use_bfactor_layers"] = False
621
+
622
+ for key in ("input_pdb", "real_parm7", "model_pdb", "mm_fd_dir"):
623
+ val = calc_cfg.get(key)
624
+ if val:
625
+ calc_cfg[key] = str(Path(val).expanduser().resolve())
626
+ echo_geom = format_freeze_atoms_for_echo(geom_cfg, key="freeze_atoms")
627
+ echo_calc = format_freeze_atoms_for_echo(filter_calc_for_echo(calc_cfg), key="freeze_atoms")
628
+ echo_opt = strip_inherited_keys({**opt_cfg, "out_dir": str(out_dir_path)}, OPT_BASE_KW, mode="same")
629
+ # Show only lbfgs-specific settings, not inherited from opt_cfg
630
+ echo_lbfgs = strip_inherited_keys(lbfgs_cfg, opt_cfg)
631
+ click.echo(pretty_block("geom", echo_geom))
632
+ click.echo(pretty_block("calc", echo_calc))
633
+ click.echo(pretty_block("opt", echo_opt))
634
+ click.echo(pretty_block("lbfgs", echo_lbfgs))
635
+ click.echo(pretty_block("bias", bias_cfg))
636
+ click.echo(pretty_block("bond", bond_cfg))
637
+
638
+ pdb_atom_meta: List[Dict[str, Any]] = []
639
+ if source_path.suffix.lower() == ".pdb":
640
+ pdb_atom_meta = load_pdb_atom_metadata(source_path)
641
+
642
+ cli_scan_values = collect_single_option_values(
643
+ sys.argv[1:], ("-s", "--scan-lists"), "--scan-lists"
644
+ )
645
+ if not cli_scan_values:
646
+ raise click.BadParameter("--scan-lists is required.")
647
+
648
+ stages: List[List[Tuple[int, int, float]]]
649
+ scan_one_based = bool(one_based)
650
+ scan_source = "--scan-lists"
651
+ # Bidirectional scan support (4-tuple): track which stages
652
+ # need geometry snapshot/reset.
653
+ _bidir_reset_before: set = set()
654
+ _bidir_snapshot_before: set = set()
655
+ # Auto-detect: single value that is a YAML/JSON file → spec mode
656
+ if len(cli_scan_values) == 1 and is_scan_spec_file(cli_scan_values[0]):
657
+ spec_path = Path(cli_scan_values[0])
658
+ stages, scan_one_based = parse_scan_spec_stages(
659
+ spec_path,
660
+ one_based_default=one_based,
661
+ atom_meta=pdb_atom_meta,
662
+ option_name="--scan-lists",
663
+ )
664
+ scan_source = f"--scan-lists ({spec_path})"
665
+ else:
666
+ stages = []
667
+ for idx, raw in enumerate(cli_scan_values, start=1):
668
+ parsed, _ = parse_scan_list_triples(
669
+ raw,
670
+ one_based=scan_one_based,
671
+ atom_meta=pdb_atom_meta,
672
+ option_name=f"--scan-lists #{idx}",
673
+ )
674
+ for t in parsed:
675
+ for dist in t[2:]:
676
+ if dist <= 0.0:
677
+ raise click.BadParameter(
678
+ f"Non-positive target length in --scan-lists #{idx}: {t}."
679
+ )
680
+ # Expand 4-tuples into two stages with reset marker
681
+ has_4tuple = any(len(t) == 4 for t in parsed)
682
+ if has_4tuple:
683
+ for t in parsed:
684
+ if len(t) == 4:
685
+ i, j, start, end = t
686
+ stage_a_idx = len(stages)
687
+ stages.append([(i, j, start)])
688
+ _bidir_snapshot_before.add(stage_a_idx)
689
+ _bidir_reset_before.add(stage_a_idx + 1)
690
+ stages.append([(i, j, end)])
691
+ else:
692
+ stages.append([t])
693
+ else:
694
+ stages.append(parsed)
695
+ K = len(stages)
696
+ click.echo(f"[scan] Received {K} stage(s).")
697
+ if print_parsed:
698
+ click.echo(
699
+ pretty_block(
700
+ "scan-parsed",
701
+ {
702
+ "source": scan_source,
703
+ "one_based": bool(scan_one_based),
704
+ "stages_0based": stages,
705
+ },
706
+ )
707
+ )
708
+
709
+ if dry_run:
710
+ model_region_source = "bfactor"
711
+ if not detect_layer:
712
+ if model_pdb is not None:
713
+ model_region_source = "model_pdb"
714
+ elif model_indices:
715
+ model_region_source = "model_indices"
716
+ click.echo(
717
+ pretty_block(
718
+ "dry_run_plan",
719
+ {
720
+ "input_geometry": str(geom_input_path),
721
+ "output_dir": str(out_dir_path),
722
+ "detect_layer": bool(detect_layer),
723
+ "model_region_source": model_region_source,
724
+ "num_stages": len(stages),
725
+ "stages_0based": stages,
726
+ "preopt": bool(preopt),
727
+ "endopt": bool(endopt),
728
+ "bias_k": float(bias_cfg["k"]),
729
+ "max_step_size": float(max_step_size),
730
+ "max_cycles": int(max_cycles),
731
+ "backend": calc_cfg.get("backend", "uma"),
732
+ "embedcharge": bool(calc_cfg.get("embedcharge", False)),
733
+ },
734
+ )
735
+ )
736
+ click.echo("[dry-run] Validation complete. Scan execution was skipped.")
737
+ return
738
+
739
+ if pdb_atom_meta:
740
+ click.echo("[scan] PDB atom details for scanned pairs:")
741
+ legend = PDB_ATOM_META_HEADER
742
+ click.echo(f" legend: {legend}")
743
+ for stage_idx, tuples in enumerate(stages, start=1):
744
+ click.echo(f" Stage {stage_idx}:")
745
+ for pair_idx, (i, j, _) in enumerate(tuples, start=1):
746
+ click.echo(
747
+ f" pair {pair_idx} i: {format_pdb_atom_metadata(pdb_atom_meta, i)}"
748
+ )
749
+ click.echo(
750
+ f" j: {format_pdb_atom_metadata(pdb_atom_meta, j)}"
751
+ )
752
+ stages_summary: List[Dict[str, Any]] = []
753
+
754
+ out_dir_path.mkdir(parents=True, exist_ok=True)
755
+ coord_type = geom_cfg.get("coord_type", "cart")
756
+ geom = geom_loader(geom_input_path, coord_type=coord_type)
757
+
758
+ freeze = list(geom_cfg.get("freeze_atoms") or [])
759
+ if freeze:
760
+ try:
761
+ geom.freeze_atoms = np.array(freeze, dtype=int)
762
+ except Exception:
763
+ logger.debug("Failed to set freeze_atoms on geometry", exc_info=True)
764
+
765
+ base_calc = mlmm(**calc_cfg)
766
+
767
+ max_step_bohr = float(max_step_size) * ANG2BOHR
768
+
769
+ def _make_lbfgs(_out_dir: Path, _prefix: str) -> LBFGS:
770
+ common = dict(opt_cfg)
771
+ common["out_dir"] = str(_out_dir)
772
+ common["prefix"] = _prefix
773
+ args = {**lbfgs_cfg, **common}
774
+ args["max_step"] = min(float(lbfgs_cfg.get("max_step", 0.30)), max_step_bohr)
775
+ return LBFGS(geom, **args)
776
+
777
+ if preopt:
778
+ pre_dir = out_dir_path / "preopt"
779
+ pre_dir.mkdir(parents=True, exist_ok=True)
780
+ geom.set_calculator(base_calc)
781
+ click.echo("[preopt] Unbiased relaxation (LBFGS) ...")
782
+ optimizer0 = _make_lbfgs(pre_dir, "preopt")
783
+ try:
784
+ optimizer0.run()
785
+ except ZeroStepLength:
786
+ click.echo("[preopt] ZeroStepLength — continuing.", err=True)
787
+ except OptimizationError as e:
788
+ click.echo(f"[preopt] OptimizationError — {e}", err=True)
789
+
790
+ pre_xyz = pre_dir / "result.xyz"
791
+ with open(pre_xyz, "w") as f:
792
+ f.write(_coords3d_to_xyz_string(geom))
793
+ click.echo(f"[write] Wrote '{pre_xyz}'.")
794
+ try:
795
+ convert_xyz_to_pdb(pre_xyz, source_path.resolve(), pre_dir / "result.pdb")
796
+ click.echo(f"[convert] Wrote '{pre_dir / 'result.pdb'}'.")
797
+ except Exception as e:
798
+ click.echo(f"[convert] WARNING: Failed to convert preopt result to PDB: {e}", err=True)
799
+
800
+ biased = HarmonicBiasCalculator(base_calc, k=float(bias_cfg["k"]))
801
+ geom.set_calculator(biased)
802
+
803
+ all_trj_blocks: List[str] = []
804
+ # For bidirectional 4-tuple scans: save geometry before pass 1,
805
+ # restore before pass 2, and reverse pass 1 trajectory.
806
+ _bidir_saved_geom = None
807
+ _bidir_pass1_trj: List[str] = []
808
+
809
+ for k, tuples in enumerate(stages, start=1):
810
+ # Bidirectional support: snapshot before pass 1
811
+ stage_idx_0 = k - 1 # 0-based
812
+ if stage_idx_0 in _bidir_snapshot_before:
813
+ _bidir_saved_geom = _snapshot_geometry(geom)
814
+ _bidir_pass1_trj = []
815
+ # Bidirectional support: restore geometry before pass 2
816
+ if stage_idx_0 in _bidir_reset_before and _bidir_saved_geom is not None:
817
+ click.echo("[bidir] Restoring initial geometry for reverse-direction pass.")
818
+ geom.coords = _bidir_saved_geom.coords.copy()
819
+
820
+ stage_dir = out_dir_path / f"stage_{k:02d}"
821
+ stage_dir.mkdir(parents=True, exist_ok=True)
822
+ click.echo(f"\n--- Stage {k}/{K} ---")
823
+ click.echo(f"Targets (i,j,target Å): {tuples}")
824
+
825
+ start_geom_for_stage = _snapshot_geometry(geom)
826
+
827
+ R_bohr = np.array(geom.coords3d, dtype=float)
828
+ R_ang = R_bohr * BOHR2ANG
829
+ Nsteps, r0, rT, step_widths = _schedule_for_stage(R_ang, tuples, float(max_step_size))
830
+ click.echo(f"[stage {k}] initial distances (Å) = {['{:.3f}'.format(x) for x in r0]}")
831
+ click.echo(f"[stage {k}] target distances (Å) = {['{:.3f}'.format(x) for x in rT]}")
832
+ click.echo(f"[stage {k}] steps N = {Nsteps}")
833
+
834
+ srec: Dict[str, Any] = {
835
+ "index": int(k),
836
+ "pairs_1based": [(int(i) + 1, int(j) + 1) for (i, j, _) in tuples],
837
+ "initial_distances_A": [float(f"{x:.3f}") for x in r0],
838
+ "target_distances_A": [float(f"{x:.3f}") for x in rT],
839
+ "per_pair_step_A": [float(f"{x:.3f}") for x in step_widths],
840
+ "num_steps": int(Nsteps),
841
+ "bond_change": {"changed": None, "summary": ""},
842
+ }
843
+ stages_summary.append(srec)
844
+
845
+ trj_blocks: List[str] = []
846
+ stage_trj_path = stage_dir / "scan_trj.xyz"
847
+ stage_trj_path.write_text("")
848
+ pairs = [(i, j) for (i, j, _) in tuples]
849
+
850
+ if Nsteps == 0:
851
+ if endopt:
852
+ geom.set_calculator(base_calc)
853
+ click.echo(f"[stage {k}] endopt (unbiased) ...")
854
+ try:
855
+ end_optimizer = _make_lbfgs(stage_dir, "endopt")
856
+ end_optimizer.run()
857
+ except ZeroStepLength:
858
+ click.echo(f"[stage {k}] endopt ZeroStepLength — continuing.", err=True)
859
+ except OptimizationError as e:
860
+ click.echo(f"[stage {k}] endopt OptimizationError — {e}", err=True)
861
+ finally:
862
+ geom.set_calculator(biased)
863
+
864
+ try:
865
+ changed, summary = _has_bond_change(start_geom_for_stage, geom, bond_cfg)
866
+ click.echo(f"[stage {k}] Covalent-bond changes (start vs final): {'Yes' if changed else 'No'}")
867
+ if changed and summary and summary.strip():
868
+ click.echo(textwrap.indent(summary.strip(), prefix=" "))
869
+ if not changed:
870
+ click.echo(" (no covalent changes detected)")
871
+ try:
872
+ srec["bond_change"]["changed"] = bool(changed)
873
+ srec["bond_change"]["summary"] = (summary.strip() if (summary and summary.strip()) else "")
874
+ except Exception:
875
+ logger.debug("Failed to store bond_change record", exc_info=True)
876
+ except Exception as e:
877
+ click.echo(f"[stage {k}] WARNING: Failed to evaluate bond changes: {e}", err=True)
878
+
879
+ final_xyz = stage_dir / "result.xyz"
880
+ with open(final_xyz, "w") as f:
881
+ f.write(_coords3d_to_xyz_string(geom))
882
+ click.echo(f"[write] Wrote '{final_xyz}'.")
883
+ try:
884
+ convert_xyz_to_pdb(final_xyz, source_path.resolve(), stage_dir / "result.pdb")
885
+ click.echo(f"[convert] Wrote '{stage_dir / 'result.pdb'}'.")
886
+ except Exception as e:
887
+ click.echo(f"[convert] WARNING: Failed to convert stage result to PDB: {e}", err=True)
888
+ continue
889
+
890
+ for s in range(1, Nsteps + 1):
891
+ step_targets = [r0_i + s * dw for (r0_i, dw) in zip(r0, step_widths)]
892
+ biased.set_pairs([(i, j, t) for ((i, j), t) in zip(pairs, step_targets)])
893
+ geom.set_calculator(biased)
894
+
895
+ prefix = f"scan_s{s:04d}"
896
+ optimizer = _make_lbfgs(stage_dir, prefix)
897
+ click.echo(f"[stage {k}] step {s}/{Nsteps}: relaxation (LBFGS) ...")
898
+ try:
899
+ optimizer.run()
900
+ except ZeroStepLength:
901
+ click.echo(f"[stage {k}] step {s}: ZeroStepLength — continuing to next step.", err=True)
902
+ except OptimizationError as e:
903
+ click.echo(f"[stage {k}] step {s}: OptimizationError — {e}", err=True)
904
+
905
+ trj_blocks.append(_coords3d_to_xyz_string(geom))
906
+ with open(stage_trj_path, "a") as _tf:
907
+ _tf.write(trj_blocks[-1])
908
+
909
+ if endopt:
910
+ geom.set_calculator(base_calc)
911
+ click.echo(f"[stage {k}] endopt (unbiased) ...")
912
+ try:
913
+ end_optimizer = _make_lbfgs(stage_dir, "endopt")
914
+ end_optimizer.run()
915
+ except ZeroStepLength:
916
+ click.echo(f"[stage {k}] endopt ZeroStepLength — continuing.", err=True)
917
+ except OptimizationError as e:
918
+ click.echo(f"[stage {k}] endopt OptimizationError — {e}", err=True)
919
+ finally:
920
+ geom.set_calculator(biased)
921
+
922
+ try:
923
+ changed, summary = _has_bond_change(start_geom_for_stage, geom, bond_cfg)
924
+ click.echo(f"[stage {k}] Covalent-bond changes (start vs final): {'Yes' if changed else 'No'}")
925
+ if changed and summary and summary.strip():
926
+ click.echo(textwrap.indent(summary.strip(), prefix=" "))
927
+ if not changed:
928
+ click.echo(" (no covalent changes detected)")
929
+ try:
930
+ srec["bond_change"]["changed"] = bool(changed)
931
+ srec["bond_change"]["summary"] = (summary.strip() if (summary and summary.strip()) else "")
932
+ except Exception:
933
+ logger.debug("Failed to store bond_change record", exc_info=True)
934
+ except Exception as e:
935
+ click.echo(f"[stage {k}] WARNING: Failed to evaluate bond changes: {e}", err=True)
936
+
937
+ if trj_blocks:
938
+ click.echo(f"[write] Wrote '{stage_trj_path}'.")
939
+ # Bidirectional trajectory assembly:
940
+ # pass 1 (initial→start) is saved; pass 2 (initial→end)
941
+ # triggers assembly: reversed(pass1) + pass2 → start→initial→end
942
+ if stage_idx_0 in _bidir_snapshot_before:
943
+ _bidir_pass1_trj = list(trj_blocks)
944
+ elif stage_idx_0 in _bidir_reset_before:
945
+ all_trj_blocks.extend(reversed(_bidir_pass1_trj))
946
+ all_trj_blocks.extend(trj_blocks)
947
+ _bidir_pass1_trj = []
948
+ else:
949
+ all_trj_blocks.extend(trj_blocks)
950
+ try:
951
+ convert_xyz_to_pdb(stage_trj_path, source_path.resolve(), stage_dir / "scan.pdb")
952
+ click.echo(f"[convert] Wrote '{stage_dir / 'scan.pdb'}'.")
953
+ except Exception as e:
954
+ click.echo(f"[convert] WARNING: Failed to convert stage trajectory to PDB: {e}", err=True)
955
+
956
+ final_xyz = stage_dir / "result.xyz"
957
+ with open(final_xyz, "w") as f:
958
+ f.write(_coords3d_to_xyz_string(geom))
959
+ click.echo(f"[write] Wrote '{final_xyz}'.")
960
+ try:
961
+ convert_xyz_to_pdb(final_xyz, source_path.resolve(), stage_dir / "result.pdb")
962
+ click.echo(f"[convert] Wrote '{stage_dir / 'result.pdb'}'.")
963
+ except Exception as e:
964
+ click.echo(f"[convert] WARNING: Failed to convert stage result to PDB: {e}", err=True)
965
+
966
+ # ------------------------------------------------------------------
967
+ # 4b) Write combined scan_trj.xyz + scan.pdb to out_dir
968
+ # ------------------------------------------------------------------
969
+ if all_trj_blocks:
970
+ combined_trj = out_dir_path / "scan_trj.xyz"
971
+ with open(combined_trj, "w") as f:
972
+ f.write("".join(all_trj_blocks))
973
+ click.echo(f"[write] Wrote '{combined_trj}'.")
974
+ try:
975
+ convert_xyz_to_pdb(combined_trj, source_path.resolve(), out_dir_path / "scan.pdb")
976
+ click.echo(f"[convert] Wrote '{out_dir_path / 'scan.pdb'}'.")
977
+ except Exception as e:
978
+ click.echo(f"[convert] WARNING: Failed to convert combined trajectory to PDB: {e}", err=True)
979
+
980
+ # ------------------------------------------------------------------
981
+ # 5) Final summary echo (human‑friendly)
982
+ # ------------------------------------------------------------------
983
+ def _echo_human_summary(_stages: List[Dict[str, Any]], _max_step_size: float) -> None:
984
+ """
985
+ Print a readable end-of-run summary like the requested example.
986
+ """
987
+ def _fmt_target_value(x: float) -> str:
988
+ # 2.600 -> "2.6", 1.500 -> "1.5"
989
+ s = f"{x:.3f}".rstrip("0").rstrip(".")
990
+ return s
991
+
992
+ def _targets_triplet_str(pairs_1based: List[Tuple[int, int]], targets: List[float]) -> str:
993
+ triples = [f"({i}, {j}, {_fmt_target_value(t)})" for (i, j), t in zip(pairs_1based, targets)]
994
+ return "[" + ", ".join(triples) + "]"
995
+
996
+ def _list_of_str_3f(values: List[float]) -> str:
997
+ return "[" + ", ".join(f"'{v:.3f}'" for v in values) + "]"
998
+
999
+ click.echo("\nSummary")
1000
+ click.echo("------------------")
1001
+ for s in _stages:
1002
+ idx = int(s.get("index", 0))
1003
+ pairs_1b = list(s.get("pairs_1based", []))
1004
+ r0 = list(s.get("initial_distances_A", []))
1005
+ rT = list(s.get("target_distances_A", []))
1006
+ dA = list(s.get("per_pair_step_A", []))
1007
+ N = int(s.get("num_steps", 0))
1008
+ bchg = s.get("bond_change", {}) or {}
1009
+ changed = bool(bchg.get("changed"))
1010
+ summary_txt = (bchg.get("summary") or "").strip()
1011
+
1012
+ click.echo(f"[stage {idx}] Targets (i,j,target Å): { _targets_triplet_str(pairs_1b, rT) }")
1013
+ click.echo(f"[stage {idx}] initial distances (Å) = { _list_of_str_3f(r0) }")
1014
+ click.echo(f"[stage {idx}] target distances (Å) = { _list_of_str_3f(rT) }")
1015
+ click.echo(f"[stage {idx}] per_pair_step (Å) = { _list_of_str_3f(dA) }")
1016
+ click.echo(f"[stage {idx}] steps N = {N}")
1017
+ click.echo(f"[stage {idx}] Covalent-bond changes (start vs final): {'Yes' if changed else 'No'}")
1018
+ if changed and summary_txt:
1019
+ click.echo(textwrap.indent(summary_txt, prefix=" "))
1020
+ if not changed:
1021
+ click.echo(" (no covalent changes detected)")
1022
+ click.echo("") # blank line between stages
1023
+
1024
+ _echo_human_summary(stages_summary, float(max_step_size))
1025
+ # ------------------------------------------------------------------
1026
+
1027
+ click.echo("\n=== Scan finished ===\n")
1028
+
1029
+ click.echo(format_elapsed("[time] Elapsed Time for Scan", time_start))
1030
+
1031
+ except KeyboardInterrupt:
1032
+ click.echo("\nInterrupted by user.", err=True)
1033
+ sys.exit(130)
1034
+ except Exception as e:
1035
+ tb = "".join(traceback.format_exception(type(e), e, e.__traceback__))
1036
+ click.echo("Unhandled error during scan:\n" + textwrap.indent(tb, " "), err=True)
1037
+ sys.exit(1)
1038
+ finally:
1039
+ # Release GPU memory so subsequent pipeline stages don't OOM
1040
+ base_calc = biased = geom = optimizer = optimizer0 = end_optimizer = None
1041
+ gc.collect() # break cyclic refs inside torch.nn.Module
1042
+ if torch.cuda.is_available():
1043
+ torch.cuda.empty_cache()
1044
+
1045
+
1046
+ if __name__ == "__main__":
1047
+ cli()