torchnep 1.0.0__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.
torchnep/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ # Copyright 2025 Yongchao Wu and the GPUMD development team
2
+ # This file is part of GPUMD (Torchnep project).
3
+ # GPUMD is free software: you can redistribute it and/or modify
4
+ # it under the terms of the GNU General Public License as published by
5
+ # the Free Software Foundation, either version 3 of the License, or
6
+ # (at your option) any later version.
7
+ # GPUMD is distributed in the hope that it will be useful,
8
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
9
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10
+ # GNU General Public License for more details.
11
+ # You should have received a copy of the GNU General Public License
12
+ # along with GPUMD. If not, see <http://www.gnu.org/licenses/>.
13
+
14
+ __version__ = "1.0.0"
15
+
16
+ from .predict import predict_dataset
17
+ from .train import train_nep
18
+ from .train_sharded import train_nep_sharded
19
+
20
+ __all__ = [
21
+ "predict_dataset", "train_nep", "train_nep_sharded",
22
+ ]
@@ -0,0 +1,196 @@
1
+ # Copyright 2025 Yongchao Wu and the GPUMD development team
2
+ # This file is part of GPUMD (Torchnep project).
3
+ # GPUMD is free software: you can redistribute it and/or modify
4
+ # it under the terms of the GNU General Public License as published by
5
+ # the Free Software Foundation, either version 3 of the License, or
6
+ # (at your option) any later version.
7
+ # GPUMD is distributed in the hope that it will be useful,
8
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
9
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10
+ # GNU General Public License for more details.
11
+ # You should have received a copy of the GNU General Public License
12
+ # along with GPUMD. If not, see <http://www.gnu.org/licenses/>.
13
+
14
+ """ASE interface for torchnep NEP4 models.
15
+
16
+ This module provides :class:`NEP`, a standard ``ase.calculators.calculator.
17
+ Calculator`` so that any ASE workflow (relaxation, MD, EOS, phonons, ...) can
18
+ drive a trained NEP4 potential::
19
+
20
+ from ase.io import read
21
+ from torchnep.ase_calculator import NEP
22
+
23
+ atoms = read("POSCAR")
24
+ atoms.calc = NEP("nep.txt")
25
+ print(atoms.get_potential_energy())
26
+ print(atoms.get_forces())
27
+ print(atoms.get_stress()) # only for periodic cells
28
+
29
+ ``ase`` is an OPTIONAL dependency of torchnep — the core package (training and
30
+ prediction) never imports it. Importing *this* module requires ase to be
31
+ installed; if it is not, a clear ImportError is raised here rather than deep
32
+ inside ASE. This is also why ``NEP`` is intentionally not re-exported from
33
+ ``torchnep/__init__.py``.
34
+ """
35
+
36
+ import numpy as np
37
+
38
+ try:
39
+ from ase.calculators.calculator import Calculator, all_changes
40
+ from ase.stress import full_3x3_to_voigt_6_stress
41
+ except ModuleNotFoundError as exc: # pragma: no cover - exercised only sans ase
42
+ raise ModuleNotFoundError(
43
+ "torchnep.ase_calculator requires the optional dependency 'ase'. "
44
+ "Install it with `pip install ase`."
45
+ ) from exc
46
+
47
+ from .nep import NEPCalculator as _NEPCore
48
+
49
+
50
+ def _resolve_dtype(dtype):
51
+ import torch
52
+ if isinstance(dtype, torch.dtype):
53
+ return dtype
54
+ return {"float64": torch.float64, "float32": torch.float32,
55
+ "double": torch.float64, "single": torch.float32}[str(dtype)]
56
+
57
+
58
+ class NEP(Calculator):
59
+ """ASE calculator backed by a torchnep NEP4 model (``nep.txt``).
60
+
61
+ Parameters
62
+ ----------
63
+ model_file : str
64
+ Path to a GPUMD-format ``nep.txt`` (NEP4).
65
+ dtype : str or torch.dtype
66
+ Compute precision; ``"float64"`` (default) reproduces GPUMD to
67
+ round-off, ``"float32"`` is faster.
68
+ device : str or torch.device
69
+ Torch device, e.g. ``"cpu"`` (default) or ``"cuda"``.
70
+ **kwargs
71
+ Forwarded to ``ase.calculators.calculator.Calculator`` (e.g. ``label``).
72
+
73
+ Notes
74
+ -----
75
+ ``stress`` is only reported for fully periodic cells (a finite volume is
76
+ required); for molecules / clusters it is omitted. Non-periodic systems
77
+ are handled by embedding the atoms in a vacuum box large enough that no
78
+ atom sees a periodic image within the model cutoff.
79
+ """
80
+
81
+ implemented_properties = ["energy", "energies", "free_energy",
82
+ "forces", "stress"]
83
+
84
+ def __init__(self, model_file, dtype="float64", device="cpu",
85
+ tiled="auto", block_size="auto", compile=False, **kwargs):
86
+ super().__init__(**kwargs)
87
+ self.nep = _NEPCore(model_file, dtype=_resolve_dtype(dtype),
88
+ device=device)
89
+ # tiled: memory-bounded analytical inference for large cells.
90
+ # True -> always tile; False -> never (autograd path);
91
+ # "auto" -> tile once the system exceeds ``tiled_threshold`` atoms.
92
+ self.tiled = tiled
93
+ # block_size: "auto" sizes each tile from free memory; or an int override.
94
+ self.block_size = block_size
95
+ self.tiled_threshold = 50000
96
+ # compile: torch.compile the tiled kernels (CPU/CUDA only; one-time
97
+ # warm-up, then a dynamic graph that survives MD pair-count changes).
98
+ self.compile = bool(compile)
99
+
100
+ # -- helpers --------------------------------------------------------------
101
+ def _cell_for_neighbors(self, atoms):
102
+ """Return (cell_3x3, periodic) for the neighbor-list builder.
103
+
104
+ Fully periodic cells are passed through. Otherwise we wrap the atoms in
105
+ an orthorhombic vacuum box padded by ``2*rc`` so the builder's PBC
106
+ replicas never fall within the cutoff (emulating an isolated system).
107
+ """
108
+ if atoms.cell.rank == 3 and atoms.pbc.all():
109
+ return np.asarray(atoms.get_cell()[:], dtype=float), True
110
+ pos = atoms.get_positions()
111
+ rc = max(self.nep.rc_radial, self.nep.rc_angular)
112
+ span = pos.max(axis=0) - pos.min(axis=0) if len(pos) else np.zeros(3)
113
+ box = np.diag(span + 4.0 * rc + 1.0)
114
+ return box, False
115
+
116
+ # -- ASE entry point ------------------------------------------------------
117
+ def calculate(self, atoms=None, properties=("energy",),
118
+ system_changes=all_changes):
119
+ super().calculate(atoms, properties, system_changes)
120
+ atoms = self.atoms # set by super().calculate
121
+
122
+ cell, periodic = self._cell_for_neighbors(atoms)
123
+ species = atoms.get_chemical_symbols()
124
+ use_tiled = (self.tiled is True or
125
+ (self.tiled == "auto" and len(species) >= self.tiled_threshold))
126
+ if use_tiled:
127
+ res = self.nep.compute_tiled(
128
+ species=species, positions=atoms.get_positions(),
129
+ cell=cell, block_size=self.block_size, compile=self.compile)
130
+ else:
131
+ res = self.nep.compute(
132
+ species=species, positions=atoms.get_positions(), cell=cell)
133
+ energies = res["energy"].detach().cpu().numpy()
134
+ forces = res["forces"].detach().cpu().numpy()
135
+
136
+ self.results["energies"] = energies
137
+ self.results["energy"] = float(energies.sum())
138
+ self.results["free_energy"] = self.results["energy"]
139
+ self.results["forces"] = forces
140
+
141
+ if periodic:
142
+ self.results["stress"] = self._stress_from_virial(
143
+ res["virial"], atoms.get_volume())
144
+
145
+ @staticmethod
146
+ def _stress_from_virial(virial, volume):
147
+ # Per-atom virial is (N, 9) row-major; W_ab = Σ rij_a * F_b.
148
+ # ASE stress σ = -W / V (Voigt order xx, yy, zz, yz, xz, xy).
149
+ w = virial.detach().cpu().numpy().sum(0).reshape(3, 3)
150
+ return full_3x3_to_voigt_6_stress(-w / volume)
151
+
152
+ def get_energy_components(self, atoms=None):
153
+ """Return the NEP / ZBL / total potential-energy split (eV).
154
+
155
+ Useful to inspect how much of the energy comes from the ZBL repulsive
156
+ baseline versus the neural-network NEP part::
157
+
158
+ {'nep': ..., 'zbl': ..., 'total': ...}
159
+
160
+ For a model trained without ZBL, ``'zbl'`` is 0 and ``'nep'`` equals
161
+ ``'total'``. See :meth:`get_components` for forces and stress too.
162
+ """
163
+ return {k: v["energy"] for k, v in self.get_components(atoms).items()}
164
+
165
+ def get_components(self, atoms=None):
166
+ """Full NEP / ZBL / total breakdown of energy, forces, and stress.
167
+
168
+ Returns ``{'nep': {...}, 'zbl': {...}, 'total': {...}}`` where each
169
+ inner dict has ``'energy'`` (float, eV), ``'forces'`` ((N, 3) eV/Å) and,
170
+ for periodic cells, ``'stress'`` (6-vector Voigt, eV/ų). The ``total``
171
+ block equals what ``get_potential_energy`` / ``get_forces`` /
172
+ ``get_stress`` return.
173
+ """
174
+ if atoms is None:
175
+ atoms = self.atoms
176
+ if atoms is None:
177
+ raise ValueError("No atoms supplied and none attached to the calculator.")
178
+
179
+ cell, periodic = self._cell_for_neighbors(atoms)
180
+ res = self.nep.compute(
181
+ species=atoms.get_chemical_symbols(),
182
+ positions=atoms.get_positions(),
183
+ cell=cell,
184
+ return_components=True,
185
+ )
186
+ vol = atoms.get_volume() if periodic else None
187
+ out = {}
188
+ for name, esuffix in (("nep", "_nep"), ("zbl", "_zbl"), ("total", "")):
189
+ e = res["energy" + esuffix].detach().cpu().numpy()
190
+ block = {"energy": float(e.sum()),
191
+ "forces": res["forces" + esuffix].detach().cpu().numpy()}
192
+ if periodic:
193
+ block["stress"] = self._stress_from_virial(
194
+ res["virial" + esuffix], vol)
195
+ out[name] = block
196
+ return out
torchnep/constants.py ADDED
@@ -0,0 +1,218 @@
1
+ # Copyright 2025 Yongchao Wu and the GPUMD development team
2
+ # This file is part of GPUMD (Torchnep project).
3
+ # GPUMD is free software: you can redistribute it and/or modify
4
+ # it under the terms of the GNU General Public License as published by
5
+ # the Free Software Foundation, either version 3 of the License, or
6
+ # (at your option) any later version.
7
+ # GPUMD is distributed in the hope that it will be useful,
8
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
9
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10
+ # GNU General Public License for more details.
11
+ # You should have received a copy of the GNU General Public License
12
+ # along with GPUMD. If not, see <http://www.gnu.org/licenses/>.
13
+
14
+ """Shared constants for NEP implementation."""
15
+
16
+ PI = 3.141592653589793
17
+
18
+ ELEMENTS = [
19
+ "H", "He", "Li", "Be", "B", "C", "N", "O", "F", "Ne", "Na", "Mg",
20
+ "Al", "Si", "P", "S", "Cl", "Ar", "K", "Ca", "Sc", "Ti", "V", "Cr",
21
+ "Mn", "Fe", "Co", "Ni", "Cu", "Zn", "Ga", "Ge", "As", "Se", "Br", "Kr",
22
+ "Rb", "Sr", "Y", "Zr", "Nb", "Mo", "Tc", "Ru", "Rh", "Pd", "Ag", "Cd",
23
+ "In", "Sn", "Sb", "Te", "I", "Xe", "Cs", "Ba", "La", "Ce", "Pr", "Nd",
24
+ "Pm", "Sm", "Eu", "Gd", "Tb", "Dy", "Ho", "Er", "Tm", "Yb", "Lu", "Hf",
25
+ "Ta", "W", "Re", "Os", "Ir", "Pt", "Au", "Hg", "Tl", "Pb", "Bi", "Po",
26
+ "At", "Rn", "Fr", "Ra", "Ac", "Th", "Pa", "U", "Np", "Pu",
27
+ # Transuranics + transactinides (Z = 95–118).
28
+ "Am", "Cm", "Bk", "Cf", "Es", "Fm", "Md", "No", "Lr",
29
+ "Rf", "Db", "Sg", "Bh", "Hs", "Mt", "Ds", "Rg", "Cn",
30
+ "Nh", "Fl", "Mc", "Lv", "Ts", "Og",
31
+ ]
32
+
33
+ # Maximum 3-body angular order supported by the implementation.
34
+ MAX_L3B = 8
35
+
36
+ # C3B coefficients for 3-body angular descriptors (from GPUMD).
37
+ # Indexed [L*L - 1 + k] for L in 1..8, k in 0..2L; total = 3+5+7+9+11+13+15+17 = 80.
38
+ C3B = [
39
+ 0.238732414637843, 0.119366207318922, 0.119366207318922, 0.099471839432435,
40
+ 0.596831036594608, 0.596831036594608, 0.149207759148652, 0.149207759148652,
41
+ 0.139260575205408, 0.104445431404056, 0.104445431404056, 1.044454314040563,
42
+ 1.044454314040563, 0.174075719006761, 0.174075719006761, 0.011190581936149,
43
+ 0.223811638722978, 0.223811638722978, 0.111905819361489, 0.111905819361489,
44
+ 1.566681471060845, 1.566681471060845, 0.195835183882606, 0.195835183882606,
45
+ 0.013677377921960, 0.102580334414698, 0.102580334414698, 2.872249363611549,
46
+ 2.872249363611549, 0.119677056817148, 0.119677056817148, 2.154187022708661,
47
+ 2.154187022708661, 0.215418702270866, 0.215418702270866, 0.004041043476943,
48
+ 0.169723826031592, 0.169723826031592, 0.106077391269745, 0.106077391269745,
49
+ 0.424309565078979, 0.424309565078979, 0.127292869523694, 0.127292869523694,
50
+ 2.800443129521260, 2.800443129521260, 0.233370260793438, 0.233370260793438,
51
+ 0.004662742473395, 0.004079899664221, 0.004079899664221, 0.024479397985326,
52
+ 0.024479397985326, 0.012239698992663, 0.012239698992663, 0.538546755677165,
53
+ 0.538546755677165, 0.134636688919291, 0.134636688919291, 3.500553911901575,
54
+ 3.500553911901575, 0.250039565135827, 0.250039565135827, 0.000082569397966,
55
+ 0.005944996653579, 0.005944996653579, 0.104037441437634, 0.104037441437634,
56
+ 0.762941237209318, 0.762941237209318, 0.114441185581398, 0.114441185581398,
57
+ 5.950941650232678, 5.950941650232678, 0.141689086910302, 0.141689086910302,
58
+ 4.250672607309055, 4.250672607309055, 0.265667037956816, 0.265667037956816,
59
+ ]
60
+ assert len(C3B) == sum(2 * L + 1 for L in range(1, MAX_L3B + 1)) # 80
61
+
62
+ # Z_COEFFICIENT[L][n1][n2]: polynomial coefficients such that the (L, n1)
63
+ # angular basis element equals z_factor(z) * (x+iy)^n1, where
64
+ # z_factor(z) = sum_{n2} Z_COEFFICIENT[L][n1][n2] * z^n2
65
+ # (only n2 with (L+n1) parity match the sum; others are stored as 0).
66
+ # Matches NEP_CPU's Z_COEFFICIENT_1..8 tables; rows/cols padded to (L+1) * (L+1).
67
+ Z_COEFFICIENT = [
68
+ None, # L=0 unused
69
+ [[0.0, 1.0], [1.0, 0.0]],
70
+ [[-1.0, 0.0, 3.0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0]],
71
+ [[0.0, -3.0, 0.0, 5.0], [-1.0, 0.0, 5.0, 0.0],
72
+ [0.0, 1.0, 0.0, 0.0], [1.0, 0.0, 0.0, 0.0]],
73
+ [[3.0, 0.0, -30.0, 0.0, 35.0], [0.0, -3.0, 0.0, 7.0, 0.0],
74
+ [-1.0, 0.0, 7.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0, 0.0],
75
+ [1.0, 0.0, 0.0, 0.0, 0.0]],
76
+ [[0.0, 15.0, 0.0, -70.0, 0.0, 63.0], [1.0, 0.0, -14.0, 0.0, 21.0, 0.0],
77
+ [0.0, -1.0, 0.0, 3.0, 0.0, 0.0], [-1.0, 0.0, 9.0, 0.0, 0.0, 0.0],
78
+ [0.0, 1.0, 0.0, 0.0, 0.0, 0.0], [1.0, 0.0, 0.0, 0.0, 0.0, 0.0]],
79
+ [[-5.0, 0.0, 105.0, 0.0, -315.0, 0.0, 231.0],
80
+ [0.0, 5.0, 0.0, -30.0, 0.0, 33.0, 0.0],
81
+ [1.0, 0.0, -18.0, 0.0, 33.0, 0.0, 0.0],
82
+ [0.0, -3.0, 0.0, 11.0, 0.0, 0.0, 0.0],
83
+ [-1.0, 0.0, 11.0, 0.0, 0.0, 0.0, 0.0],
84
+ [0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0],
85
+ [1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]],
86
+ [[0.0, -35.0, 0.0, 315.0, 0.0, -693.0, 0.0, 429.0],
87
+ [-5.0, 0.0, 135.0, 0.0, -495.0, 0.0, 429.0, 0.0],
88
+ [0.0, 15.0, 0.0, -110.0, 0.0, 143.0, 0.0, 0.0],
89
+ [3.0, 0.0, -66.0, 0.0, 143.0, 0.0, 0.0, 0.0],
90
+ [0.0, -3.0, 0.0, 13.0, 0.0, 0.0, 0.0, 0.0],
91
+ [-1.0, 0.0, 13.0, 0.0, 0.0, 0.0, 0.0, 0.0],
92
+ [0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
93
+ [1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]],
94
+ [[35.0, 0.0, -1260.0, 0.0, 6930.0, 0.0, -12012.0, 0.0, 6435.0],
95
+ [0.0, -35.0, 0.0, 385.0, 0.0, -1001.0, 0.0, 715.0, 0.0],
96
+ [-1.0, 0.0, 33.0, 0.0, -143.0, 0.0, 143.0, 0.0, 0.0],
97
+ [0.0, 3.0, 0.0, -26.0, 0.0, 39.0, 0.0, 0.0, 0.0],
98
+ [1.0, 0.0, -26.0, 0.0, 65.0, 0.0, 0.0, 0.0, 0.0],
99
+ [0.0, -1.0, 0.0, 5.0, 0.0, 0.0, 0.0, 0.0, 0.0],
100
+ [-1.0, 0.0, 15.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
101
+ [0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
102
+ [1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]],
103
+ ]
104
+
105
+ C4B = [-0.007499480826664, -0.134990654879954, 0.067495327439977,
106
+ 0.404971964639861, -0.809943929279723]
107
+
108
+ C5B = [0.026596810706114, 0.053193621412227, 0.026596810706114]
109
+
110
+ # Mixed-L invariant (GPUMD NEP feature `has_q_112`).
111
+ C4B2 = [0.027493550848847,
112
+ 0.164961305093080,
113
+ -0.013746775424423,
114
+ 0.041240326273270,
115
+ 0.082480652546540]
116
+
117
+ # Higher-L 4-body bispectrum invariants (GPUMD: has_q_123 / has_q_233 / has_q_134).
118
+ C4B_123 = [-0.008418146349617, -0.016836292699234, -0.033672585398469,
119
+ -0.042090731748086, -0.067345170796937, -0.084181463496172,
120
+ -0.168362926992344]
121
+
122
+ C4B_233 = [0.008572620635186, 0.009644198214584, 0.019288396429168,
123
+ 0.025717861905558, 0.026789439484956, 0.032147327381947,
124
+ 0.038576792858337, 0.128589309527790, 0.192883964291685,
125
+ 0.321473273819474]
126
+
127
+ # q_134 = B(1,3,4): couples L=1, L=3, L=4 moments (GPUMD: has_q_134).
128
+ # Uses L=4 s-moments (lm indices 15..23), so it requires l_max_3b >= 4.
129
+ C4B_134 = [0.003645164295772, 0.004860219061029, 0.006075273826286,
130
+ 0.018225821478859, 0.024301095305146, 0.036451642957719,
131
+ 0.042526916784005, 0.072903285915437, 0.085053833568010,
132
+ 0.255161500704030]
133
+
134
+ # Each group is (C4B_*[k] index, [(sign, (lm_idx, lm_idx, lm_idx)), ...]),
135
+ # mirroring GPUMD's find_q line by line for easy auditing. Expanded below to
136
+ # the flat (coeff, idx) form consumed by ops._eval_extra / _extra_grad.
137
+ _Q123_GROUPS = [
138
+ (6, [(+1, (12, 2, 4)), (-1, (11, 2, 5)), (+1, (1, 11, 4)), (+1, (1, 12, 5))]),
139
+ (5, [(+1, (0, 11, 6)), (+1, (0, 12, 7))]),
140
+ (3, [(+1, (14, 2, 6)), (-1, (13, 2, 7)), (+1, (1, 13, 6)), (+1, (1, 14, 7))]),
141
+ (4, [(+1, (10, 0, 5)), (+1, (0, 4, 9))]),
142
+ (1, [(+1, (10, 2, 3)), (+1, (0, 3, 8)), (+1, (1, 3, 9))]),
143
+ (0, [(+1, (10, 2, 6)), (-1, (10, 1, 7)), (-1, (2, 7, 9)), (-1, (1, 6, 9))]),
144
+ (2, [(-1, (2, 5, 8)), (-1, (1, 4, 8))]),
145
+ ]
146
+ _Q233_GROUPS = [
147
+ (0, [(+1, (3, 8, 8))]),
148
+ (1, [(+1, (10, 10, 3)), (+1, (3, 9, 9))]),
149
+ (2, [(-1, (10, 10, 6)), (+1, (6, 9, 9))]),
150
+ (3, [(+1, (4, 8, 9)), (+1, (10, 5, 8))]),
151
+ (4, [(-1, (13, 13, 3)), (-1, (14, 14, 3))]),
152
+ (5, [(-1, (14, 7, 9)), (-1, (13, 6, 9)), (-1, (10, 14, 6)), (+1, (10, 13, 7))]),
153
+ (6, [(+1, (10, 7, 9))]),
154
+ (7, [(-1, (11, 6, 8)), (-1, (12, 7, 8))]),
155
+ (8, [(+1, (11, 4, 9)), (+1, (12, 5, 9)), (+1, (10, 12, 4)), (-1, (10, 11, 5))]),
156
+ (9, [(+1, (12, 14, 4)), (+1, (11, 14, 5)), (+1, (13, 11, 4)), (-1, (13, 12, 5))]),
157
+ ]
158
+ _Q134_GROUPS = [
159
+ (0, [(-1, (10, 15, 2)), (-1, (1, 15, 9))]),
160
+ (1, [(+1, (0, 15, 8))]),
161
+ (2, [(-1, (1, 13, 18)), (-1, (1, 14, 19)), (-1, (2, 14, 18)), (+1, (2, 13, 19))]),
162
+ (3, [(-1, (10, 18, 2)), (+1, (1, 10, 19)), (+1, (1, 18, 9)), (+1, (2, 19, 9))]),
163
+ (4, [(+1, (1, 16, 8)), (+1, (2, 17, 8))]),
164
+ (5, [(+1, (0, 10, 17)), (+1, (0, 16, 9)), (-1, (1, 11, 16)), (-1, (1, 12, 17)),
165
+ (-1, (2, 12, 16)), (+1, (2, 11, 17))]),
166
+ (6, [(+1, (1, 13, 22)), (+1, (1, 14, 23)), (-1, (2, 14, 22)), (+1, (2, 13, 23))]),
167
+ (7, [(+1, (0, 11, 18)), (+1, (0, 12, 19))]),
168
+ (8, [(+1, (0, 13, 20)), (+1, (0, 14, 21))]),
169
+ (9, [(+1, (1, 11, 20)), (+1, (1, 12, 21)), (-1, (2, 12, 20)), (+1, (2, 11, 21))]),
170
+ ]
171
+ Q123_TERMS = [(sign * C4B_123[k], idx)
172
+ for k, grp in _Q123_GROUPS for sign, idx in grp]
173
+ Q233_TERMS = [(sign * C4B_233[k], idx)
174
+ for k, grp in _Q233_GROUPS for sign, idx in grp]
175
+ Q134_TERMS = [(sign * C4B_134[k], idx)
176
+ for k, grp in _Q134_GROUPS for sign, idx in grp]
177
+
178
+ K_C_SP = 14.399645 # Coulomb constant (eV*Angstrom)
179
+
180
+ # Unit conversion: 1 eV/A**3 = 160.21766208 GPa (CODATA 2018 e = 1.602176634e-19 C)
181
+ EV_PER_A3_TO_GPa = 160.21766208
182
+
183
+ ZBL_PARA = [0.18175, 3.1998, 0.50986, 0.94229, 0.28022, 0.4029, 0.02817, 0.20162]
184
+
185
+ # Covalent radii in Angstrom, indexed by Z-1.
186
+ #
187
+ # Z = 1–94: from GPUMD (src/utilities/nep_utilities.cuh::COVALENT_RADIUS),
188
+ # which stores Grimme's DFT-D3 covalent radii * 4/3 (k2 scaling).
189
+ # Reference: Grimme, Antony, Ehrlich, Krieg, J. Chem. Phys. 132,
190
+ # 154104 (2010), Table I (itself derived from Pyykkö & Atsumi
191
+ # 2009 with Li/Be/B/etc. empirical adjustments). GPUMD hard-codes
192
+ # these * 4/3 for ZBL typewise cutoffs; we reproduce them exactly
193
+ # for bit-compatibility.
194
+ # Z = 95–118: Pyykkö, "Additive Covalent Radii for Single-, Double-, and
195
+ # Triple-Bonded Molecules and Tetrahedrally Bonded Crystals: A
196
+ # Summary", J. Phys. Chem. A 119, 2326 (2015), single-bond
197
+ # radii. Grimme D3 only tabulates up to Z=94 (Pu), so this block
198
+ # fills the gap from a different reference — there is a minor
199
+ # scale discontinuity at the Z=94 -> 95 boundary.
200
+ COVALENT_RADIUS = [
201
+ 0.426667, 0.613333, 1.6, 1.25333, 1.02667, 1.0, 0.946667, 0.84,
202
+ 0.853333, 0.893333, 1.86667, 1.66667, 1.50667, 1.38667, 1.46667, 1.36,
203
+ 1.32, 1.28, 2.34667, 2.05333, 1.77333, 1.62667, 1.61333, 1.46667,
204
+ 1.42667, 1.38667, 1.33333, 1.32, 1.34667, 1.45333, 1.49333, 1.45333,
205
+ 1.53333, 1.46667, 1.52, 1.56, 2.52, 2.22667, 1.96, 1.85333,
206
+ 1.76, 1.65333, 1.53333, 1.50667, 1.50667, 1.44, 1.53333, 1.64,
207
+ 1.70667, 1.68, 1.68, 1.64, 1.76, 1.74667, 2.78667, 2.34667,
208
+ 2.16, 1.96, 2.10667, 2.09333, 2.08, 2.06667, 2.01333, 2.02667,
209
+ 2.01333, 2.0, 1.98667, 1.98667, 1.97333, 2.04, 1.94667, 1.82667,
210
+ 1.74667, 1.64, 1.57333, 1.54667, 1.48, 1.49333, 1.50667, 1.76,
211
+ 1.73333, 1.73333, 1.81333, 1.74667, 1.84, 1.89333, 2.68, 2.41333,
212
+ 2.22667, 2.10667, 2.02667, 2.04, 2.05333, 2.06667,
213
+ # Pyykkö 2015 single-bond values (see note above the table).
214
+ 1.80, 1.69, 1.68, 1.68, 1.65, 1.67, 1.73, 1.76, 1.61, # Am-Lr
215
+ 1.57, 1.49, 1.43, 1.41, 1.34, 1.29, 1.28, 1.21, 1.22, # Rf-Cn
216
+ 1.36, 1.43, 1.62, 1.75, 1.65, 1.57, # Nh-Og
217
+ ]
218
+ assert len(COVALENT_RADIUS) == 118