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