BUCToolkit 1.0b2__tar.gz → 1.0.1__tar.gz

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 (157) hide show
  1. buctoolkit-1.0.1/BUCToolkit/Bases/BaseConstraints.py +1207 -0
  2. buctoolkit-1.0.1/BUCToolkit/Bases/BaseMotion.py +723 -0
  3. buctoolkit-1.0.1/BUCToolkit/Bases/StdContainer.py +45 -0
  4. buctoolkit-1.0.1/BUCToolkit/Bases/__init__.py +5 -0
  5. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/BUCToolkit/BatchGenerate/CheckStructures.py +3 -3
  6. {buctoolkit-1.0b2/BUCToolkit/BatchOptim/_utils → buctoolkit-1.0.1/BUCToolkit/BatchGenerate}/__init__.py +1 -1
  7. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/BUCToolkit/BatchGenerate/coords_interp.py +1 -1
  8. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/BUCToolkit/BatchGenerate/initial_ads_structures.py +1 -1
  9. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/BUCToolkit/BatchGenerate/random_replace.py +1 -1
  10. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/BUCToolkit/BatchMC/MetropolisMC.py +141 -47
  11. buctoolkit-1.0.1/BUCToolkit/BatchMC/_BaseMC.py +982 -0
  12. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/BUCToolkit/BatchMC/__init__.py +1 -1
  13. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/BUCToolkit/BatchMD/BiasedMD.py +9 -6
  14. buctoolkit-1.0.1/BUCToolkit/BatchMD/ConstrNVE.py +107 -0
  15. buctoolkit-1.0.1/BUCToolkit/BatchMD/ConstrNVT.py +115 -0
  16. buctoolkit-1.0.1/BUCToolkit/BatchMD/NVE.py +445 -0
  17. buctoolkit-1.0.1/BUCToolkit/BatchMD/NVT.py +1139 -0
  18. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/BUCToolkit/BatchMD/_BaseMD.py +450 -470
  19. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/BUCToolkit/BatchMD/__init__.py +4 -2
  20. buctoolkit-1.0.1/BUCToolkit/BatchMD/fused_ops/VelocityVerlet.py +219 -0
  21. buctoolkit-1.0.1/BUCToolkit/BatchMD/fused_ops/__init__.py +35 -0
  22. buctoolkit-1.0.1/BUCToolkit/BatchMD/fused_ops/thermostats.py +844 -0
  23. buctoolkit-1.0.1/BUCToolkit/BatchMD/fused_ops/utils.py +206 -0
  24. buctoolkit-1.0.1/BUCToolkit/BatchMD/utils.py +200 -0
  25. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/BUCToolkit/BatchOptim/TS/CI_NEB.py +13 -3
  26. buctoolkit-1.0.1/BUCToolkit/BatchOptim/TS/Dimer.py +432 -0
  27. buctoolkit-1.0.1/BUCToolkit/BatchOptim/TS/Krylov.py +1248 -0
  28. buctoolkit-1.0.1/BUCToolkit/BatchOptim/TS/__init__.py +6 -0
  29. buctoolkit-1.0.1/BUCToolkit/BatchOptim/TS/_eigen_solver.py +954 -0
  30. buctoolkit-1.0.1/BUCToolkit/BatchOptim/_BaseOpt.py +1252 -0
  31. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/BUCToolkit/BatchOptim/__init__.py +4 -2
  32. {buctoolkit-1.0b2/BUCToolkit/BatchGenerate → buctoolkit-1.0.1/BUCToolkit/BatchOptim/_utils}/__init__.py +1 -1
  33. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/BUCToolkit/BatchOptim/_utils/_line_search.py +287 -137
  34. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/BUCToolkit/BatchOptim/_utils/_warnings.py +1 -1
  35. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/BUCToolkit/BatchOptim/frequency.py +217 -166
  36. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/BUCToolkit/BatchOptim/minimize/CG.py +19 -10
  37. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/BUCToolkit/BatchOptim/minimize/FIRE.py +277 -28
  38. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/BUCToolkit/BatchOptim/minimize/QN.py +107 -43
  39. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/BUCToolkit/BatchOptim/minimize/__init__.py +3 -3
  40. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/BUCToolkit/BatchStructures/BatchStructuresBase.py +137 -16
  41. buctoolkit-1.0.1/BUCToolkit/BatchStructures/PropertiesContainer.py +136 -0
  42. buctoolkit-1.0.1/BUCToolkit/BatchStructures/StructuresIO.py +2342 -0
  43. buctoolkit-1.0.1/BUCToolkit/BatchStructures/__init__.py +65 -0
  44. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/BUCToolkit/BatchStructures/batch.py +24 -8
  45. buctoolkit-1.0.1/BUCToolkit/BatchStructures/convert_dump.py +306 -0
  46. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/BUCToolkit/BatchStructures/data.py +9 -1
  47. buctoolkit-1.0.1/BUCToolkit/Postprocessing/__init__.py +25 -0
  48. buctoolkit-1.0.1/BUCToolkit/Postprocessing/_collective_variables.py +151 -0
  49. buctoolkit-1.0.1/BUCToolkit/Postprocessing/blue_moon.py +294 -0
  50. buctoolkit-1.0.1/BUCToolkit/Postprocessing/constraint_work.py +223 -0
  51. buctoolkit-1.0.1/BUCToolkit/Postprocessing/trajectory.py +242 -0
  52. buctoolkit-1.0.1/BUCToolkit/Postprocessing/vibrational_spectrum.py +262 -0
  53. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/BUCToolkit/Preprocessing/__init__.py +1 -1
  54. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/BUCToolkit/Preprocessing/load_files.py +22 -170
  55. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/BUCToolkit/Preprocessing/preprocessing.py +107 -8
  56. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/BUCToolkit/Preprocessing/sampling.py +1 -1
  57. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/BUCToolkit/Preprocessing/supercells.py +1 -1
  58. buctoolkit-1.0.1/BUCToolkit/Preprocessing/write_files.py +1008 -0
  59. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/BUCToolkit/StandardTemplate.py +2 -2
  60. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/BUCToolkit/__init__.py +25 -3
  61. buctoolkit-1.0.1/BUCToolkit/_version.py +11 -0
  62. buctoolkit-1.0.1/BUCToolkit/api/ConstrainedMolecularDynamics.py +506 -0
  63. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/BUCToolkit/api/DataLoaders.py +270 -10
  64. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/BUCToolkit/api/Losses.py +4 -1
  65. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/BUCToolkit/api/Metrics.py +1 -1
  66. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/BUCToolkit/api/ModelOptims.py +2 -2
  67. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/BUCToolkit/api/MolecularDynamics.py +24 -15
  68. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/BUCToolkit/api/MonteCarlo.py +25 -16
  69. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/BUCToolkit/api/NEB.py +66 -41
  70. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/BUCToolkit/api/Predictor.py +40 -17
  71. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/BUCToolkit/api/StructureOptimization.py +197 -88
  72. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/BUCToolkit/api/Trainer.py +266 -21
  73. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/BUCToolkit/api/VibrationAnalysis.py +39 -40
  74. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/BUCToolkit/api/__init__.py +1 -1
  75. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/BUCToolkit/api/_io.py +461 -280
  76. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/BUCToolkit/cli/__init__.py +1 -1
  77. buctoolkit-1.0.1/BUCToolkit/cli/_config.py +269 -0
  78. buctoolkit-1.0.1/BUCToolkit/cli/_vasp_interface.py +57 -0
  79. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/BUCToolkit/cli/cli.py +369 -315
  80. buctoolkit-1.0.1/BUCToolkit/cli/convert_data.py +100 -0
  81. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/BUCToolkit/cli/input_stub.py +72 -17
  82. buctoolkit-1.0.1/BUCToolkit/cli/main.py +688 -0
  83. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/BUCToolkit/cli/print_logo.py +2 -2
  84. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/BUCToolkit/io.py +1 -1
  85. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/BUCToolkit/utils/AtomSelector.py +1 -1
  86. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/BUCToolkit/utils/AtomicNumber2Properties.py +1 -1
  87. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/BUCToolkit/utils/ElemListReduce.py +1 -1
  88. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/BUCToolkit/utils/IrregularTensorReformat.py +1 -1
  89. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/BUCToolkit/utils/_CheckModules.py +1 -1
  90. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/BUCToolkit/utils/_Element_info.py +15 -1
  91. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/BUCToolkit/utils/__init__.py +10 -3
  92. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/BUCToolkit/utils/_para_flatt_list.py +11 -1
  93. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/BUCToolkit/utils/_print_formatter.py +1 -1
  94. buctoolkit-1.0.1/BUCToolkit/utils/ab_initio_wrappers.py +136 -0
  95. buctoolkit-1.0.1/BUCToolkit/utils/batch_updaters.py +72 -0
  96. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/BUCToolkit/utils/check_structures.py +1 -1
  97. buctoolkit-1.0.1/BUCToolkit/utils/exceptions.py +6 -0
  98. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/BUCToolkit/utils/function_utils.py +45 -4
  99. buctoolkit-1.0.1/BUCToolkit/utils/grad_functions.py +148 -0
  100. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/BUCToolkit/utils/index_ops.py +181 -18
  101. buctoolkit-1.0.1/BUCToolkit/utils/model_wrappers/MACE_model_wrapper.py +436 -0
  102. buctoolkit-1.0.1/BUCToolkit/utils/model_wrappers/VASP_model_wrapper.py +150 -0
  103. buctoolkit-1.0.1/BUCToolkit/utils/model_wrappers/VASP_plugin_wrapper.py +1181 -0
  104. buctoolkit-1.0.1/BUCToolkit/utils/model_wrappers/__init__.py +33 -0
  105. buctoolkit-1.0.1/BUCToolkit/utils/model_wrappers/dgl_model_wrappers.py +38 -0
  106. buctoolkit-1.0.1/BUCToolkit/utils/model_wrappers/multi_devices_wrappers_mp.py +765 -0
  107. buctoolkit-1.0.1/BUCToolkit/utils/model_wrappers/multi_devices_wrappers_mt.py +343 -0
  108. buctoolkit-1.0.1/BUCToolkit/utils/model_wrappers/online_model_wrappers.py +421 -0
  109. buctoolkit-1.0.1/BUCToolkit/utils/model_wrappers/pyg_model_wrappers.py +114 -0
  110. buctoolkit-1.0.1/BUCToolkit/utils/operators/__init__.py +0 -0
  111. buctoolkit-1.0.1/BUCToolkit/utils/operators/element_wise_ops.py +270 -0
  112. buctoolkit-1.0.1/BUCToolkit/utils/setup_loggers.py +56 -0
  113. buctoolkit-1.0.1/BUCToolkit.egg-info/PKG-INFO +985 -0
  114. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/BUCToolkit.egg-info/SOURCES.txt +40 -13
  115. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/BUCToolkit.egg-info/entry_points.txt +2 -1
  116. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/BUCToolkit.egg-info/requires.txt +10 -3
  117. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/BUCToolkit.egg-info/top_level.txt +1 -0
  118. buctoolkit-1.0.1/CHANGELOG.md +41 -0
  119. buctoolkit-1.0.1/MANIFEST.in +2 -0
  120. buctoolkit-1.0.1/PKG-INFO +985 -0
  121. buctoolkit-1.0.1/README.md +957 -0
  122. buctoolkit-1.0.1/pyproject.toml +62 -0
  123. buctoolkit-1.0b2/BUCToolkit/BatchMC/_BaseMC.py +0 -863
  124. buctoolkit-1.0b2/BUCToolkit/BatchMD/NVE.py +0 -92
  125. buctoolkit-1.0b2/BUCToolkit/BatchMD/NVT.py +0 -510
  126. buctoolkit-1.0b2/BUCToolkit/BatchMD/constrained_md/BiasedMD.py +0 -273
  127. buctoolkit-1.0b2/BUCToolkit/BatchMD/constrained_md/ConstrNVE.py +0 -169
  128. buctoolkit-1.0b2/BUCToolkit/BatchMD/constrained_md/ConstrNVT.py +0 -462
  129. buctoolkit-1.0b2/BUCToolkit/BatchMD/constrained_md/NVE.py +0 -78
  130. buctoolkit-1.0b2/BUCToolkit/BatchMD/constrained_md/NVT.py +0 -346
  131. buctoolkit-1.0b2/BUCToolkit/BatchMD/constrained_md/README.md +0 -4
  132. buctoolkit-1.0b2/BUCToolkit/BatchMD/constrained_md/_BaseMD.py +0 -440
  133. buctoolkit-1.0b2/BUCToolkit/BatchMD/constrained_md/_ConstrBaseMD.py +0 -607
  134. buctoolkit-1.0b2/BUCToolkit/BatchMD/constrained_md/__init__.py +0 -18
  135. buctoolkit-1.0b2/BUCToolkit/BatchOptim/TS/Dimer.py +0 -950
  136. buctoolkit-1.0b2/BUCToolkit/BatchOptim/TS/__init__.py +0 -10
  137. buctoolkit-1.0b2/BUCToolkit/BatchOptim/_BaseOpt.py +0 -702
  138. buctoolkit-1.0b2/BUCToolkit/BatchStructures/StructuresIO.py +0 -1394
  139. buctoolkit-1.0b2/BUCToolkit/BatchStructures/__init__.py +0 -22
  140. buctoolkit-1.0b2/BUCToolkit/Preprocessing/write_files.py +0 -524
  141. buctoolkit-1.0b2/BUCToolkit/_version.py +0 -7
  142. buctoolkit-1.0b2/BUCToolkit/api/ConstrainedMolecularDynamics.py +0 -349
  143. buctoolkit-1.0b2/BUCToolkit/cli/convert_data.py +0 -56
  144. buctoolkit-1.0b2/BUCToolkit/cli/main.py +0 -386
  145. buctoolkit-1.0b2/BUCToolkit/cli/template_model.py +0 -6
  146. buctoolkit-1.0b2/BUCToolkit/utils/Ewald.py +0 -210
  147. buctoolkit-1.0b2/BUCToolkit/utils/grad_functions.py +0 -69
  148. buctoolkit-1.0b2/BUCToolkit/utils/setup_loggers.py +0 -137
  149. buctoolkit-1.0b2/BUCToolkit.egg-info/PKG-INFO +0 -28
  150. buctoolkit-1.0b2/MANIFEST.in +0 -1
  151. buctoolkit-1.0b2/PKG-INFO +0 -28
  152. buctoolkit-1.0b2/README.md +0 -545
  153. buctoolkit-1.0b2/setup.py +0 -46
  154. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/BUCToolkit/BatchStructures/README.md +0 -0
  155. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/BUCToolkit.egg-info/dependency_links.txt +0 -0
  156. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/LICENSE +0 -0
  157. {buctoolkit-1.0b2 → buctoolkit-1.0.1}/setup.cfg +0 -0
@@ -0,0 +1,1207 @@
1
+ # Copyright (c) 2026.4.24, BUCToolkit.
2
+ # Authors: Pu Pengxin, Song Xin
3
+ # Version: 1.0.1
4
+ # File: BaseConstraints.py
5
+ # Environment: Python 3.12
6
+ import inspect
7
+ import math
8
+ import os
9
+ import warnings
10
+ from typing import Iterable, Dict, Any, List, Literal, Optional, Callable, Sequence, Tuple # noqa: F401
11
+
12
+ import torch as th
13
+ from torch import nn
14
+ import numpy as np
15
+
16
+ from BUCToolkit.utils._print_formatter import FLOAT_ARRAY_FORMAT, SCIENTIFIC_ARRAY_FORMAT
17
+ from BUCToolkit.utils._Element_info import DTYPE
18
+ from BUCToolkit.utils.grad_functions import bhvp
19
+ from BUCToolkit.Bases.BaseMotion import BaseIO
20
+
21
+ FLOAT_TYPE = os.environ.get('BT_FLOAT_TYPE', 'float32')
22
+ FLOAT_TYPE = DTYPE.get(FLOAT_TYPE, th.float32)
23
+
24
+ # Probe reference precision for the threshold estimate. Kept local to this
25
+ # module: the map only decides which dtype is used to measure the round-off
26
+ # of the user constraint function.
27
+ _PROBE_DTYPE = {
28
+ th.float16: th.float32,
29
+ th.bfloat16: th.float32,
30
+ th.float32: th.float64,
31
+ th.float64: th.float64,
32
+ }
33
+
34
+
35
+ def _report_diagnostic(message: str, verbose: int, logger: Any) -> None:
36
+ """
37
+ Report a constraint diagnostic on the channel selected by verbosity.
38
+
39
+ Args:
40
+ message: Human-readable description of the diagnostic.
41
+ verbose: Diagnostic verbosity of the constraint component.
42
+ logger: Logger used when ``verbose > 0``.
43
+
44
+ Return: None.
45
+
46
+ Note:
47
+ At ``verbose == 0`` the message is emitted as a ``RuntimeWarning`` so
48
+ that it still reaches stderr after the standard logger has been
49
+ redirected to a file; at ``verbose > 0`` it goes to ``logger`` at
50
+ WARNING level. The default warning filter reports each call site once.
51
+ """
52
+ if verbose > 0:
53
+ logger.warning(message)
54
+ else:
55
+ warnings.warn(message, RuntimeWarning, stacklevel=2)
56
+
57
+
58
+ class BaseConstr(BaseIO):
59
+ """
60
+ Base Constraints used for constraints MD or optimizations.
61
+ It can be combined into any normal gradient-based algorithms by applying `project1` to gradient or any other tangent space vectors,
62
+ and `project2` as a retraction mapping to coordinates or any other vectors on the manifold.
63
+
64
+ I strongly suggest composition instead of inheritance when BaseConstr is
65
+ embedded in another class: create the component as ``self._constr`` and
66
+ call its methods directly (for example ``self._constr._project2(...)``).
67
+ Do not reproduce a dynamic ``__getattr__`` proxy, which makes the host
68
+ class opaque to tooling and compilers.
69
+
70
+ Args:
71
+ constr_func: Callable, a tuple of Python functions as the constraint functions s_k(X) that map R^n -> R^k.
72
+ It takes one or more arguments, one of which must be a Tensor, and returns one Tensor with shape (k, ).
73
+ `None` for constant function that always return [0., ]. It should support auto-gradient ops. see example below.
74
+ constr_val: Callable[..., th.Tensor] | th.Tensor, the constraint value of `constr_func`,
75
+ i.e., constraints are `constr_func(X) = constr_val`.
76
+ A time-dependent callback may be `constr_val(t)` or
77
+ `constr_val(t, c0)`, where `t` is a scalar Tensor and `c0` is the
78
+ value of `constr_func(X_init)` computed at initialization.
79
+ constr_threshold: float | Literal['auto'], the threshold of constraint convergence (error of manifold violation).
80
+ ``'auto'`` estimates the achievable floor at initialization and
81
+ uses three times that value; it requires threshold detection.
82
+ require_fixman: bool, whether to calculate fixman
83
+ retraction_solver: Literal['newton', 'damped'], the position projection solver.
84
+ ``'newton'`` takes the default full Newton step; ``'damped'`` adds
85
+ a backtracking line search that is slower but can recover from an
86
+ ill-conditioned normal-space Jacobian. The solver is bound at
87
+ construction; reassign ``_retraction_map_solver`` to switch later.
88
+ device: str|torch.device, device that program rum on.
89
+ verbose: print level. 0 is silent, 1 permits scalar diagnostics only,
90
+ and 2 or greater also permits array diagnostics.
91
+ 0 for silence, 1 for output Energy and Forces per step, 2 for output all structures.
92
+ _detect_dynamic_thres: Literal['None', 'Once'] | int, when to re-estimate
93
+ the achievable threshold floor. ``'Once'`` estimates it only during
94
+ initialization, ``'None'`` disables the estimate, and an ``int``
95
+ re-estimates it every that many projection calls. Each estimate
96
+ evaluates ``constr_func`` a few extra times for probing.
97
+
98
+ Examples for constrains:
99
+ def constr_func(X):
100
+ y = list()
101
+ # X: shape(N, D), Note the batch dimension would NOT be considered in constraints calculation of X.
102
+ # fix the distance between atoms (2, 4), (3, 7), (5, 8) into corresponding `constr_val[:3]`
103
+ y.append(th.linalg.norm(X[[2, 3, 5]] - X[[4, 7, 8]], dim=-1))
104
+
105
+ # fix the angle of atom7-atom5-atom8 and atom11-atom9-atom12 into corresponding `constr_val[3:6]`
106
+ x1 = X[[5, 9]]
107
+ x2 = X[[7, 11]]
108
+ x3 = X[[8, 12]]
109
+ y.append(
110
+ (
111
+ th.sum((x2 - x1) * (x3 - x1))
112
+ ) / (th.linalg.norm(x2 - x1) * th.linalg.norm(x3 - x1))
113
+ )
114
+ z = th.cat(y)
115
+ return z
116
+
117
+ Methods:
118
+ run: run BatchMD.
119
+
120
+ """
121
+ def __init__(
122
+ self,
123
+ constr_func: Callable | None = None,
124
+ constr_val: Callable[..., th.Tensor|Tuple[th.Tensor]] | th.Tensor | None = None,
125
+ constr_threshold: float | Literal['auto'] = 'auto',
126
+ require_fixman: bool = False,
127
+ retraction_solver: Literal['newton', 'damped'] = 'newton',
128
+ device: str | th.device = 'cpu',
129
+ verbose: int = 0,
130
+ _detect_dynamic_thres: Literal['None', 'Once'] | int = 'Once',
131
+ ):
132
+ # var check
133
+ if retraction_solver not in ('newton', 'damped'):
134
+ raise ValueError(
135
+ f"`retraction_solver` must be 'newton' or 'damped', but got {retraction_solver!r}."
136
+ )
137
+ if _detect_dynamic_thres not in ('None', 'Once'):
138
+ _detect_dynamic_thres = int(_detect_dynamic_thres)
139
+ if _detect_dynamic_thres <= 0:
140
+ raise ValueError(f"`int(_detect_dynamic_thres)` must be > 0, but got {_detect_dynamic_thres}")
141
+ if constr_threshold != 'auto':
142
+ constr_threshold = float(constr_threshold)
143
+ if constr_threshold <= 0:
144
+ raise ValueError(f"`constr_threshold` must be > 0, but got {constr_threshold}")
145
+ elif _detect_dynamic_thres == 'None':
146
+ raise ValueError(
147
+ "`constr_threshold='auto'` requires threshold detection, but "
148
+ "`_detect_dynamic_thres='None'`."
149
+ )
150
+
151
+ if constr_func is None:
152
+ constr_func = lambda X: th.tensor(0., dtype=X.dtype, device=X.device)
153
+ self._lazy_calc_constr_val = False # use the value of constr_func(X_init) to determine constr_val (and fixed).
154
+ self.device = th.device(device)
155
+
156
+ self.time_now = th.scalar_tensor(0., device=device, dtype=FLOAT_TYPE)
157
+ self.is_const_constr = False
158
+ self.constr_val_func_raw = None
159
+ self._constr_val_func = None
160
+ self._constr_val_uses_c0 = False
161
+ self._constr_val_c0 = None
162
+ self.time_step = 1.
163
+ self.require_fixman = require_fixman
164
+ self.retraction_solver = retraction_solver
165
+
166
+ if isinstance(constr_val, th.Tensor):
167
+ self.is_const_constr = True
168
+ self.constr_val_func_raw = None
169
+ self.constr_val_now = constr_val.to(self.device, dtype=FLOAT_TYPE)
170
+ elif callable(constr_val):
171
+ self.is_const_constr = False
172
+ self.constr_val_func_raw = constr_val
173
+ self.constr_val_now = None
174
+ elif constr_val is None:
175
+ self.is_const_constr = True
176
+ self.constr_val_func_raw = None
177
+ self._lazy_calc_constr_val = True
178
+ else:
179
+ raise TypeError(f'Expected `constr_val` is Callable or torch.Tensor, but got {type(constr_val)}')
180
+
181
+ if not isinstance(constr_func, Callable):
182
+ raise TypeError(f"`constr_func` must consist of callable, but got {type(constr_func)}")
183
+ self.constr_func = constr_func
184
+ self.Q = th.tensor([], device=self.device, dtype=FLOAT_TYPE) # Q(n_batch, n_atoms * n_dim, n_constr)
185
+ self.jac = None # Jacobian Matrix
186
+ self.R = th.tensor([], device=self.device, dtype=FLOAT_TYPE) # R(n_batch, n_constr, n_constr)
187
+ self.q = th.tensor([], device=self.device, dtype=FLOAT_TYPE) # (n_batch, n_constr, 1), solution of `R^T q = d/dt constr_val(t)`
188
+ self.sqrtM = None # M^1/2, (n_batch, n_atoms, n_dim)
189
+ self.negsqrtM = None # M^-1/2
190
+ self.max_proj_iter = 10
191
+ self.retraction_stagnation_factor = 0.05
192
+ self.constr_thres = constr_threshold
193
+ self._is_auto_thres = (constr_threshold == 'auto')
194
+ self._detect_dynamic_thres = _detect_dynamic_thres
195
+ self._proj2_call_number: int = 0 # the count of `project2` call. used for auto-detection sheme.
196
+ self._thres_below = False # flag indices whether current constr thres lower than the estimated infimum.
197
+ self.X_cache = None # constr force, i.e., the mu of Lagrange multipler
198
+ self._compiled_jac = None # pre-compiled Jacobian for replay
199
+
200
+ self._X_qr_cache = None # check the consistency of X in project1, ensuring it is the new tangent space projection
201
+
202
+ super().__init__()
203
+ self.init_logger('Main.Constraints')
204
+ self.verbose = int(verbose)
205
+ # Bind the concrete retraction implementation once. The solver is
206
+ # selected at construction; reassign this attribute to switch later.
207
+ if retraction_solver == 'newton':
208
+ self._retraction_map_solver = self._retraction_newton
209
+ else:
210
+ self._retraction_map_solver = self._retraction_damp_newton
211
+
212
+ def initialize(
213
+ self,
214
+ func: Any | nn.Module,
215
+ X: th.Tensor,
216
+ Element_list: List[List[str]] | List[List[int]] | None,
217
+ masses: th.Tensor | None,
218
+ V_init: th.Tensor | None = None,
219
+ grad_func: Any | nn.Module = None,
220
+ func_args: Sequence = tuple(),
221
+ func_kwargs: Dict | None = None,
222
+ grad_func_args: Sequence = tuple(),
223
+ grad_func_kwargs: Dict | None = None,
224
+ is_grad_func_contain_y: bool = True,
225
+ require_grad: bool = False,
226
+ batch_indices: List[int] | Tuple[int, ...] | th.Tensor | np.ndarray | None = None,
227
+ fixed_atom_tensor: Optional[th.Tensor] = None,
228
+ is_fix_mass_center: bool = False,
229
+ compile_jacobian: bool = True,
230
+ ):
231
+ """Initialize masses, targets, and the tangent-space basis.
232
+
233
+ This method owns all state required by constraint projection. Proxy
234
+ users such as constrained MD and constrained FIRE should call it rather
235
+ than reproducing the setup or invoking ``_build_compiled_jacobian``
236
+ directly.
237
+
238
+ Args:
239
+ func: Parent algorithm objective. Accepted for compatibility with
240
+ the common motion-initialization hook; constraints do not use it.
241
+ X: Initial coordinates with shape
242
+ ``(n_batch, n_atom, n_dim)``.
243
+ Element_list: Atomic symbols or numbers supplied by the parent
244
+ algorithm. Constraint initialization does not use them.
245
+ masses: Per-coordinate masses broadcast to the shape of ``X``. If
246
+ ``None``, unit masses are used.
247
+ V_init: Initial velocities accepted by the common initialization
248
+ hook. Constraint initialization does not use them.
249
+ grad_func: Parent objective-gradient callable. Not used here.
250
+ func_args: Positional objective arguments. Not used here.
251
+ func_kwargs: Keyword objective arguments. Not used here.
252
+ grad_func_args: Positional gradient arguments. Not used here.
253
+ grad_func_kwargs: Keyword gradient arguments. Not used here.
254
+ is_grad_func_contain_y: Parent gradient-call convention. Not used
255
+ by constraint initialization.
256
+ require_grad: Parent objective autograd flag. Not used here.
257
+ batch_indices: Irregular-batch atom counts. Not used here because
258
+ ``constr_func`` is vectorized over the leading dimension of X.
259
+ fixed_atom_tensor: Parent selective-dynamics mask. Not used here.
260
+ is_fix_mass_center: Parent center-of-mass setting. Not used here.
261
+ compile_jacobian: Whether to compile the batched constraint
262
+ Jacobian. Compilation is enabled by default on both CPU and
263
+ CUDA. Unsupported constraint graphs automatically fall back to
264
+ the eager implementation.
265
+
266
+ Returns:
267
+ None. The method updates mass factors, the current constraint
268
+ target, Jacobian/QR state, compilation state, and projection cache.
269
+
270
+ Raises:
271
+ TypeError: If ``compile_jacobian`` is not ``bool``.
272
+ Also raised when a time-dependent ``constr_val`` callback has
273
+ no inspectable signature accepting ``(t)`` or ``(t, c0)``.
274
+ RuntimeError: If ``constr_val`` and ``constr_func(X)`` have
275
+ different shapes.
276
+ ValueError: If the batched constraint residual is not two-dimensional.
277
+
278
+ Note:
279
+ ``X``, ``masses``, and a tensor constraint target are converted to
280
+ the module-level ``FLOAT_TYPE`` (``BT_FLOAT_TYPE`` environment
281
+ variable, default float32) before use.
282
+ """
283
+ if not isinstance(compile_jacobian, bool):
284
+ raise TypeError(
285
+ f'`compile_jacobian` must be bool, but got {type(compile_jacobian)}.'
286
+ )
287
+ X = X.to(device=self.device, dtype=FLOAT_TYPE)
288
+ if masses is not None:
289
+ masses = masses.to(device=self.device, dtype=FLOAT_TYPE)
290
+ if masses is None: masses = th.ones_like(X)
291
+ self.sqrtM = th.sqrt(masses) # M^1/2, (n_batch, n_atoms, n_dim)
292
+ self.negsqrtM = 1 / th.sqrt(masses) # M^-1/2
293
+ _y_check = th.vmap(self.constr_func)(X)
294
+ if self._lazy_calc_constr_val:
295
+ self.constr_val_now = _y_check
296
+ elif not self.is_const_constr:
297
+ # Keep the initial target as a detached, immutable run reference;
298
+ # later time updates must not retain the coordinate autograd graph.
299
+ self._constr_val_c0 = _y_check.detach().clone()
300
+ try:
301
+ constr_val_signature = inspect.signature(self.constr_val_func_raw)
302
+ except (TypeError, ValueError) as error:
303
+ raise TypeError(
304
+ '`constr_val` must have an inspectable signature accepting '
305
+ '(t) or (t, c0).'
306
+ ) from error
307
+
308
+ try:
309
+ constr_val_signature.bind(self.time_now, self._constr_val_c0)
310
+ except TypeError:
311
+ try:
312
+ constr_val_signature.bind(self.time_now)
313
+ except TypeError as unary_error:
314
+ raise TypeError(
315
+ '`constr_val` must accept either (t) or (t, c0).'
316
+ ) from unary_error
317
+ self._constr_val_uses_c0 = False
318
+ self._constr_val_func = self.constr_val_func_raw
319
+ else:
320
+ self._constr_val_uses_c0 = True
321
+ self._constr_val_func = lambda t: self.constr_val_func_raw(t, self._constr_val_c0)
322
+ self._update_constr(self.time_now)
323
+ if self.verbose > 0:
324
+ self.logger.info(
325
+ f'Constraint values are now {np.array2string(self.constr_val_now.squeeze().numpy(force=True), **SCIENTIFIC_ARRAY_FORMAT)}'
326
+ )
327
+ # check constr_val shape
328
+ if _y_check.shape != self.constr_val_now.shape:
329
+ raise RuntimeError(
330
+ f'`constr_val` must have the same shape as what constr_func returned {self.constr_val_now.shape}, but got {_y_check.shape}.'
331
+ )
332
+ self.constr_val_now = self.constr_val_now.to(self.device, dtype=FLOAT_TYPE)
333
+ # A previous initialization may have compiled a different run shape or
334
+ # constraint callable. Perform validation through the eager path, then
335
+ # build a fresh graph for the current inputs below.
336
+ self._compiled_jac = None
337
+ self._proj2_call_number = 0
338
+ self._thres_below = False
339
+ jac, y = self._jacobian(X)
340
+ if y.ndim != 2:
341
+ raise ValueError(f'`constr_func` must return a 2D tensor of shape (n_batch, n_constr), but got {y.shape}.')
342
+ if compile_jacobian:
343
+ self._build_compiled_jacobian(X)
344
+ self._do_qr(jac)
345
+ if self._detect_dynamic_thres != 'None':
346
+ # Numerical rank deficiency is a one-off initialization diagnostic;
347
+ # it is not repeated in the projection loop.
348
+ weighted_jac = th.flatten(jac * self.negsqrtM.unsqueeze(1), -2, -1)
349
+ spectrum = th.linalg.svdvals(weighted_jac)
350
+ eps = th.finfo(X.dtype).eps
351
+ sigma_min = float(spectrum.min())
352
+ sigma_max = float(spectrum.max())
353
+ if sigma_max > 0. and sigma_min <= sigma_max * eps ** 0.5:
354
+ _report_diagnostic(
355
+ 'The constraint Jacobian is numerically rank-deficient at '
356
+ f'the working precision (sigma_min={sigma_min:.3e}, '
357
+ f'sigma_max={sigma_max:.3e}, 1/sqrt(eps)={eps ** -0.5:.3e}). '
358
+ 'The projection may not converge. Please ensure that your constraints have a full-rank Jacobian '
359
+ '(almost everywhere). If so, try to set `retraction_solver=\'damped\'`.',
360
+ self.verbose,
361
+ self.logger,
362
+ )
363
+ if self._detect_dynamic_thres == 'Once' or isinstance(self._detect_dynamic_thres, int):
364
+ self._apply_thres_estimate(self._detect_thres_inf(X, jac), X)
365
+ self.X_cache = th.empty_like(X)
366
+
367
+ def _constr_func_wrapped(self, X, constr_val_now):
368
+ """
369
+ Manage the constraint functions.
370
+ Converting constraint functions into s_k(X) = constr_func(X) - constr_val, thereby constraints are s_k(X) = 0.
371
+ Returns:
372
+
373
+ """
374
+ y = self.constr_func(X) - constr_val_now # (n_batch, n_constr, )
375
+ y = th.atleast_1d(y)
376
+ return y, y # repeat the output to use `has_aux` in th.func.jacrev to return the function values
377
+
378
+ def _constr_func_for_hvp(self, X):
379
+ """
380
+ Manage the constraint functions used for HVP.
381
+ Converting constraint functions into s_k(X) = constr_func(X) - constr_val, thereby constraints are s_k(X) = 0.
382
+ Returns:
383
+
384
+ """
385
+ y = self.constr_func(X) - self.constr_val_now # (n_batch, n_constr, )
386
+ y = th.atleast_1d(y)
387
+ return y
388
+
389
+ def _constr_val_wrapped(self, t):
390
+ """
391
+ Manage the time-dependent constraint values.
392
+ Args:
393
+ t:
394
+
395
+ Returns:
396
+
397
+ """
398
+ if self.is_const_constr:
399
+ return None
400
+ else:
401
+ y = self._constr_val_func(t)
402
+ if isinstance(y, (Tuple, List)):
403
+ y = th.vstack(y).mT
404
+ else:
405
+ y = y.view(self._constr_val_c0.shape)
406
+
407
+ return y, y
408
+
409
+ def _update_constr(self, t: th.Tensor | float):
410
+ """Update a time-dependent constraint at an explicit evaluation time.
411
+
412
+ This method does not mutate ``self.time_now``. Integrators therefore
413
+ remain responsible for advancing the committed simulation clock and
414
+ may evaluate constraint stages such as ``time_now + time_step`` or a
415
+ half-step without changing the parent motion-loop state.
416
+
417
+ Args:
418
+ t: Scalar time at which ``constr_val_func_raw`` and its derivative
419
+ are evaluated. Python numeric values are converted to the same
420
+ device and dtype as ``self.time_now``.
421
+
422
+ Returns:
423
+ None. For a time-dependent constraint, ``self.constr_val_now`` and
424
+ ``self.d_constr`` are replaced by the value and time derivative at
425
+ ``t``. Constant constraints are unchanged.
426
+
427
+ Raises:
428
+ ValueError: If ``t`` is not scalar.
429
+ """
430
+ if not self.is_const_constr:
431
+ t = th.as_tensor(
432
+ t,
433
+ device=self.time_now.device,
434
+ dtype=self.time_now.dtype,
435
+ )
436
+ if t.numel() != 1:
437
+ raise ValueError(f'Constraint evaluation time must be scalar, but got shape {t.shape}.')
438
+ t = t.reshape(())
439
+ self.d_constr, self.constr_val_now = th.func.jacrev(
440
+ self._constr_val_wrapped,
441
+ has_aux=True,
442
+ )(t) # d constr_val(t) / dt
443
+ # ensure the returns from udf function keeping the dtype and device.
444
+ self.d_constr = self.d_constr.to(self.device, dtype=FLOAT_TYPE)
445
+ self.constr_val_now = self.constr_val_now.to(self.device, dtype=FLOAT_TYPE)
446
+
447
+ def _build_compiled_jacobian(self, X_sample: th.Tensor):
448
+ """
449
+ Pre-compile the batched constraint Jacobian for a fixed run shape.
450
+
451
+ Each structure and its corresponding constraint target are mapped as a
452
+ pair. The resulting function returns the same tensors as the eager
453
+ ``vmap(jacrev(...))`` path: a Jacobian with shape
454
+ ``(n_batch, n_constr, n_atom, n_dim)`` and residual values with shape
455
+ ``(n_batch, n_constr)``. Molecular-dynamics runs keep these dimensions
456
+ fixed, which permits a static full-graph compilation and avoids graph
457
+ breaks inside every projection iteration.
458
+
459
+ Falls back to eager mode if compilation fails.
460
+
461
+ Args:
462
+ X_sample: Representative coordinates with shape
463
+ ``(n_batch, n_atom, n_dim)``. Its shape, device, and dtype are
464
+ used to compile and warm up the Jacobian graph.
465
+
466
+ Returns:
467
+ None. On success, ``self._compiled_jac`` stores the compiled
468
+ callable. On failure it is reset to ``None`` so ``_jacobian`` uses
469
+ the eager implementation.
470
+ """
471
+ self._compiled_jac = None
472
+ try:
473
+ def _wrapped(X_single, constr_val):
474
+ y = self.constr_func(X_single) - constr_val
475
+ y = th.atleast_1d(y)
476
+ return y, y
477
+
478
+ jac_fn = th.func.vmap(
479
+ th.func.jacrev(_wrapped, argnums=0, has_aux=True),
480
+ # Pair X[i] with constr_val_now[i]. Broadcasting the complete
481
+ # target tensor here would introduce a second batch dimension.
482
+ in_dims=(0, 0)
483
+ )
484
+ compiled_jac = th.compile(jac_fn, fullgraph=True, dynamic=False)
485
+
486
+ # Warm up now so compilation latency and unsupported operations are
487
+ # handled before entering the MD loop. Shape validation prevents a
488
+ # malformed compiled graph from silently replacing the eager path.
489
+ # Match the gradient mode used by _project2. Without this, a normal
490
+ # non-Fixman MD run warms the graph with gradients enabled and then
491
+ # recompiles it on first use inside the no_grad main loop.
492
+ with th.set_grad_enabled(self.require_fixman):
493
+ jac, y = compiled_jac(X_sample, self.constr_val_now)
494
+ expected_jac_shape = (
495
+ X_sample.shape[0], self.constr_val_now.shape[-1],
496
+ *X_sample.shape[1:]
497
+ )
498
+ expected_y_shape = self.constr_val_now.shape
499
+ if jac.shape != expected_jac_shape or y.shape != expected_y_shape:
500
+ raise RuntimeError(
501
+ 'Compiled constraint Jacobian returned invalid shapes: '
502
+ f'expected {expected_jac_shape} and {expected_y_shape}, '
503
+ f'but got {jac.shape} and {y.shape}.'
504
+ )
505
+ self._compiled_jac = compiled_jac
506
+
507
+ if self.verbose:
508
+ self.logger.info('Constraint Jacobian compiled successfully.')
509
+ except Exception as e:
510
+ if self.verbose:
511
+ self.logger.warning(f'Jacobian compilation failed ({type(e).__name__}): {e}. Falling back to eager.')
512
+ self._compiled_jac = None
513
+
514
+ def _jacobian(self, X: th.Tensor) -> Tuple[th.Tensor, th.Tensor]:
515
+ """
516
+ Compute the Jacobian for the current constraint target.
517
+
518
+ ``constr_val_now`` and ``d_constr`` must already correspond to the
519
+ current simulation time. Projection entry points refresh that state
520
+ before calling this method, which avoids differentiating a
521
+ time-dependent target in every nonlinear projection subiteration.
522
+
523
+ Args:
524
+ X: Coordinates with shape ``(n_batch, n_atom, n_dim)``.
525
+
526
+ Returns:
527
+ The Jacobian with shape
528
+ ``(n_batch, n_constr, n_atom, n_dim)`` and residual values with
529
+ shape ``(n_batch, n_constr)``.
530
+
531
+ """
532
+ if self._compiled_jac is not None:
533
+ jac, y = self._compiled_jac(X, self.constr_val_now)
534
+ else:
535
+ jac, y = th.vmap(th.func.jacrev(self._constr_func_wrapped, has_aux=True))(X, self.constr_val_now)
536
+ self.jac = jac
537
+ return jac, y
538
+
539
+ def _do_qr(self, jac: th.Tensor):
540
+ """
541
+ Do QR factorization and save/update self.Q.
542
+ Args:
543
+ jac: Jacobian matrix of s_k at X.
544
+
545
+ Returns: None
546
+
547
+ """
548
+ # weighted jac: J_w = J @ M^-1/2,
549
+ # (n_batch, n_constr, n_atoms, n_dim) * (n_batch, 1, n_atoms, n_dim) -flatten-> (n_batch, n_constr, n_atoms * n_dim)
550
+ _jac = th.flatten(jac * self.negsqrtM.unsqueeze(1), -2, -1)
551
+ _jacT = _jac.mT.contiguous()
552
+ self.Q, self.R = th.linalg.qr(_jacT) # Q(n_batch, n_atoms * n_dim, n_constr) , R(n_batch, n_constr, n_constr)
553
+
554
+ def _detect_thres_inf(self, X: th.Tensor, jac: th.Tensor | None = None) -> float | None:
555
+ """
556
+ Estimate the smallest achievable constraint residual for this dtype.
557
+
558
+ The estimate combines the measured round-off of the user constraint
559
+ function with the coordinate round-off amplified by the normal-space
560
+ spectrum. The function is probed at a few deterministic ULP-scale
561
+ perturbations in the working dtype and, when possible, in the next
562
+ precision selected by the local ``_PROBE_DTYPE`` map.
563
+
564
+ Args:
565
+ X: Reference coordinates with shape ``(n_batch, n_atom, n_dim)``.
566
+ jac: Constraint Jacobian at ``X``. Recomputed when omitted.
567
+
568
+ Returns:
569
+ The estimated residual floor, or ``None`` when the constraint
570
+ function cannot be probed in the reference precision.
571
+ """
572
+ if jac is None:
573
+ jac, _ = self._jacobian(X)
574
+ work_dtype = X.dtype
575
+ reference_dtype = _PROBE_DTYPE.get(work_dtype, work_dtype)
576
+ with th.no_grad():
577
+ weighted_jac = th.flatten(jac * self.negsqrtM.unsqueeze(1), -2, -1)
578
+ sigma_max = float(th.linalg.svdvals(weighted_jac).max())
579
+ eps = th.finfo(work_dtype).eps
580
+ coordinate_scale = max(1., float(X.abs().max())) # bound of input X, clamp to 1 to avoid too small values.
581
+ evaluation_floor = 0.
582
+ if reference_dtype != work_dtype:
583
+ try:
584
+ rng = th.Generator(device=X.device).manual_seed(114514) # the hard-code seed is only for reproduction
585
+ noise = th.randn(X.shape, generator=rng, device=X.device, dtype=X.dtype)
586
+ # 8 ULP of the largest coordinate; clamped to avoid underflow
587
+ step = 8. * eps * X.abs().clamp_min(coordinate_scale)
588
+ probes = (X, X + step * noise, X - step * noise)
589
+ values_work = th.stack([th.vmap(self.constr_func)(p) for p in probes])
590
+ values_reference = th.stack([
591
+ th.vmap(self.constr_func)(p.to(reference_dtype)) for p in probes
592
+ ]) # (3, batch, n_constr)
593
+
594
+ if values_reference.dtype != reference_dtype:
595
+ return None
596
+ difference = (values_work.to(reference_dtype) - values_reference).abs()
597
+ if not bool(th.isfinite(difference).all()):
598
+ return None
599
+ evaluation_floor = float(difference.max())
600
+ except Exception:
601
+ return None
602
+ return float(
603
+ 2. * evaluation_floor
604
+ + 2. * sigma_max * eps * coordinate_scale
605
+ ) # count both the inherent error from constr_function and the error from Jacobian
606
+
607
+ def _apply_thres_estimate(self, floor: float|None, X: th.Tensor) -> None:
608
+ """
609
+ Apply or warn about an estimated constraint-residual floor.
610
+
611
+ Args:
612
+ floor: Estimate from ``_detect_thres_inf``; ``None`` when it failed.
613
+ X: Coordinates used for the estimate.
614
+
615
+ Returns:
616
+ None. An ``'auto'`` threshold is replaced by three times the floor;
617
+ an explicit threshold is never changed and is only warned about
618
+ when it lies below half of the estimated floor.
619
+
620
+ Raises:
621
+ RuntimeError: If the threshold is ``'auto'`` and the estimate failed.
622
+ """
623
+ if floor is None:
624
+ if self._is_auto_thres:
625
+ raise RuntimeError(
626
+ 'Unable to estimate the constraint threshold floor for the '
627
+ 'given constraint function and dtype; set an explicit '
628
+ '`constr_threshold` or make the constraint function '
629
+ 'evaluable in the probe precision.'
630
+ )
631
+ _report_diagnostic(
632
+ 'Unable to estimate the constraint threshold floor for the '
633
+ 'given constraint function; the user-supplied '
634
+ '`constr_threshold` is used unchanged.',
635
+ self.verbose,
636
+ self.logger,
637
+ )
638
+ return
639
+ if self._is_auto_thres:
640
+ self.constr_thres = max(3. * floor, th.finfo(X.dtype).eps)
641
+ return
642
+ is_below = float(self.constr_thres) < 0.5 * floor
643
+ if is_below and not self._thres_below:
644
+ _report_diagnostic(
645
+ f'`constr_threshold`={self.constr_thres} is below half of the '
646
+ f'estimated achievable floor ({floor:.3e} for dtype '
647
+ f'{X.dtype}); convergence may stagnate.',
648
+ self.verbose,
649
+ self.logger,
650
+ )
651
+ self._thres_below = is_below
652
+
653
+ def _project1(self, V:th.Tensor, X:th.Tensor | None = None, out: th.Tensor | None = None) -> th.Tensor:
654
+ """
655
+ Project X into the manifold defined by all constrains by QR factorization.
656
+ Usually used for update velocity V.
657
+ for Jacobian matrix J of constraints s_k at X, let J^T M^-1/2 = QR, thus s_k(X + v * dt) = s_k(X) + R^T Q^T M^1/2 v * dt + O(dt^2)
658
+ define the projector: P = M^-1/2 Q_2 Q_2^T M^1/2 = M^-1/2 (I - Q_1 Q_1^T) M^1/2, where Q = [Q_1, Q_2], Q_1 is from the financial QRF.
659
+
660
+ Args:
661
+ V: the input tensor of shape (n_batch, n_atom, n_dim). It might be coordinates, velocity, and higher deviations.
662
+ X: the point that calculate the projector. If None, stored Q R (maybe last step) will be used.
663
+ out: the output tensor. If None, return a new tensor.
664
+
665
+ Returns: th.Tensor, the projected X.
666
+
667
+ """
668
+ n_batch, n_atoms, n_dim = V.shape
669
+ sqrtM = self.sqrtM # M^1/2, (n_batch, n_atoms, n_dim)
670
+ negsqrtM = self.negsqrtM # M^-1/2
671
+ # Check consistency. For time-dependent constraints, callers must have
672
+ # explicitly selected the target stage through ``_update_constr(t)`` or
673
+ # a preceding ``_project2`` call.
674
+ if X is not None:
675
+ jac, y = self._jacobian(X)
676
+ self._do_qr(jac)
677
+ #self._X_qr_cache = X.clone()
678
+
679
+ # P = M^-1/2 @ (I - Q Q^T) @ M^1/2 = I - M^-1/2 @ Q @ Q^T @ M^1/2
680
+ Q = self.Q # Q(n_batch, n_atoms * n_dim, n_constr)
681
+ Px = (sqrtM * V).reshape(n_batch, n_atoms * n_dim, 1).contiguous() # (n_batch, n_atoms * n_dim, 1)
682
+ Px = (Q @ (Q.mT.contiguous() @ Px)).reshape(n_batch, n_atoms, n_dim) # (n_batch, n_atoms, n_dim)
683
+ Px.mul_(negsqrtM)
684
+ Px = th.sub(V, Px, out=out)
685
+ # manifold veloc. correction
686
+ if not self.is_const_constr: # time-dependent constraints
687
+ th.linalg.solve_triangular(self.R.mT.contiguous(), self.d_constr.unsqueeze(-1), upper=False, out=self.q)
688
+ Px.add_(self.negsqrtM * (self.Q @ self.q).reshape(n_batch, n_atoms, n_dim))
689
+
690
+ return Px
691
+
692
+ def _project_norm(self, V:th.Tensor, X:th.Tensor | None = None) -> th.Tensor:
693
+ """
694
+ Project V to normal space.
695
+ for Jacobian matrix J of constraints s_k at X, let J^T M^-1/2 = QR, thus s_k(X + v * dt) = s_k(X) + R^T Q^T M^1/2 v * dt + O(dt^2)
696
+ define the projector: P = M^-1/2 Q_2 Q_2^T M^1/2 = M^-1/2 (I - Q_1 Q_1^T) M^1/2, where Q = [Q_1, Q_2], Q_1 is from the financial QRF.
697
+ Args:
698
+ V: the input tensor of shape (n_batch, n_atom, n_dim). It might be coordinates, velocity, and higher deviations.
699
+
700
+ Returns: th.Tensor, the projected X.
701
+
702
+ """
703
+ n_batch, n_atoms, n_dim = V.shape
704
+ sqrtM = self.sqrtM # M^1/2, (n_batch, n_atoms, n_dim)
705
+ negsqrtM = self.negsqrtM # M^-1/2
706
+ # The normal-space basis depends on the coordinate Jacobian, not on the
707
+ # target value. Do not alter the caller-selected constraint time here.
708
+ if X is not None:
709
+ jac, y = self._jacobian(X)
710
+ self._do_qr(jac)
711
+
712
+ # P = M^-1/2 @ (I - Q Q^T) @ M^1/2 = I - M^-1/2 @ Q @ Q^T @ M^1/2
713
+ Q = self.Q # Q(n_batch, n_atoms * n_dim, n_constr)
714
+ Px = (sqrtM * V).reshape(n_batch, n_atoms * n_dim, 1).contiguous() # (n_batch, n_atoms * n_dim, 1)
715
+ Px = (Q @ (Q.mT.contiguous() @ Px)).reshape(n_batch, n_atoms, n_dim) # (n_batch, n_atoms, n_dim)
716
+ Px.mul_(negsqrtM)
717
+
718
+ return Px
719
+
720
+ @staticmethod
721
+ def _retraction_newton(
722
+ X: th.Tensor,
723
+ X_work: th.Tensor,
724
+ sqrtM_Q: th.Tensor,
725
+ R: th.Tensor,
726
+ aux_multiplier: th.Tensor,
727
+ jacobian_func: Callable[[th.Tensor], Tuple[th.Tensor, th.Tensor]],
728
+ initial_constr_err: th.Tensor,
729
+ constr_threshold: float,
730
+ stagnation_threshold: float,
731
+ max_proj_iter: int,
732
+ time_step: float,
733
+ verbose: int,
734
+ logger: Any,
735
+ ) -> Tuple[th.Tensor, th.Tensor, th.Tensor]:
736
+ """Solve the retraction in the fixed initial normal space.
737
+
738
+ Args:
739
+ X: Coordinates before retraction. Updated in-place with the final
740
+ working coordinates when the solver exits.
741
+ X_work: Coordinate work buffer with the same shape as ``X``.
742
+ sqrtM_Q: Fixed mass-weighted normal basis.
743
+ R: Fixed upper-triangular QR factor for force recovery.
744
+ aux_multiplier: Accumulated multiplier updated in-place.
745
+ jacobian_func: Callable returning the current Jacobian and
746
+ residual.
747
+ initial_constr_err: Initial maximum constraint residual.
748
+ constr_threshold: Absolute constraint convergence threshold.
749
+ stagnation_threshold: Threshold for the existing early-stop
750
+ criterion.
751
+ max_proj_iter: Maximum number of solver iterations.
752
+ time_step: Integration time step used for force conversion.
753
+ verbose: Constraint diagnostic verbosity.
754
+ logger: Logger used for diagnostics.
755
+
756
+ Returns:
757
+ ``Fc``, the constraint force recovered once from the final
758
+ multiplier; ``dX``, the final coordinate displacement; and a
759
+ scalar Boolean tensor that is true only when the constraint
760
+ residual reached ``constr_threshold``.
761
+ """
762
+ n_batch, n_atoms, n_dim = X.shape
763
+ dX = th.zeros_like(X)
764
+ X_work.copy_(X)
765
+ constr_err_old = initial_constr_err
766
+
767
+ def _final_calc_Fc():
768
+ """
769
+ Re-solve the constraints force by triangular solve.
770
+ """
771
+ fc: th.Tensor = th.linalg.solve_triangular(
772
+ R,
773
+ aux_multiplier.unsqueeze(-1),
774
+ upper=True,
775
+ ).squeeze(-1)
776
+ fc *= 207.28617 / (time_step ** 2)
777
+ X.copy_(X_work)
778
+ return fc
779
+
780
+ for i in range(max_proj_iter):
781
+ jac, y = jacobian_func(X_work)
782
+ constr_err = th.max(th.abs(y))
783
+ if verbose > 1:
784
+ logger.info(f'{i: <3d} Constraint errors are now: {constr_err:.4e}')
785
+
786
+ is_converged = constr_err <= constr_threshold
787
+ is_fixed = th.abs(constr_err - constr_err_old) < stagnation_threshold
788
+ if is_converged or is_fixed:
789
+ if is_fixed and not is_converged:
790
+ _report_diagnostic(
791
+ 'Constraint errors are not converged yet, but it has '
792
+ 'been fixed. This loop will be skipped.',
793
+ verbose,
794
+ logger,
795
+ )
796
+ Fc = _final_calc_Fc()
797
+ return Fc, dX, is_converged
798
+ constr_err_old = constr_err
799
+
800
+ aux = th.bmm(jac.flatten(-2, -1), sqrtM_Q)
801
+ d_multiplier, _ = th.linalg.solve_ex(aux, -y)
802
+ aux_multiplier.add_(d_multiplier)
803
+ dX = th.einsum(
804
+ 'bnc, bc -> bn',
805
+ sqrtM_Q, aux_multiplier
806
+ ).reshape(n_batch, n_atoms, n_dim)
807
+ th.add(X, dX, out=X_work)
808
+
809
+ _report_diagnostic(
810
+ "Projection of X to the manifold is not converged.", verbose, logger,
811
+ )
812
+ is_converged = th.zeros((), device=X.device, dtype=th.bool)
813
+ Fc = _final_calc_Fc()
814
+ return Fc, dX, is_converged
815
+
816
+ @staticmethod
817
+ def _retraction_damp_newton(
818
+ X: th.Tensor,
819
+ X_work: th.Tensor,
820
+ sqrtM_Q: th.Tensor,
821
+ R: th.Tensor,
822
+ aux_multiplier: th.Tensor,
823
+ jacobian_func: Callable[[th.Tensor], Tuple[th.Tensor, th.Tensor]],
824
+ initial_constr_err: th.Tensor,
825
+ constr_threshold: float,
826
+ stagnation_threshold: float,
827
+ max_proj_iter: int,
828
+ time_step: float,
829
+ verbose: int,
830
+ logger: Any,
831
+ max_backtrack: int = 8,
832
+ step_shrink: float = 0.5,
833
+ ) -> Tuple[th.Tensor, th.Tensor, th.Tensor]:
834
+ """Solve the retraction with a safeguarded Newton step.
835
+
836
+ The Newton direction is always formed in the fixed initial normal
837
+ space. A trial multiplier is accepted only when it lowers the maximum
838
+ absolute constraint residual; otherwise the same direction is reduced
839
+ geometrically. The accumulated multiplier is changed only after an
840
+ accepted trial, so the force and velocity correction retain their
841
+ existing physical meaning.
842
+
843
+ Args:
844
+ X: Coordinates before retraction. Updated in-place on exit.
845
+ X_work: Coordinate work buffer with the same shape as ``X``.
846
+ sqrtM_Q: Fixed mass-weighted normal basis.
847
+ R: Fixed upper-triangular QR factor for force recovery.
848
+ aux_multiplier: Accumulated multiplier updated in-place.
849
+ jacobian_func: Callable returning the current Jacobian and residual.
850
+ initial_constr_err: Initial maximum constraint residual.
851
+ constr_threshold: Absolute constraint convergence threshold.
852
+ stagnation_threshold: Threshold for the existing early-stop test.
853
+ max_proj_iter: Maximum number of solver iterations.
854
+ time_step: Integration time step used for force conversion.
855
+ verbose: Constraint diagnostic verbosity.
856
+ logger: Logger used for diagnostics.
857
+ max_backtrack: Maximum number of step reductions per iteration.
858
+ step_shrink: Multiplicative reduction applied to a rejected step.
859
+
860
+ Returns:
861
+ ``Fc``, ``dX``, and a scalar Boolean convergence status.
862
+ """
863
+ n_batch, n_atoms, n_dim = X.shape
864
+ dX = th.zeros_like(X)
865
+ X_work.copy_(X)
866
+ constr_err_old = initial_constr_err
867
+ is_converged = initial_constr_err <= constr_threshold
868
+
869
+ def _final_calc_Fc():
870
+ """
871
+ Re-solve the constraints force by triangular solve.
872
+ """
873
+ fc: th.Tensor = th.linalg.solve_triangular(
874
+ R,
875
+ aux_multiplier.unsqueeze(-1),
876
+ upper=True,
877
+ ).squeeze(-1)
878
+ fc *= 207.28617 / (time_step ** 2)
879
+ X.copy_(X_work)
880
+ return fc
881
+
882
+ for i in range(max_proj_iter):
883
+ jac, y = jacobian_func(X_work)
884
+ constr_err = th.max(th.abs(y))
885
+ if verbose > 1:
886
+ logger.info(f'{i: <3d} Constraint errors are now: {constr_err:.4e}')
887
+
888
+ aux = th.bmm(jac.flatten(-2, -1), sqrtM_Q)
889
+ aux_q, aux_r = th.linalg.qr(aux.mT)
890
+ aux_diag = th.abs(th.diagonal(aux_r, dim1=-2, dim2=-1))
891
+ aux_scale = th.max(aux_diag)
892
+ is_rank_deficient = aux_scale <= 0
893
+ if not is_rank_deficient:
894
+ is_rank_deficient = th.min(aux_diag) <= 1e-6 * aux_scale
895
+
896
+ is_converged = constr_err <= constr_threshold
897
+ is_fixed = th.abs(constr_err - constr_err_old) < stagnation_threshold
898
+ if is_fixed or is_converged:
899
+ if is_fixed and (not is_converged):
900
+ if is_rank_deficient:
901
+ _report_diagnostic(
902
+ 'Constraint errors are not converged yet, but the '
903
+ 'current normal-space Jacobian is degenerate. The '
904
+ 'remaining iterations will be skipped.',
905
+ verbose,
906
+ logger,
907
+ )
908
+ else:
909
+ _report_diagnostic(
910
+ 'Constraint errors are not converged yet, but the '
911
+ 'residual improvement is below the stagnation '
912
+ 'threshold. The remaining iterations will be skipped.',
913
+ verbose,
914
+ logger,
915
+ )
916
+ Fc = _final_calc_Fc()
917
+ return Fc, dX, is_converged
918
+ constr_err_old = constr_err
919
+
920
+ d_multiplier = th.linalg.solve_triangular(
921
+ aux_r.mT.contiguous(),
922
+ (-y).unsqueeze(-1),
923
+ upper=False,
924
+ )
925
+ d_multiplier = th.bmm(aux_q, d_multiplier).squeeze(-1)
926
+ accepted = False
927
+ step = 1.
928
+ candidate_multiplier = th.empty_like(aux_multiplier)
929
+ candidate_dX = th.empty_like(dX)
930
+ for _ in range(max_backtrack + 1):
931
+ candidate_multiplier.copy_(aux_multiplier).add_(d_multiplier, alpha=step)
932
+ candidate_dX = th.einsum(
933
+ 'bnc, bc -> bn',
934
+ sqrtM_Q, candidate_multiplier
935
+ ).reshape(n_batch, n_atoms, n_dim)
936
+ th.add(X, candidate_dX, out=X_work)
937
+ _, y_trial = jacobian_func(X_work)
938
+ trial_err = th.max(th.abs(y_trial))
939
+ if trial_err < constr_err:
940
+ accepted = True
941
+ break
942
+ th.add(X, dX, out=X_work)
943
+ step *= step_shrink
944
+
945
+ if not accepted:
946
+ _report_diagnostic(
947
+ 'Damped Newton retraction step did not lower the constraint error.',
948
+ verbose,
949
+ logger,
950
+ )
951
+ Fc = _final_calc_Fc()
952
+ return Fc, dX, is_converged
953
+
954
+ aux_multiplier.copy_(candidate_multiplier)
955
+ dX.copy_(candidate_dX)
956
+
957
+ _report_diagnostic(
958
+ "Projection of X to the manifold is not converged.", verbose, logger,
959
+ )
960
+ Fc = _final_calc_Fc()
961
+ return Fc, dX, is_converged
962
+
963
+ def _project2(
964
+ self,
965
+ X: th.Tensor,
966
+ X_orig: th.Tensor | None = None,
967
+ V: th.Tensor | None = None,
968
+ constr_time: th.Tensor | float | None = None,
969
+ ) -> Tuple[th.Tensor, th.Tensor|None, th.Tensor|None]:
970
+ """
971
+ Continuously project the Jacobian of all constrains to the exact manifold.
972
+ ** Update X and V (if given) in-place.
973
+ If V is provided, it is updated in-place with the constraint force contribution
974
+ to velocity, using the converged multiplier mu to avoid subtraction cancellation.
975
+
976
+ Args:
977
+ X: (n_batch, n_atom, n_dim) coordinates to be projected. Modified in-place.
978
+ X_orig: reference coordinates for fixed Jacobian basis. Defaults to X.
979
+ V: (n_batch, n_atom, n_dim) velocities. If given, updated in-place by
980
+ dv = M^{-1/2} Q mu / time_step, where mu is the converged multiplier.
981
+ constr_time: Time at which the position constraint is enforced. If
982
+ ``None``, use ``self.time_now + self.time_step``, corresponding
983
+ to the end of the current outer MD step. Explicit values allow
984
+ integrators with other splitting schemes to select a half-step
985
+ or another stage without modifying the parent MD clock.
986
+
987
+ Returns:
988
+ Fc: constraint forces (eV/atom)
989
+ G: geometric Fixman force term, or ``None`` when
990
+ ``require_fixman=False``.
991
+ w: Fixman/Blue-Moon reweighting factor
992
+ ``1 / sqrt(det(J M^-1 J^T))`` for each batch sample, or
993
+ ``None`` when ``require_fixman=False``.
994
+ """
995
+ n_batch, n_atoms, n_dim = X.shape
996
+ self._proj2_call_number += 1
997
+ n_constr = self.R.shape[-1]
998
+ aux_multipler = th.zeros(n_batch, n_constr, device=self.device, dtype=FLOAT_TYPE)
999
+ if (X_orig is None) or (X_orig is X):
1000
+ # A detached reference is required: the retraction rewrites ``X``
1001
+ # in place at the end, which is illegal on a leaf that requires
1002
+ # grad for the Fixman derivative.
1003
+ X_orig = X.detach()
1004
+ time_step = self.time_step
1005
+
1006
+ # The target value and its time derivative are constant throughout one
1007
+ # nonlinear position projection. Refresh them once at the end-of-step
1008
+ # stage by default, before the fixed-basis Jacobian and all subsequent
1009
+ # Newton subiterations. The following velocity projection reuses this
1010
+ # same staged state.
1011
+ if constr_time is None:
1012
+ constr_time = self.time_now + time_step
1013
+ self._update_constr(constr_time)
1014
+
1015
+ with th.set_grad_enabled(self.require_fixman):
1016
+ X_orig.requires_grad_(self.require_fixman)
1017
+ jac, y = self._jacobian(X_orig)
1018
+ check_freq = self._detect_dynamic_thres
1019
+ if isinstance(check_freq, int):
1020
+ if self._proj2_call_number % check_freq == 0:
1021
+ self._apply_thres_estimate(self._detect_thres_inf(X_orig, jac), X_orig)
1022
+
1023
+ constr_thres = self.constr_thres
1024
+ self._do_qr(jac)
1025
+ Q, R = self.Q, self.R
1026
+ # Safeguard against degenerate constraint directions
1027
+ # (e.g., erf soft-CN with vanishing gradients):
1028
+ # mask y for convergence — degenerate dirs ignored
1029
+ # clamp R diagonals — prevents solve_triangular blow-up
1030
+ # Stop only when the residual improvement is small compared with
1031
+ # the requested tolerance. Five percent preserves
1032
+ # the cheap stagnation escape without cutting off useful Newton
1033
+ # progress too early.
1034
+ degenerate_eps = self.retraction_stagnation_factor * constr_thres
1035
+ #degenerate_mask = th.abs(R.diagonal(dim1=-2, dim2=-1)) < degenerate_eps # (n_batch, n_constr)
1036
+ #R.diagonal(dim1=-2, dim2=-1).clamp_min_(degenerate_eps)
1037
+ sqrtM_Q = self.negsqrtM.reshape(n_batch, -1, 1) * self.Q # (b, an, c)
1038
+ if self.require_fixman:
1039
+ # (n_batch, n_constr, n_constr) -> (n_batch, n_constr)
1040
+ abs_diag_R = th.diagonal(R, dim1=-2, dim2=-1).abs()
1041
+ log_sqrt_det_Z = abs_diag_R.log_().sum(dim=-1) # (n_batch), "sqrt" is due to Z=R^T R, R is triangular
1042
+ half_logdet_Z = log_sqrt_det_Z.sum() # scalar
1043
+ # A linear constraint has a coordinate-independent Jacobian,
1044
+ # hence a constant Fixman determinant and an exactly zero
1045
+ # derivative. In that case ``half_logdet_Z`` contains no
1046
+ # autograd graph and calling ``autograd.grad`` would raise even
1047
+ # though the required result is well-defined. ``allow_unused``
1048
+ # also covers CV implementations whose Jacobian graph is
1049
+ # present but independent of some/all coordinate inputs.
1050
+ if half_logdet_Z.requires_grad:
1051
+ half_dlnz_dx = th.autograd.grad(
1052
+ half_logdet_Z,
1053
+ X_orig,
1054
+ allow_unused=True,
1055
+ )[0]
1056
+ if half_dlnz_dx is None:
1057
+ half_dlnz_dx = th.zeros_like(X_orig)
1058
+ else:
1059
+ half_dlnz_dx = th.zeros_like(X_orig)
1060
+ half_dlnz_dx = half_dlnz_dx.reshape(n_batch, -1)
1061
+ RG = th.einsum('bnc, bn -> bc', sqrtM_Q, half_dlnz_dx)
1062
+ G = th.linalg.solve_triangular(R, RG.unsqueeze(-1), upper=True).squeeze(-1)
1063
+ w = log_sqrt_det_Z.neg_().exp_()
1064
+ else:
1065
+ G = None
1066
+ w = None
1067
+ R.detach_()
1068
+ sqrtM_Q.detach_()
1069
+
1070
+ Fc, dX, _is_converged = self._retraction_map_solver(
1071
+ X=X,
1072
+ X_work=self.X_cache,
1073
+ sqrtM_Q=sqrtM_Q,
1074
+ R=R,
1075
+ aux_multiplier=aux_multipler,
1076
+ jacobian_func=self._jacobian,
1077
+ initial_constr_err=th.max(th.abs(y)),
1078
+ constr_threshold=constr_thres,
1079
+ stagnation_threshold=degenerate_eps,
1080
+ max_proj_iter=self.max_proj_iter,
1081
+ time_step=time_step,
1082
+ verbose=self.verbose,
1083
+ logger=self.logger,
1084
+ )
1085
+
1086
+ if V is not None:
1087
+ V.add_(dX.div_(time_step))
1088
+
1089
+ return Fc, G, w
1090
+
1091
+ def _jacobian_derivative(self, X, v: th.Tensor) -> th.Tensor:
1092
+ """
1093
+ Compute `dJ/dt = v^T H` by auto-differentiation.
1094
+ Args:
1095
+ X:
1096
+ v:
1097
+
1098
+ Returns: v^T H with shape (n_batch, n_constr, n_atom, n_dim)
1099
+
1100
+ """
1101
+ vH: th.Tensor = bhvp(self._constr_func_for_hvp, (X,), (v,), False) # (n_batch, n_constr, n_atom, n_dim)
1102
+ return vH.contiguous()
1103
+
1104
+ def christoffel_solver(
1105
+ self,
1106
+ X: th.Tensor,
1107
+ V: th.Tensor,
1108
+ F_tan: th.Tensor | None = None,
1109
+ max_iter: int = 20,
1110
+ tol: float = 1e-6,
1111
+ omega: float = 0.7
1112
+ ):
1113
+ r"""
1114
+ Implicit midpoint integration of the constrained geodesic equation:
1115
+
1116
+ dV/dt = -Gamma(V, V) + M^{-1} F_tan
1117
+
1118
+ where Gamma = M^{-1/2} Q R^{-T} (V^T H) V is the Christoffel symbol
1119
+ capturing the curvature of the constraint manifold, and F_tan is the
1120
+ tangent-space projection of the physical forces.
1121
+
1122
+ Uses the Woodbury identity to reduce the Newton-step matrix inverse
1123
+ from O(N_free^3) to O(k^3), where k << N_free is the number of constraints.
1124
+
1125
+ Args:
1126
+ X: current positions (n_batch, n_atom, n_dim)
1127
+ V: current velocities (n_batch, n_atom, n_dim)
1128
+ F_tan: tangent-projected forces, or None for pure-geodesic flow
1129
+ max_iter: maximum Newton iterations
1130
+ tol: convergence tolerance on the residual norm
1131
+ omega: SOR relaxation factor (< 1 damps, =1 pure Newton)
1132
+
1133
+ Returns:
1134
+ X_new, V_new – updated in-place.
1135
+ """
1136
+ n_batch, n_atom, n_dim = X.shape
1137
+ n_free = n_atom * n_dim
1138
+ dt = self.time_step
1139
+
1140
+ # Precompute Woodbury blocks (independent of X_new, V_new)
1141
+ B = self.negsqrtM.reshape(n_batch, -1, 1) * self.Q # (B, n_free, k)
1142
+ B_T = B.mT.contiguous() # (B, k, n_free)
1143
+ eye_k = th.eye(self.R.shape[-1], device=X.device, dtype=X.dtype).expand(n_batch, -1, -1)
1144
+
1145
+ V_old = V.clone()
1146
+ X_old = X.clone()
1147
+ # Initial guess: explicit Euler half-step
1148
+ X_new = X.add(V_old, alpha=dt)
1149
+ V_new = V.clone()
1150
+ if F_tan is not None:
1151
+ # Include force: V_new = V_old + dt * acc, acc = M^{-1} F_tan (F_tan is already force)
1152
+ acc = F_tan * self.negsqrtM * self.negsqrtM # M^{-1} = negsqrtM^2
1153
+ V_new.add_(acc, alpha=dt)
1154
+
1155
+ for it in range(max_iter):
1156
+ X_mid = (X_new + X_old) * 0.5
1157
+ V_mid = (V_new + V_old) * 0.5
1158
+
1159
+ # Compute Christoffel symbol: Gamma = B @ R^{-T} @ (J' V_mid)
1160
+ dJ = self._jacobian_derivative(X_mid, V_mid) # (B, k, N, D)
1161
+ dJ_flat = dJ.flatten(-2, -1) # (B, k, n_free)
1162
+ Jp_V = th.einsum('bkn, bn -> bk', dJ_flat, V_mid.flatten(-2, -1)) # J'·V_mid (B, k)
1163
+ _sol = th.linalg.solve_triangular(
1164
+ self.R.mT.contiguous(), Jp_V.unsqueeze(-1), upper=False
1165
+ ) # R^{-T} J'·V (B, k, 1)
1166
+ Gamma = (B @ _sol).reshape(n_batch, n_atom, n_dim) # Christoffel (B, N, D)
1167
+
1168
+ # Residuals for the implicit midpoint system
1169
+ # R_X = X_new - X_old - dt * V_mid
1170
+ R_X = X_new - X_old - dt * V_mid
1171
+ # R_V = V_new - V_old - dt * (acc - Gamma)
1172
+ if F_tan is not None:
1173
+ R_V = V_new - V_old - dt * (acc - Gamma)
1174
+ else:
1175
+ R_V = V_new - V_old + dt * Gamma
1176
+
1177
+ # Convergence check
1178
+ norm_RX = th.linalg.norm(R_X.reshape(n_batch, -1), dim=-1)
1179
+ norm_RV = th.linalg.norm(R_V.reshape(n_batch, -1), dim=-1)
1180
+ if th.all((norm_RX < tol) & (norm_RV < tol)):
1181
+ V.copy_(V_new)
1182
+ X.copy_(X_new)
1183
+ return X_new, V_new
1184
+
1185
+ # --- Newton update with Woodbury identity ---
1186
+ # dJ_flat^T = dJ.mT, shape (B, n_free, k)
1187
+ dJ_T = dJ_flat.mT.contiguous() # (B, n_free, k)
1188
+ dJ_T_B = th.bmm(dJ_T, B) # (B, k, k)
1189
+ M_wood = eye_k + dt * dJ_T_B # I + dt * dJ^T B
1190
+ rv_flat = R_V.reshape(n_batch, -1, 1) # (B, n_free, 1)
1191
+
1192
+ dJ_T_rv = th.bmm(dJ_T, rv_flat) # dJ^T @ R_V (B, k, 1)
1193
+ sol_W = th.linalg.solve(M_wood, dJ_T_rv) # (I + dt dJ^T B)^{-1} dJ^T R_V
1194
+ P_RV = rv_flat - dt * th.bmm(B, sol_W) # P @ R_V (B, n_free, 1)
1195
+
1196
+ dV = P_RV.reshape(n_batch, n_atom, n_dim)
1197
+ dX = (R_X + 0.5 * dt * dV)
1198
+
1199
+ # SOR relaxation
1200
+ V_new = V_new - dV
1201
+ V_new = omega * V_new + (1.0 - omega) * V
1202
+ X_new = X_new - dX
1203
+
1204
+ # Did not converge – return best effort
1205
+ V.copy_(V_new)
1206
+ X.copy_(X_new)
1207
+ return X_new, V_new