├── .gitignore
├── LICENSE
├── MANIFEST.in
├── README.md
├── benchmark
├── benchmark.ipynb
├── benchmark.py
├── diamond_benchmark.png
└── profile_nl.py
├── pyproject.toml
├── requirements.txt
├── setup.cfg
├── setup.py
└── torch_nl
├── __init__.py
├── geometry.py
├── linked_cell.py
├── naive_impl.py
├── neighbor_list.py
├── test_nl.py
├── timer.py
└── utils.py
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 | *.prof
6 | *.code-workspace
7 | # C extensions
8 | *.so
9 | .DS_Store
10 | # Distribution / packaging
11 | .Python
12 | build/
13 | develop-eggs/
14 | dist/
15 | downloads/
16 | eggs/
17 | .eggs/
18 | lib/
19 | lib64/
20 | parts/
21 | sdist/
22 | var/
23 | wheels/
24 | pip-wheel-metadata/
25 | share/python-wheels/
26 | *.egg-info/
27 | .installed.cfg
28 | *.egg
29 | MANIFEST
30 |
31 | # PyInstaller
32 | # Usually these files are written by a python script from a template
33 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
34 | *.manifest
35 | *.spec
36 |
37 | # Installer logs
38 | pip-log.txt
39 | pip-delete-this-directory.txt
40 |
41 | # Unit test / coverage reports
42 | htmlcov/
43 | .tox/
44 | .nox/
45 | .coverage
46 | .coverage.*
47 | .cache
48 | nosetests.xml
49 | coverage.xml
50 | *.cover
51 | *.py,cover
52 | .hypothesis/
53 | .pytest_cache/
54 |
55 | # Translations
56 | *.mo
57 | *.pot
58 |
59 | # Django stuff:
60 | *.log
61 | local_settings.py
62 | db.sqlite3
63 | db.sqlite3-journal
64 |
65 | # Flask stuff:
66 | instance/
67 | .webassets-cache
68 |
69 | # Scrapy stuff:
70 | .scrapy
71 |
72 | # Sphinx documentation
73 | docs/_build/
74 |
75 | # PyBuilder
76 | target/
77 |
78 | # Jupyter Notebook
79 | .ipynb_checkpoints
80 |
81 | # IPython
82 | profile_default/
83 | ipython_config.py
84 |
85 | # pyenv
86 | .python-version
87 |
88 | # pipenv
89 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
90 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
91 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
92 | # install all needed dependencies.
93 | #Pipfile.lock
94 |
95 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow
96 | __pypackages__/
97 |
98 | # Celery stuff
99 | celerybeat-schedule
100 | celerybeat.pid
101 |
102 | # SageMath parsed files
103 | *.sage.py
104 |
105 | # Environments
106 | .env
107 | .venv
108 | env/
109 | venv/
110 | ENV/
111 | env.bak/
112 | venv.bak/
113 |
114 | # Spyder project settings
115 | .spyderproject
116 | .spyproject
117 |
118 | # Rope project settings
119 | .ropeproject
120 |
121 | # mkdocs documentation
122 | /site
123 |
124 | # mypy
125 | .mypy_cache/
126 | .dmypy.json
127 | dmypy.json
128 |
129 | # Pyre type checker
130 | .pyre/
131 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 felixmusil
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | include requirements.txt
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # torch_nl
2 |
3 | Provide a pytorch implementation of a naive (`compute_neighborlist_n2`) and a linked cell (`compute_neighborlist`) neighbor list that are compatible with TorchScript.
4 |
5 | Their correctness is tested against ASE's implementation.
6 |
7 | Note that contrary to ASE, the atoms positions are assumed to be wrapped inside the unit cell.
8 | # How to
9 |
10 | ## instal with pip
11 |
12 | ```bash
13 | pip install torch-nl
14 | ```
15 |
16 | ## use the neighborlist
17 |
18 | ```python
19 | from torch_nl import compute_neighborlist, ase2data
20 | from ase.build import bulk, molecule
21 |
22 | frames = [bulk("Si", "diamond", a=6, cubic=True), molecule("CH3CH2NH2")]
23 | pos, cell, pbc, batch, n_atoms = ase2data(frames)
24 |
25 | mapping, batch_mapping, shifts_idx = compute_neighborlist(
26 | cutoff, pos, cell, pbc, batch, self_interaction
27 | )
28 | ```
29 |
30 | # Benchmarks
31 |
32 | ## Periodic structure
33 |
34 | 
35 |
--------------------------------------------------------------------------------
/benchmark/benchmark.ipynb:
--------------------------------------------------------------------------------
1 | {
2 | "cells": [
3 | {
4 | "cell_type": "code",
5 | "execution_count": 14,
6 | "metadata": {},
7 | "outputs": [],
8 | "source": [
9 | "import matplotlib.pyplot as plt\n",
10 | "import torch\n",
11 | "import ase\n",
12 | "import numpy as np\n",
13 | "import scipy\n",
14 | "from ase.build import molecule, bulk, make_supercell\n",
15 | "from ase.neighborlist import neighbor_list\n",
16 | "import pandas as pd\n",
17 | "from tqdm.notebook import tqdm\n",
18 | "import seaborn as sns"
19 | ]
20 | },
21 | {
22 | "cell_type": "code",
23 | "execution_count": 2,
24 | "metadata": {},
25 | "outputs": [],
26 | "source": [
27 | "from torch_nl import compute_neighborlist, compute_neighborlist_n2, ase2data\n",
28 | "from torch_nl.timer import timeit"
29 | ]
30 | },
31 | {
32 | "cell_type": "markdown",
33 | "metadata": {},
34 | "source": [
35 | "# Periodic systems"
36 | ]
37 | },
38 | {
39 | "cell_type": "markdown",
40 | "metadata": {},
41 | "source": [
42 | "## Metal "
43 | ]
44 | },
45 | {
46 | "cell_type": "code",
47 | "execution_count": 3,
48 | "metadata": {},
49 | "outputs": [],
50 | "source": [
51 | "frame = bulk('Si', 'diamond', a=4, cubic=True)\n",
52 | "aa = torch.arange(1, 6)\n",
53 | "Ps = torch.cartesian_prod(aa,aa,aa)\n",
54 | "Ps = Ps[torch.sort(Ps.sum(dim=1)).indices].to(torch.long).numpy()\n",
55 | "frames = []\n",
56 | "n_atoms = []\n",
57 | "for P in Ps:\n",
58 | " frames.append(make_supercell(frame, np.diag(P)))\n",
59 | " n_atoms.append(len(frames[-1]))\n",
60 | "n_atoms = np.array(n_atoms)"
61 | ]
62 | },
63 | {
64 | "cell_type": "code",
65 | "execution_count": 4,
66 | "metadata": {},
67 | "outputs": [],
68 | "source": [
69 | "cutoff = 4"
70 | ]
71 | },
72 | {
73 | "cell_type": "code",
74 | "execution_count": 5,
75 | "metadata": {},
76 | "outputs": [
77 | {
78 | "data": {
79 | "application/json": {
80 | "ascii": false,
81 | "bar_format": null,
82 | "colour": null,
83 | "elapsed": 0.011367321014404297,
84 | "initial": 0,
85 | "n": 0,
86 | "ncols": null,
87 | "nrows": 54,
88 | "postfix": null,
89 | "prefix": "",
90 | "rate": null,
91 | "total": 125,
92 | "unit": "it",
93 | "unit_divisor": 1000,
94 | "unit_scale": false
95 | },
96 | "application/vnd.jupyter.widget-view+json": {
97 | "model_id": "ab749187b35f41579193cdd675218261",
98 | "version_major": 2,
99 | "version_minor": 0
100 | },
101 | "text/plain": [
102 | " 0%| | 0/125 [00:00, ?it/s]"
103 | ]
104 | },
105 | "metadata": {},
106 | "output_type": "display_data"
107 | }
108 | ],
109 | "source": [
110 | "tag = \"ASE\"\n",
111 | "datas = []\n",
112 | "for frame in tqdm(frames):\n",
113 | " timing = timeit(neighbor_list, ['ijS', frame, cutoff], tag=tag, warmup=1, nit=50)\n",
114 | " data = timing.dumps()\n",
115 | " i,j,S = neighbor_list('ijS', frame, cutoff)\n",
116 | " n_neighbor = np.bincount(i).mean()\n",
117 | " data.update(n_atom=len(frame), n_neighbor_per_atom_avg=int(n_neighbor))\n",
118 | " data.pop('samples')\n",
119 | " datas.append(data)\n",
120 | "# df = pd.DataFrame(datas)"
121 | ]
122 | },
123 | {
124 | "cell_type": "code",
125 | "execution_count": 6,
126 | "metadata": {},
127 | "outputs": [
128 | {
129 | "data": {
130 | "application/json": {
131 | "ascii": false,
132 | "bar_format": null,
133 | "colour": null,
134 | "elapsed": 0.011538267135620117,
135 | "initial": 0,
136 | "n": 0,
137 | "ncols": null,
138 | "nrows": 54,
139 | "postfix": null,
140 | "prefix": "",
141 | "rate": null,
142 | "total": 3,
143 | "unit": "it",
144 | "unit_divisor": 1000,
145 | "unit_scale": false
146 | },
147 | "application/vnd.jupyter.widget-view+json": {
148 | "model_id": "7271363cbf3c488b8f304250ae5e5dae",
149 | "version_major": 2,
150 | "version_minor": 0
151 | },
152 | "text/plain": [
153 | " 0%| | 0/3 [00:00, ?it/s]"
154 | ]
155 | },
156 | "metadata": {},
157 | "output_type": "display_data"
158 | },
159 | {
160 | "data": {
161 | "application/json": {
162 | "ascii": false,
163 | "bar_format": null,
164 | "colour": null,
165 | "elapsed": 0.011441469192504883,
166 | "initial": 0,
167 | "n": 0,
168 | "ncols": null,
169 | "nrows": 54,
170 | "postfix": null,
171 | "prefix": "",
172 | "rate": null,
173 | "total": 125,
174 | "unit": "it",
175 | "unit_divisor": 1000,
176 | "unit_scale": false
177 | },
178 | "application/vnd.jupyter.widget-view+json": {
179 | "model_id": "64db2e509ee9433589ffa2fe314aec27",
180 | "version_major": 2,
181 | "version_minor": 0
182 | },
183 | "text/plain": [
184 | " 0%| | 0/125 [00:00, ?it/s]"
185 | ]
186 | },
187 | "metadata": {},
188 | "output_type": "display_data"
189 | },
190 | {
191 | "data": {
192 | "application/json": {
193 | "ascii": false,
194 | "bar_format": null,
195 | "colour": null,
196 | "elapsed": 0.009797096252441406,
197 | "initial": 0,
198 | "n": 0,
199 | "ncols": null,
200 | "nrows": 54,
201 | "postfix": null,
202 | "prefix": "",
203 | "rate": null,
204 | "total": 125,
205 | "unit": "it",
206 | "unit_divisor": 1000,
207 | "unit_scale": false
208 | },
209 | "application/vnd.jupyter.widget-view+json": {
210 | "model_id": "4af8f351b2d54b7e8a59d589d6ee0d79",
211 | "version_major": 2,
212 | "version_minor": 0
213 | },
214 | "text/plain": [
215 | " 0%| | 0/125 [00:00, ?it/s]"
216 | ]
217 | },
218 | "metadata": {},
219 | "output_type": "display_data"
220 | },
221 | {
222 | "data": {
223 | "application/json": {
224 | "ascii": false,
225 | "bar_format": null,
226 | "colour": null,
227 | "elapsed": 0.018729209899902344,
228 | "initial": 0,
229 | "n": 0,
230 | "ncols": null,
231 | "nrows": 54,
232 | "postfix": null,
233 | "prefix": "",
234 | "rate": null,
235 | "total": 125,
236 | "unit": "it",
237 | "unit_divisor": 1000,
238 | "unit_scale": false
239 | },
240 | "application/vnd.jupyter.widget-view+json": {
241 | "model_id": "a0754679bca646fe9edc4286a67f8004",
242 | "version_major": 2,
243 | "version_minor": 0
244 | },
245 | "text/plain": [
246 | " 0%| | 0/125 [00:00, ?it/s]"
247 | ]
248 | },
249 | "metadata": {},
250 | "output_type": "display_data"
251 | }
252 | ],
253 | "source": [
254 | "tags = [\n",
255 | " # \"torch_nl O(n^2) CPU\", \n",
256 | " \"torch_nl O(n^2) GPU\", \n",
257 | " \"torch_nl O(n) CPU\", \n",
258 | " \"torch_nl O(n) GPU\"\n",
259 | "]\n",
260 | "for tag in tqdm(tags):\n",
261 | " if \"CPU\" in tag:\n",
262 | " device = 'cpu'\n",
263 | " elif \"GPU\" in tag:\n",
264 | " device = 'cuda'\n",
265 | " \n",
266 | " if 'O(n^2)' in tag:\n",
267 | " nl_func = compute_neighborlist_n2\n",
268 | " elif 'O(n)' in tag:\n",
269 | " nl_func = compute_neighborlist\n",
270 | "\n",
271 | " for frame in tqdm(frames):\n",
272 | " pos, cell, pbc, batch, n_atoms = ase2data([frame], device=device)\n",
273 | " timing = timeit(nl_func, [cutoff, pos, cell, pbc, batch], tag=tag, warmup=10, nit=50)\n",
274 | " data = timing.dumps()\n",
275 | " data.pop('samples')\n",
276 | " mapping, mapping_batch, shifts_idx = nl_func(cutoff, pos, cell, pbc, batch)\n",
277 | " n_neighbor = np.bincount(mapping[0].cpu().numpy()).mean()\n",
278 | " data.update(n_atom=len(frame), n_neighbor_per_atom_avg=int(n_neighbor))\n",
279 | " datas.append(data)"
280 | ]
281 | },
282 | {
283 | "cell_type": "code",
284 | "execution_count": 7,
285 | "metadata": {},
286 | "outputs": [],
287 | "source": [
288 | "df = pd.DataFrame(datas)"
289 | ]
290 | },
291 | {
292 | "cell_type": "code",
293 | "execution_count": 8,
294 | "metadata": {},
295 | "outputs": [
296 | {
297 | "data": {
298 | "text/html": [
299 | "
\n",
300 | "\n",
313 | "
\n",
314 | " \n",
315 | " \n",
316 | " | \n",
317 | " tag | \n",
318 | " mean | \n",
319 | " stdev | \n",
320 | " min | \n",
321 | " max | \n",
322 | " n_atom | \n",
323 | " n_neighbor_per_atom_avg | \n",
324 | "
\n",
325 | " \n",
326 | " \n",
327 | " \n",
328 | " 0 | \n",
329 | " ASE | \n",
330 | " 0.002498 | \n",
331 | " 0.000041 | \n",
332 | " 0.002439 | \n",
333 | " 0.002685 | \n",
334 | " 8 | \n",
335 | " 28 | \n",
336 | "
\n",
337 | " \n",
338 | " 1 | \n",
339 | " ASE | \n",
340 | " 0.003127 | \n",
341 | " 0.000020 | \n",
342 | " 0.003101 | \n",
343 | " 0.003168 | \n",
344 | " 16 | \n",
345 | " 28 | \n",
346 | "
\n",
347 | " \n",
348 | " 2 | \n",
349 | " ASE | \n",
350 | " 0.003162 | \n",
351 | " 0.000063 | \n",
352 | " 0.003101 | \n",
353 | " 0.003374 | \n",
354 | " 16 | \n",
355 | " 28 | \n",
356 | "
\n",
357 | " \n",
358 | " 3 | \n",
359 | " ASE | \n",
360 | " 0.003145 | \n",
361 | " 0.000043 | \n",
362 | " 0.003100 | \n",
363 | " 0.003297 | \n",
364 | " 16 | \n",
365 | " 28 | \n",
366 | "
\n",
367 | " \n",
368 | " 4 | \n",
369 | " ASE | \n",
370 | " 0.004429 | \n",
371 | " 0.000050 | \n",
372 | " 0.004374 | \n",
373 | " 0.004577 | \n",
374 | " 32 | \n",
375 | " 28 | \n",
376 | "
\n",
377 | " \n",
378 | " ... | \n",
379 | " ... | \n",
380 | " ... | \n",
381 | " ... | \n",
382 | " ... | \n",
383 | " ... | \n",
384 | " ... | \n",
385 | " ... | \n",
386 | "
\n",
387 | " \n",
388 | " 495 | \n",
389 | " torch_nl O(n) GPU | \n",
390 | " 0.005046 | \n",
391 | " 0.000190 | \n",
392 | " 0.004930 | \n",
393 | " 0.006348 | \n",
394 | " 600 | \n",
395 | " 29 | \n",
396 | "
\n",
397 | " \n",
398 | " 496 | \n",
399 | " torch_nl O(n) GPU | \n",
400 | " 0.005924 | \n",
401 | " 0.000094 | \n",
402 | " 0.005809 | \n",
403 | " 0.006476 | \n",
404 | " 800 | \n",
405 | " 29 | \n",
406 | "
\n",
407 | " \n",
408 | " 497 | \n",
409 | " torch_nl O(n) GPU | \n",
410 | " 0.005886 | \n",
411 | " 0.000038 | \n",
412 | " 0.005795 | \n",
413 | " 0.005943 | \n",
414 | " 800 | \n",
415 | " 29 | \n",
416 | "
\n",
417 | " \n",
418 | " 498 | \n",
419 | " torch_nl O(n) GPU | \n",
420 | " 0.005913 | \n",
421 | " 0.000068 | \n",
422 | " 0.005803 | \n",
423 | " 0.006292 | \n",
424 | " 800 | \n",
425 | " 29 | \n",
426 | "
\n",
427 | " \n",
428 | " 499 | \n",
429 | " torch_nl O(n) GPU | \n",
430 | " 0.006839 | \n",
431 | " 0.000631 | \n",
432 | " 0.006651 | \n",
433 | " 0.011249 | \n",
434 | " 1000 | \n",
435 | " 29 | \n",
436 | "
\n",
437 | " \n",
438 | "
\n",
439 | "
500 rows × 7 columns
\n",
440 | "
"
441 | ],
442 | "text/plain": [
443 | " tag mean stdev min max n_atom \\\n",
444 | "0 ASE 0.002498 0.000041 0.002439 0.002685 8 \n",
445 | "1 ASE 0.003127 0.000020 0.003101 0.003168 16 \n",
446 | "2 ASE 0.003162 0.000063 0.003101 0.003374 16 \n",
447 | "3 ASE 0.003145 0.000043 0.003100 0.003297 16 \n",
448 | "4 ASE 0.004429 0.000050 0.004374 0.004577 32 \n",
449 | ".. ... ... ... ... ... ... \n",
450 | "495 torch_nl O(n) GPU 0.005046 0.000190 0.004930 0.006348 600 \n",
451 | "496 torch_nl O(n) GPU 0.005924 0.000094 0.005809 0.006476 800 \n",
452 | "497 torch_nl O(n) GPU 0.005886 0.000038 0.005795 0.005943 800 \n",
453 | "498 torch_nl O(n) GPU 0.005913 0.000068 0.005803 0.006292 800 \n",
454 | "499 torch_nl O(n) GPU 0.006839 0.000631 0.006651 0.011249 1000 \n",
455 | "\n",
456 | " n_neighbor_per_atom_avg \n",
457 | "0 28 \n",
458 | "1 28 \n",
459 | "2 28 \n",
460 | "3 28 \n",
461 | "4 28 \n",
462 | ".. ... \n",
463 | "495 29 \n",
464 | "496 29 \n",
465 | "497 29 \n",
466 | "498 29 \n",
467 | "499 29 \n",
468 | "\n",
469 | "[500 rows x 7 columns]"
470 | ]
471 | },
472 | "execution_count": 8,
473 | "metadata": {},
474 | "output_type": "execute_result"
475 | }
476 | ],
477 | "source": [
478 | "df"
479 | ]
480 | },
481 | {
482 | "cell_type": "code",
483 | "execution_count": 16,
484 | "metadata": {
485 | "scrolled": true
486 | },
487 | "outputs": [
488 | {
489 | "data": {
490 | "image/png": "iVBORw0KGgoAAAANSUhEUgAAAp8AAAHkCAYAAAB45USXAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjUuMSwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/YYfK9AAAACXBIWXMAAA9hAAAPYQGoP6dpAACboUlEQVR4nOzdd3xUVdoH8N+9UzPphZDQQyAkQGgCIiBNQEQQAQEpCi4CiqLIriviS3Wl7LKiix0LrKCgIqA0WWkWmiBSQiIQQk8IKZM2ybR73z+GGTKZSUhCMpPA77ufLM6559x7LvXJKc8RZFmWQURERETkAaK3O0BEREREdw8Gn0RERETkMQw+iYiIiMhjGHwSERERkccw+CQiIiIij2HwSUREREQew+CTiIiIiDyGwScREREReQyDz0ro2bMnevbs6e1uEBEREdU6Sm93oDa6dOmSt7tAREREVCtx5JOIiIiIPIbBJxERERF5DINPIiIiIvIYBp9ERERE5DEMPomIiIjIYxh8EhEREZHHMPgkIiIiIo9h8ElEREREHsPgk4iIiIg8hsEnEREREXkMg08iIiIi8hgGn0RERETkMQw+iYiIiMhjlN7uABERUblJEpB2DDBkArpQIKItIHIchag2YfBJRES1w7m9wC/LgIwzgGQGRBUQ1hzo/hLQtKe3e0dE5cRvF4mIqOY7txfYPB24lgCofQG/urYfryXYys/t9XYPiaicGHwSEVHNJkm2EU9jPuAfCah8AEG0/egfaSv/ZZmtHhHVeAw+iYioZks7Zptq9wkGBMH5miDYyjPO2OoRUY3H4JOIiGo2Q6ZtjadS4/66UmO7bsj0bL+IqFIYfBIRUc2mC7VtLrIY3V+3GG3XdaGe7RcRVQqDTyIiqtki2tp2tRdmA7LsfE2WbeVhzW31iKjGY/BJREQ1myja0ilp/IC8VMBcCMiS7ce8VEDjb7vOfJ9EtQL/pBIRUc3XtCcw6C2gbivAVADkX7P9WLcVMGgZ83wS1SJMMk9ERLVD055Ak/t5whFRLcfgk4iIag9RBOq193YviOg28NtFIiIiIvIYBp9ERERE5DEMPomIiIjIYxh8EhEREZHHMPgkIiIiIo9h8ElEREREHsPgk4iIiIg8hsEnEREREXkMg08iIiIi8hgGn0RERETkMQw+iYiIiMhjGHwSERERkccw+CQiIiIij2HwSUREREQew+CTiIiIiDyGwScREREReQyDTyIiIiLymBoRfJ4+fRoDBgyAr68vwsPD8eKLL6KwsLBcbVetWoXY2FhotVq0bt0aX3/9tdt6CQkJeOSRRxAYGAg/Pz907NgR+/btq8rXICIiIqJbUHq7A3q9Hn369EHjxo2xfv16pKenY8aMGcjMzMTq1avLbPvNN99gwoQJmDlzJvr374+NGzdi1KhRCAwMRP/+/R31jh8/jvvvvx8PP/ww1q5dC6VSid9//x0Gg6G6X4+IiIiIihFkWZa92YElS5ZgwYIFuHDhAsLCwgAAX3zxBcaOHYtTp04hLi6u1LZxcXGIj4/HV1995Sh78MEHkZOTgwMHDjjKunbtiiZNmuCLL76okj43bdoUAHDu3LkquR8RERHR3cLr0+5bt25F3759HYEnAAwfPhwajQZbt24ttV1KSgqSkpIwevRop/IxY8bg0KFDyMjIAAAkJiZi//79mDZtWvW8ABERERGVm9eDz8TERJfRTY1Gg+joaCQmJpbZDoBL25YtW0KWZSQlJQGAYwQ0JycH7dq1g1KpRJMmTbB8+fKqfA0iIiIiKgevr/nMzs5GUFCQS3lwcDCysrLKbAfApW1wcDAAONqmpaUBAMaOHYu//e1vWLZsGb777ju88MILCAkJwdixY93e3z617s6lS5fQsGHDUq8TERERkXteDz4BQBAElzJZlt2W36qtfQmrvVySJADAxIkT8eqrrwIAevfujeTkZLzxxhulBp9EREREVPW8HnwGBwc7RjGL0+v1ZW42so9wZmdno27duk7til8PCQkBAPTp08epfZ8+fbB161aYzWaoVCqX+5e1maisUVEiIiIiKp3X13zGxcW5rO00Go1ITk6+5U53AC5tT506BUEQEBsb61SvJFmWIYpiuUZXiYiIiKhqeD34HDhwIHbu3InMzExH2YYNG2A0GjFw4MBS20VFRSE2Nhbr1q1zKv/yyy/RuXNnx+75rl27Ijg4GD/++KNTvZ07d6Jly5ZQKr0++EtERER01/B65DVlyhQsX74cQ4YMwezZsx1J5seOHes0ajlx4kSsWrUKFovFUbZgwQKMGjUK0dHR6NevHzZt2oQdO3Zg+/btjjpqtRpz5szB3//+dwQFBeHee+/F999/jy1btmDDhg0efVciIiKiu53Xg8+goCDs2rUL06ZNw7Bhw6DT6TB69GgsWbLEqZ7VaoXVanUqGzFiBAwGAxYuXIilS5eiWbNmWLdundPpRgAwffp0CIKAt99+GwsWLEB0dDRWrVqFRx99tLpfj4iIiIiK8foJR7URTzgiIiIiqhyvr/kkIiIiorsHg08iIiIi8hgGn0RERETkMQw+iYiIiMhjGHwSERERkccw+CQiIiIij2HwSUREREQew+CTiIiIiDyGwScREREReQyDTyIiIiLyGAafREREROQxDD6JiIiIyGMYfBIRERGRxzD4JCIiIiKPYfBJRERERB7D4JOIiIiIPIbBJxERERF5DINPIiIiIvIYBp9ERERE5DEMPomIiIjIYxh8EhEREZHHMPgkIiIiIo9h8ElEREREHsPgk4iIiIg8hsEnEREREXkMg08iIiIi8hgGn0RERETkMQw+iYiIiMhjGHwSERERkccw+CQiIiIij2HwSUREREQew+CTiIiIiDyGwScREREReQyDTyIiIiLyGAafREREROQxDD6JiIiIyGMYfBIRERGRxzD4JCIiIiKPYfBJRERERB7D4JOIiIiIPIbBJxERERF5DINPIiIiIvIYBp9ERERE5DEMPomIiIjIYxh8EhEREZHHMPgkIiIiIo+pEcHn6dOnMWDAAPj6+iI8PBwvvvgiCgsLy9V21apViI2NhVarRevWrfH111+71BEEweUrIiKiql+DiIiIiG5B6e0O6PV69OnTB40bN8b69euRnp6OGTNmIDMzE6tXry6z7TfffIMJEyZg5syZ6N+/PzZu3IhRo0YhMDAQ/fv3d6o7bdo0jBkzxvFZrVZXy/sQERERUem8Hnx++OGHyM7Oxh9//IGwsDAAgFKpxNixY/Haa68hLi6u1LazZ8/GiBEjsGjRIgBA7969kZSUhDlz5rgEn40aNUKXLl2q70WIiIiI6Ja8Pu2+detW9O3b1xF4AsDw4cOh0WiwdevWUtulpKQgKSkJo0ePdiofM2YMDh06hIyMjGrrMxERERFVjteDz8TERJfRTY1Gg+joaCQmJpbZDoBL25YtW0KWZSQlJTmVL168GCqVCkFBQRg1ahQuXrxYRW9AREREROXl9Wn37OxsBAUFuZQHBwcjKyurzHYAXNoGBwcDgFPbJ598EoMGDULdunVx8uRJvP766+jevTuOHTvmqF9S06ZNS332pUuX0LBhw1KvExER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c1SVqDpIkowTl3Ow9/R1nLicA6lkJFhN0vOLkFtU+i5MAMgtsiI9v/zTW0RkI4giwiZPguirgyU9HVJREWRJglRUBEt6OkRfX4RNnlTt+T7j4uJw/vx5fPnllzh8+DDOnz8PwLbh6NChQxg9ejS2b9+O9957D5MnT8YDDzzgtNnInSeeeALt27fHoEGDsHz5cuzevRurV6/G5MmTq/VdbsegQYPw0ksvYdGiRZgwYQK2bduGH3/8ES+99BKee+45jBw5ElOmTHHU79atGyRJwtGjRyv9zN9++w1xcXGlrh21++KLLyCKIjp06IDFixdj586d2L17Nz777DO8//770Gg0FZ4mnzVrFi5duoQHHngAX331FX788Ue88cYbePbZZzF27FjExsZW+r2qi9en3bOzs13WVABAcHCwy1qUku0A1/UYwTemNUprazabMX36dLRo0aLMI7qaNm1a6rVLly657FYjupV9ZzPw/t5kJKfnw2yVoVIIiA73w7M9o6v92MgTl3PKXe+B2LrV2heiO5Fvly6InD8fGR+tgCklBXJODgSVCpqYGIRNngTfEjvQq8PEiRNx6NAhTJs2DZmZmRg/fjxWrlyJRx55BOvXr8eCBQswZMgQBAUFYdy4cViyZMkt76nRaLBz50689tprWLhwIbKystCgQQOXWcea5s0338S9996Ld955ByNHjnQMOC1fvhyTJ092mhGNiYlBmzZtsG3bNqcd7BWxbds2lzyh7jRr1gy///47/vWvf2HVqlWYP38+BEFA06ZN8eCDD2Lt2rUIDKzYARtt2rTBzz//jDlz5mDy5MkwGAxo3LgxXnnlFbe5TmsCQS45R+BhKpUK//jHP/DKK684lXfr1g0RERFYv36923Zr1qzBuHHjkJaWhrp1b/5jeebMGcTExOC7777D4MGDXdpNmTIFn3/+OX766acy11CUJ/is6DFcdPeyb/bJN1oQrFNDrRBhskrINpjhp1FU+7nlr204gTUHL96y3th7G+GNofHV1g+iO92ddMLR3WT58uV46623cPbsWZelerdy/PhxdOjQAWfOnHGbrolceX3kMzg42DGKWZxery9zWtw+wpmdne0UfNoXDQe7Wdg9f/58fPLJJ/j222/LDDwBlBlYlhWYEpVk3+yTb7QgIkDr+ItNKyoQESAiLddY7Zt9fDXl+6Ne3npE5J4g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\n",
491 | "text/plain": [
492 | ""
493 | ]
494 | },
495 | "metadata": {},
496 | "output_type": "display_data"
497 | }
498 | ],
499 | "source": [
500 | "with sns.plotting_context(\"notebook\"):\n",
501 | " plt.title(\"Compute neighborlist: diamond structure\")\n",
502 | " sns.lmplot(data=df, x='n_atom', y='mean', hue='tag',fit_reg=False)\n",
503 | " plt.xlabel('timing []')\n",
504 | " plt.savefig('diamond_benchmark.png', dpi=300, bbox_inches='tight')"
505 | ]
506 | },
507 | {
508 | "cell_type": "code",
509 | "execution_count": 15,
510 | "metadata": {},
511 | "outputs": [],
512 | "source": [
513 | "plt.savefig?"
514 | ]
515 | },
516 | {
517 | "cell_type": "code",
518 | "execution_count": null,
519 | "metadata": {},
520 | "outputs": [],
521 | "source": []
522 | }
523 | ],
524 | "metadata": {
525 | "kernelspec": {
526 | "display_name": "jax39",
527 | "language": "python",
528 | "name": "jax39"
529 | },
530 | "language_info": {
531 | "codemirror_mode": {
532 | "name": "ipython",
533 | "version": 3
534 | },
535 | "file_extension": ".py",
536 | "mimetype": "text/x-python",
537 | "name": "python",
538 | "nbconvert_exporter": "python",
539 | "pygments_lexer": "ipython3",
540 | "version": "3.9.13"
541 | },
542 | "toc": {
543 | "colors": {
544 | "hover_highlight": "#DAA520",
545 | "navigate_num": "#000000",
546 | "navigate_text": "#333333",
547 | "running_highlight": "#FF0000",
548 | "selected_highlight": "#FFD700",
549 | "sidebar_border": "#EEEEEE",
550 | "wrapper_background": "#FFFFFF"
551 | },
552 | "moveMenuLeft": true,
553 | "nav_menu": {
554 | "height": "48.9333px",
555 | "width": "251.8px"
556 | },
557 | "navigate_menu": true,
558 | "number_sections": true,
559 | "sideBar": true,
560 | "threshold": 4,
561 | "toc_cell": false,
562 | "toc_section_display": "block",
563 | "toc_window_display": false,
564 | "widenNotebook": false
565 | },
566 | "vscode": {
567 | "interpreter": {
568 | "hash": "f79d3df5ff5684964744ab9f5218f96eb946f2b40d3f02d5eb965bb50f364a25"
569 | }
570 | }
571 | },
572 | "nbformat": 4,
573 | "nbformat_minor": 2
574 | }
575 |
--------------------------------------------------------------------------------
/benchmark/benchmark.py:
--------------------------------------------------------------------------------
1 | import torch
2 | import ase
3 | import sys
4 | import numpy as np
5 | import scipy
6 | from ase.build import molecule, bulk, make_supercell
7 | from ase.neighborlist import neighbor_list
8 | import pandas as pd
9 | from tqdm import tqdm
10 |
11 | # import seaborn as sns
12 | import matplotlib.pyplot as plt
13 |
14 | sys.path.insert(0, "../")
15 | from torch_nl import compute_neighborlist, compute_neighborlist_n2, ase2data
16 | from torch_nl.timer import timeit
17 |
18 | torch.set_num_threads(4)
19 | cutoff = 4
20 | tags = [
21 | # "torch_nl O(n^2) CPU",
22 | "torch_nl O(n^2) GPU",
23 | "torch_nl O(n) CPU",
24 | "torch_nl O(n) GPU"
25 | ]
26 |
27 | frame = bulk("Si", "diamond", a=4, cubic=True)
28 | aa = torch.arange(1, 6)
29 | Ps = torch.cartesian_prod(aa, aa, aa)
30 | Ps = Ps[torch.sort(Ps.sum(dim=1)).indices].to(torch.long).numpy()
31 | frames = []
32 | n_atoms = []
33 | for P in Ps:
34 | frames.append(make_supercell(frame, np.diag(P)))
35 | n_atoms.append(len(frames[-1]))
36 | n_atoms = np.array(n_atoms)
37 | print("Starting")
38 | tag = "ASE"
39 | datas = []
40 | for frame in tqdm(frames):
41 | timing = timeit(
42 | neighbor_list, ["ijS", frame, cutoff], tag=tag, warmup=1, nit=50
43 | )
44 | data = timing.dumps()
45 | i, j, S = neighbor_list("ijS", frame, cutoff)
46 | n_neighbor = np.bincount(i).mean()
47 | data.update(n_atom=len(frame), n_neighbor_per_atom_avg=int(n_neighbor))
48 | data.pop("samples")
49 | datas.append(data)
50 |
51 |
52 | for tag in tqdm(tags):
53 | if "CPU" in tag:
54 | device = "cpu"
55 | elif "GPU" in tag:
56 | device = "cuda"
57 |
58 | if "O(n^2)" in tag:
59 | nl_func = compute_neighborlist_n2
60 | elif "O(n)" in tag:
61 | nl_func = compute_neighborlist
62 |
63 | for frame in tqdm(frames):
64 | pos, cell, pbc, batch, n_atoms = ase2data([frame], device=device)
65 | with torch.cuda.amp.autocast():
66 | timing = timeit(
67 | nl_func,
68 | [cutoff, pos, cell, pbc, batch],
69 | tag=tag,
70 | warmup=10,
71 | nit=50,
72 | )
73 | data = timing.dumps()
74 | data.pop("samples")
75 | mapping, mapping_batch, shifts_idx = nl_func(
76 | cutoff, pos, cell, pbc, batch
77 | )
78 | n_neighbor = np.bincount(mapping[0].cpu().numpy()).mean()
79 | data.update(n_atom=len(frame), n_neighbor_per_atom_avg=int(n_neighbor))
80 | datas.append(data)
81 |
82 | df = pd.DataFrame(datas)
83 |
84 | # sns.lmplot(data=df, x='n_atom', y='mean', hue='tag',fit_reg=False)
85 |
86 | # plt.savefig('./test_0.png', dpi=300, bbox_inches='tight')
87 | # plt.show()
88 | print("END")
89 | # %%
90 |
--------------------------------------------------------------------------------
/benchmark/diamond_benchmark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/felixmusil/torch_nl/ce69750db61343b983c4e9cd92450494a487885e/benchmark/diamond_benchmark.png
--------------------------------------------------------------------------------
/benchmark/profile_nl.py:
--------------------------------------------------------------------------------
1 | import torch.profiler
2 | import torch
3 |
4 | torch.jit.set_fusion_strategy([("STATIC", 3), ("DYNAMIC", 3)])
5 |
6 | import sys
7 |
8 | sys.path.insert(0, "../")
9 | import numpy as np
10 |
11 | from torch_nl import compute_neighborlist, compute_neighborlist_n2, ase2data
12 | from torch_nl.timer import timeit
13 |
14 | from ase.build import molecule, bulk, make_supercell
15 |
16 | device = "cuda"
17 | cutoff = 4
18 | frame = bulk("Si", "diamond", a=4, cubic=True)
19 |
20 | frame = make_supercell(frame, 6 * np.eye(3))
21 |
22 |
23 | pos, cell, pbc, batch, n_atoms = ase2data([frame], device=device)
24 |
25 |
26 | with torch.profiler.profile(
27 | schedule=torch.profiler.schedule(wait=20, warmup=20, active=2, repeat=1),
28 | on_trace_ready=torch.profiler.tensorboard_trace_handler(
29 | "/local_scratch/musil/nl_n.prof"
30 | ),
31 | record_shapes=False,
32 | profile_memory=False,
33 | with_stack=True,
34 | ) as prof:
35 | for _ in range(50):
36 | mapping, mapping_batch, shifts_idx = compute_neighborlist_n2(
37 | cutoff, pos, cell, pbc, batch
38 | )
39 | prof.step()
40 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [build-system]
2 | requires = ["setuptools", "wheel", "ninja"]
3 | build-backend = "setuptools.build_meta"
4 |
5 | [tool.black]
6 | line-length = 80
7 | target-version = ['py38', 'py39']
8 | include = '\.pyi?$'
9 | extend-exclude = '''
10 | /(
11 |
12 | )/
13 | '''
14 |
15 | [tool.pytest.ini_options]
16 | minversion = "6.0"
17 | addopts = "-ra -q"
18 | testpaths = [
19 | "mlcg",
20 | ]
21 | filterwarnings = [
22 | "ignore::DeprecationWarning:networkx.*"
23 | ]
24 |
25 | [tool.coverage.run]
26 | branch = true
27 | source = ["mlcg/"]
28 | omit = [
29 | "**/test_*.py",
30 | "**/__init__.py",
31 | ]
32 |
33 | [tool.coverage.report]
34 | exclude_lines = [
35 | "if self.debug:" ,
36 | "pragma: no cover" ,
37 | "raise NotImplementedError" ,
38 | "@(abc\\.)?abstractmethod" ,
39 | ]
40 | ignore_errors = true
41 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | ase
2 | numpy
3 | torch >=1.10
4 | # developer tools
5 | pytest
6 | black[jupyter]
7 |
8 |
--------------------------------------------------------------------------------
/setup.cfg:
--------------------------------------------------------------------------------
1 | [metadata]
2 | name = torch_nl
3 | version = attr: torch_nl.__version__
4 | description = TorchScript-able neighbor lists implementations (linear and quadratic scaling) for molecular modeling
5 | long_description = file: README.md
6 | long_description_content_type = text/markdown
7 | classifiers =
8 | Programming Language :: Python :: 3.7
9 | Topic :: Scientific/Engineering :: Chemistry
10 | Topic :: Scientific/Engineering :: Physics
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | from setuptools import setup, find_packages
2 | import re
3 |
4 | NAME = "torch_nl"
5 |
6 | # read the version number from the library
7 | pattern = r"[0-9]\.[0-9]"
8 | VERSION = None
9 | with open("./torch_nl/__init__.py", "r") as fp:
10 | for line in fp.readlines():
11 | if "__version__" in line:
12 | VERSION = re.findall(pattern, line)[0]
13 | if VERSION is None:
14 | raise ValueError("Version number not found.")
15 |
16 |
17 | with open("requirements.txt") as f:
18 | install_requires = list(
19 | filter(lambda x: "#" not in x, (line.strip() for line in f))
20 | )
21 |
22 | setup(
23 | name=NAME,
24 | version=VERSION,
25 | packages=find_packages(),
26 | zip_safe=True,
27 | python_requires=">=3.8",
28 | license="MIT",
29 | author="Fe" + "\u0301" + "lix Musil",
30 | install_requires=install_requires,
31 | )
32 |
--------------------------------------------------------------------------------
/torch_nl/__init__.py:
--------------------------------------------------------------------------------
1 | __version__ = "0.3"
2 |
3 | from .neighbor_list import (
4 | compute_neighborlist,
5 | compute_neighborlist_n2,
6 | strict_nl,
7 | )
8 | from .geometry import compute_distances, compute_cell_shifts
9 | from .naive_impl import build_naive_neighborhood
10 | from .linked_cell import linked_cell
11 | from .utils import ase2data
12 |
--------------------------------------------------------------------------------
/torch_nl/geometry.py:
--------------------------------------------------------------------------------
1 | import torch
2 | from typing import Optional
3 |
4 |
5 | def compute_distances(
6 | pos: torch.Tensor,
7 | mapping: torch.Tensor,
8 | cell_shifts: Optional[torch.Tensor] = None,
9 | ):
10 | assert mapping.dim() == 2
11 | assert mapping.shape[0] == 2
12 |
13 | if cell_shifts is None:
14 | dr = pos[mapping[1]] - pos[mapping[0]]
15 | else:
16 | dr = pos[mapping[1]] - pos[mapping[0]] + cell_shifts
17 |
18 | return dr.norm(p=2, dim=1)
19 |
20 |
21 | def compute_cell_shifts(
22 | cell: torch.Tensor, shifts_idx: torch.Tensor, batch_mapping: torch.Tensor
23 | ):
24 | if cell is None:
25 | cell_shifts = None
26 | else:
27 | cell_shifts = torch.einsum(
28 | "jn,jnm->jm", shifts_idx, cell.view(-1, 3, 3)[batch_mapping]
29 | )
30 | return cell_shifts
31 |
--------------------------------------------------------------------------------
/torch_nl/linked_cell.py:
--------------------------------------------------------------------------------
1 | from typing import Tuple
2 | import torch
3 |
4 | from .utils import get_number_of_cell_repeats, get_cell_shift_idx, strides_of
5 | from .geometry import compute_cell_shifts
6 |
7 |
8 | def ravel_3d(idx_3d: torch.Tensor, shape: torch.Tensor) -> torch.Tensor:
9 | """Convert 3d indices meant for an array of sizes `shape` into linear
10 | indices.
11 |
12 | Parameters
13 | ----------
14 | idx_3d : [-1, 3]
15 | _description_
16 | shape : [3]
17 | _description_
18 |
19 | Returns
20 | -------
21 | torch.Tensor
22 | linear indices
23 | """
24 | idx_linear = idx_3d[:, 2] + shape[2] * (
25 | idx_3d[:, 1] + shape[1] * idx_3d[:, 0]
26 | )
27 | return idx_linear
28 |
29 |
30 | def unravel_3d(idx_linear: torch.Tensor, shape: torch.Tensor) -> torch.Tensor:
31 | """Convert linear indices meant for an array of sizes `shape` into 3d indices.
32 |
33 | Parameters
34 | ----------
35 | idx_linear : torch.Tensor [-1]
36 |
37 | shape : torch.Tensor [3]
38 |
39 |
40 | Returns
41 | -------
42 | torch.Tensor [-1, 3]
43 |
44 | """
45 | idx_3d = idx_linear.new_empty((idx_linear.shape[0], 3))
46 | idx_3d[:, 2] = torch.remainder(idx_linear, shape[2])
47 | idx_3d[:, 1] = torch.remainder(
48 | torch.div(idx_linear, shape[2], rounding_mode="floor"), shape[1]
49 | )
50 | idx_3d[:, 0] = torch.div(
51 | idx_linear, shape[1] * shape[2], rounding_mode="floor"
52 | )
53 | return idx_3d
54 |
55 |
56 | def get_linear_bin_idx(
57 | cell: torch.Tensor, pos: torch.Tensor, nbins_s: torch.Tensor
58 | ) -> torch.Tensor:
59 | """Find the linear bin index of each input pos given a box defined by its cell vectors and a number of bins, contained in the box, for each directions of the box.
60 |
61 | Parameters
62 | ----------
63 | cell : torch.Tensor [3, 3]
64 | cell vectors
65 | pos : torch.Tensor [-1, 3]
66 | set of positions
67 | nbins_s : torch.Tensor [3]
68 | number of bins in each directions
69 |
70 | Returns
71 | -------
72 | torch.Tensor
73 | linear bin index
74 | """
75 | scaled_pos = torch.linalg.solve(cell.t(), pos.t()).t()
76 | bin_index_s = torch.floor(scaled_pos * nbins_s).to(torch.long)
77 | bin_index_l = ravel_3d(bin_index_s, nbins_s)
78 | return bin_index_l
79 |
80 | def scatter_bin_index(
81 | nbins: int,
82 | max_n_atom_per_bin: int,
83 | n_images: int,
84 | bin_index: torch.Tensor,
85 | ):
86 | """convert the linear table `bin_index` into the table `bin_id`. Empty entries in `bin_id` are set to `n_images` so that they can be removed later.
87 |
88 | Parameters
89 | ----------
90 | nbins : _type_
91 | total number of bins
92 | max_n_atom_per_bin : _type_
93 | maximum number of atoms per bin
94 | n_images : _type_
95 | total number of atoms counting the pbc replicas
96 | bin_index : _type_
97 | map relating `atom_index` to the `bin_index` that it belongs to such that `bin_index[atom_index] -> bin_index`.
98 |
99 | Returns
100 | -------
101 | bin_id : torch.Tensor [nbins, max_n_atom_per_bin]
102 | relate `bin_index` (row) with the `atom_index` (stored in the columns).
103 | """
104 | device = bin_index.device
105 | sorted_bin_index, sorted_id = torch.sort(bin_index)
106 | bin_id = torch.full(
107 | (nbins * max_n_atom_per_bin,), n_images, device=device, dtype=torch.long
108 | )
109 | sorted_bin_id = torch.remainder(
110 | torch.arange(bin_index.shape[0], device=device), max_n_atom_per_bin
111 | )
112 | sorted_bin_id = sorted_bin_index * max_n_atom_per_bin + sorted_bin_id
113 | bin_id.scatter_(dim=0, index=sorted_bin_id, src=sorted_id)
114 | bin_id = bin_id.view((nbins, max_n_atom_per_bin))
115 | return bin_id
116 |
117 |
118 | def linked_cell(
119 | pos: torch.Tensor,
120 | cell: torch.Tensor,
121 | cutoff: float,
122 | num_repeats: torch.Tensor,
123 | self_interaction: bool = False,
124 | ) -> Tuple[torch.Tensor, torch.Tensor]:
125 | """Determine the atomic neighborhood of the atoms of a given structure for a particular cutoff using the linked cell algorithm.
126 |
127 | Parameters
128 | ----------
129 | pos : torch.Tensor [n_atom, 3]
130 | atomic positions in the unit cell (positions outside the cell boundaries will result in an undifined behaviour)
131 | cell : torch.Tensor [3, 3]
132 | unit cell vectors in the format V=[v_0, v_1, v_2]
133 | cutoff : float
134 | length used to determine neighborhood
135 | num_repeats : torch.Tensor [3]
136 | number of unit cell repetitions in each directions required to account for PBC
137 | self_interaction : bool, optional
138 | to keep the original atoms as their own neighbor, by default False
139 |
140 | Returns
141 | -------
142 | Tuple[torch.Tensor, torch.Tensor]
143 | neigh_atom : [2, n_neighbors]
144 | indices of the original atoms (neigh_atom[0]) with their neighbor index (neigh_atom[1]). The indices are meant to access the provided position array
145 | neigh_shift_idx : [n_neighbors, 3]
146 | cell shift indices to be used in reconstructing the neighbor atom positions.
147 | """
148 | device = pos.device
149 | dtype = pos.dtype
150 | n_atom = pos.shape[0]
151 | # find all the integer shifts of the unit cell given the cutoff and periodicity
152 | shifts_idx = get_cell_shift_idx(num_repeats, dtype)
153 | n_cell_image = shifts_idx.shape[0]
154 | shifts_idx = torch.repeat_interleave(
155 | shifts_idx, n_atom, dim=0, output_size=n_atom * n_cell_image
156 | )
157 | batch_image = torch.zeros((shifts_idx.shape[0]), dtype=torch.long)
158 | cell_shifts = compute_cell_shifts(
159 | cell.view(-1, 3, 3), shifts_idx, batch_image
160 | )
161 |
162 | i_ids = torch.arange(n_atom, device=device, dtype=torch.long)
163 | i_ids = i_ids.repeat(n_cell_image)
164 | # compute the positions of the replicated unit cell (including the original)
165 | # they are organized such that: 1st n_atom are the non-shifted atom, 2nd n_atom are moved by the same translation, ...
166 | images = pos[i_ids] + cell_shifts
167 | n_images = images.shape[0]
168 | # create a rectangular box at [0,0,0] that encompases all the atoms (hence shifting the atoms so that they lie inside the box)
169 | b_min = images.min(dim=0).values
170 | b_max = images.max(dim=0).values
171 | images -= b_min - 1e-5
172 | box_length = b_max - b_min + 1e-3
173 | # divide the box into square bins of size cutoff in 3d
174 | nbins_s = torch.maximum(torch.ceil(box_length / cutoff), pos.new_ones(3))
175 | # adapt the box lenghts so that it encompasses
176 | box_vec = torch.diag_embed(nbins_s * cutoff)
177 | nbins_s = nbins_s.to(torch.long)
178 | nbins = int(torch.prod(nbins_s))
179 | # determine which bins the original atoms and the images belong to following a linear indexing of the 3d bins
180 | bin_index_j = get_linear_bin_idx(box_vec, images, nbins_s)
181 | n_atom_j_per_bin = torch.bincount(bin_index_j, minlength=nbins)
182 | max_n_atom_per_bin = int(n_atom_j_per_bin.max())
183 | # convert the linear map bin_index_j into a 2d map. This allows for
184 | # fully vectorized neighbor assignment
185 | bin_id_j = scatter_bin_index(
186 | nbins, max_n_atom_per_bin, n_images, bin_index_j
187 | )
188 |
189 | # find which bins the original atoms belong to
190 | bin_index_i = bin_index_j[:n_atom]
191 | i_bins_l = torch.unique(bin_index_i)
192 | i_bins_s = unravel_3d(i_bins_l, nbins_s)
193 |
194 | # find the bin indices in the neighborhood of i_bins_l. Since the bins have
195 | # a side length of cutoff only 27 bins are in the neighborhood
196 | # (including itself)
197 | dd = torch.tensor([0, 1, -1], dtype=torch.long, device=device)
198 | bin_shifts = torch.cartesian_prod(dd, dd, dd)
199 | n_neigh_bins = bin_shifts.shape[0]
200 | bin_shifts = bin_shifts.repeat((i_bins_s.shape[0], 1))
201 | neigh_bins_s = (
202 | torch.repeat_interleave(
203 | i_bins_s,
204 | n_neigh_bins,
205 | dim=0,
206 | output_size=n_neigh_bins * i_bins_s.shape[0],
207 | )
208 | + bin_shifts
209 | )
210 | # some of the generated bin_idx might not be valid
211 | mask = torch.all(
212 | torch.logical_and(neigh_bins_s < nbins_s.view(1, 3), neigh_bins_s >= 0),
213 | dim=1,
214 | )
215 |
216 | # remove the bins that are outside of the search range, i.e. beyond the borders of the box in the case of non-periodic directions.
217 | neigh_j_bins_l = ravel_3d(neigh_bins_s[mask], nbins_s)
218 |
219 | max_neigh_per_atom = max_n_atom_per_bin * n_neigh_bins
220 | # the i_bin related to neigh_j_bins_l
221 | repeats = mask.view(-1, n_neigh_bins).sum(dim=1)
222 | neigh_i_bins_l = torch.cat(
223 | [
224 | torch.arange(rr, device=device) + i_bins_l[ii] * n_neigh_bins
225 | for ii, rr in enumerate(repeats)
226 | ],
227 | dim=0,
228 | )
229 | # the linear neighborlist. make it at large as necessary
230 | neigh_atom = torch.empty(
231 | (2, n_atom * max_neigh_per_atom), dtype=torch.long, device=device
232 | )
233 | # fill the i_atom index
234 | neigh_atom[0] = (
235 | torch.arange(n_atom).view(-1, 1).repeat(1, max_neigh_per_atom).view(-1)
236 | )
237 | # relate `bin_index` (row) with the `neighbor_atom_index` (stored in the columns). empty entries are set to `n_images`
238 | bin_id_ij = torch.full(
239 | (nbins * n_neigh_bins, max_n_atom_per_bin),
240 | n_images,
241 | dtype=torch.long,
242 | device=device,
243 | )
244 | # fill the bins with neighbor atom indices
245 | bin_id_ij[neigh_i_bins_l] = bin_id_j[neigh_j_bins_l]
246 | bin_id_ij = bin_id_ij.view((nbins, max_neigh_per_atom))
247 | # map the neighbors in the bins to the central atoms
248 | neigh_atom[1] = bin_id_ij[bin_index_i].view(-1)
249 | # remove empty entries
250 | neigh_atom = neigh_atom[:, neigh_atom[1] != n_images]
251 |
252 | if not self_interaction:
253 | # neighbor atoms are still indexed from 0 to n_atom*n_cell_image
254 | neigh_atom = neigh_atom[:, neigh_atom[0] != neigh_atom[1]]
255 |
256 | # sort neighbor list so that the i_atom indices increase
257 | sorted_ids = torch.argsort(neigh_atom[0])
258 | neigh_atom = neigh_atom[:, sorted_ids]
259 | # get the cell shift indices for each neighbor atom
260 | neigh_shift_idx = shifts_idx[neigh_atom[1]]
261 | # make sure the j_atom indices access the original positions
262 | neigh_atom[1] = torch.remainder(neigh_atom[1], n_atom)
263 | # print(neigh_atom)
264 | return neigh_atom, neigh_shift_idx
265 |
266 |
267 | def build_linked_cell_neighborhood(
268 | positions: torch.Tensor,
269 | cell: torch.Tensor,
270 | pbc: torch.Tensor,
271 | cutoff: float,
272 | n_atoms: torch.Tensor,
273 | self_interaction: bool = False,
274 | ) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
275 | """Build the neighborlist of a given set of atomic structures using the linked cell algorithm.
276 |
277 | Parameters
278 | ----------
279 | positions : torch.Tensor [-1, 3]
280 | set of atomic positions for each structures
281 | cell : torch.Tensor [3*n_structure, 3]
282 | set of unit cell vectors for each structures
283 | pbc : torch.Tensor [n_structures, 3] bool
284 | periodic boundary conditions to apply
285 | cutoff : float
286 | length used to determine neighborhood
287 | n_atoms : torch.Tensor
288 | number of atoms in each structures
289 | self_interaction : bool
290 | to keep the original atoms as their own neighbor
291 |
292 | Returns
293 | -------
294 | Tuple[torch.Tensor, torch.Tensor, torch.Tensor]
295 | mapping : [2, n_neighbors]
296 | indices of the neighbor list for the given positions array, mapping[0/1] correspond respectively to the central/neighbor atom (or node in the graph terminology)
297 | batch_mapping : [n_neighbors]
298 | indices mapping the neighbor atom to each structures
299 | cell_shifts_idx : [n_neighbors, 3]
300 | cell shift indices to be used in reconstructing the neighbor atom positions.
301 | """
302 |
303 | n_structure = n_atoms.shape[0]
304 | device = positions.device
305 | cell = cell.view((-1, 3, 3))
306 | pbc = pbc.view((-1, 3))
307 | # compute the number of cell replica necessary so that all the unit cell's atom have a complete neighborhood (no MIC assumed here)
308 | num_repeats = get_number_of_cell_repeats(cutoff, cell, pbc)
309 |
310 | stride = strides_of(n_atoms)
311 |
312 | mapping, batch_mapping, cell_shifts_idx = [], [], []
313 | for i_structure in range(n_structure):
314 | # compute the neighborhood with the linked cell algorithm
315 | neigh_atom, neigh_shift_idx = linked_cell(
316 | positions[stride[i_structure] : stride[i_structure + 1]],
317 | cell[i_structure],
318 | cutoff,
319 | num_repeats[i_structure],
320 | self_interaction,
321 | )
322 |
323 | batch_mapping.append(
324 | i_structure
325 | * torch.ones(neigh_atom.shape[1], dtype=torch.long, device=device)
326 | )
327 | # shift the mapping indices so that they can access positions
328 | mapping.append(neigh_atom + stride[i_structure])
329 | cell_shifts_idx.append(neigh_shift_idx)
330 | return (
331 | torch.cat(mapping, dim=1),
332 | torch.cat(batch_mapping, dim=0),
333 | torch.cat(cell_shifts_idx, dim=0),
334 | )
335 |
--------------------------------------------------------------------------------
/torch_nl/naive_impl.py:
--------------------------------------------------------------------------------
1 | import torch
2 | from typing import Tuple
3 |
4 | from .utils import get_number_of_cell_repeats, get_cell_shift_idx, strides_of
5 |
6 |
7 | def get_fully_connected_mapping(
8 | i_ids: torch.Tensor, shifts_idx: torch.Tensor, self_interaction: bool
9 | ) -> Tuple[torch.Tensor, torch.Tensor]:
10 | n_atom = i_ids.shape[0]
11 | n_atom2 = n_atom * n_atom
12 | n_cell_image = shifts_idx.shape[0]
13 | j_ids = torch.repeat_interleave(
14 | i_ids, n_cell_image, dim=0, output_size=n_cell_image * n_atom
15 | )
16 | mapping = torch.cartesian_prod(i_ids, j_ids)
17 | shifts_idx = shifts_idx.repeat((n_atom2, 1))
18 | if not self_interaction:
19 | mask = torch.ones(
20 | mapping.shape[0], dtype=torch.bool, device=i_ids.device
21 | )
22 | ids = n_cell_image * torch.arange(
23 | n_atom, device=i_ids.device
24 | ) + torch.arange(
25 | 0, mapping.shape[0], n_atom * n_cell_image, device=i_ids.device
26 | )
27 | mask[ids] = False
28 | mapping = mapping[mask, :]
29 | shifts_idx = shifts_idx[mask]
30 | return mapping, shifts_idx
31 |
32 |
33 | def build_naive_neighborhood(
34 | positions: torch.Tensor,
35 | cell: torch.Tensor,
36 | pbc: torch.Tensor,
37 | cutoff: float,
38 | n_atoms: torch.Tensor,
39 | self_interaction: bool,
40 | ) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
41 | """TODO: add doc"""
42 | device = positions.device
43 | dtype = positions.dtype
44 |
45 | num_repeats_ = get_number_of_cell_repeats(cutoff, cell, pbc)
46 |
47 | stride = strides_of(n_atoms)
48 | ids = torch.arange(positions.shape[0], device=device, dtype=torch.long)
49 |
50 | mapping, batch_mapping, shifts_idx_ = [], [], []
51 | for i_structure in range(n_atoms.shape[0]):
52 | num_repeats = num_repeats_[i_structure]
53 | shifts_idx = get_cell_shift_idx(num_repeats, dtype)
54 | i_ids = ids[stride[i_structure] : stride[i_structure + 1]]
55 |
56 | s_mapping, shifts_idx = get_fully_connected_mapping(
57 | i_ids, shifts_idx, self_interaction
58 | )
59 | mapping.append(s_mapping)
60 | batch_mapping.append(
61 | torch.full(
62 | (s_mapping.shape[0],),
63 | i_structure,
64 | dtype=torch.long,
65 | device=device,
66 | )
67 | )
68 | shifts_idx_.append(shifts_idx)
69 | return (
70 | torch.cat(mapping, dim=0).t(),
71 | torch.cat(batch_mapping, dim=0),
72 | torch.cat(shifts_idx_, dim=0),
73 | )
74 |
--------------------------------------------------------------------------------
/torch_nl/neighbor_list.py:
--------------------------------------------------------------------------------
1 | import torch
2 |
3 | from .naive_impl import build_naive_neighborhood
4 | from .geometry import compute_cell_shifts
5 | from .linked_cell import build_linked_cell_neighborhood
6 |
7 |
8 | def strict_nl(
9 | cutoff: float,
10 | pos: torch.Tensor,
11 | cell: torch.Tensor,
12 | mapping: torch.Tensor,
13 | batch_mapping: torch.Tensor,
14 | shifts_idx: torch.Tensor,
15 | ):
16 | """Apply a strict cutoff to the neighbor list defined in mapping.
17 |
18 | Parameters
19 | ----------
20 | cutoff : _type_
21 | _description_
22 | pos : _type_
23 | _description_
24 | cell : _type_
25 | _description_
26 | mapping : _type_
27 | _description_
28 | batch_mapping : _type_
29 | _description_
30 | shifts_idx : _type_
31 | _description_
32 |
33 | Returns
34 | -------
35 | _type_
36 | _description_
37 | """
38 | cell_shifts = compute_cell_shifts(cell, shifts_idx, batch_mapping)
39 | if cell_shifts is None:
40 | d2 = (pos[mapping[0]] - pos[mapping[1]]).square().sum(dim=1)
41 | else:
42 | d2 = (
43 | (pos[mapping[0]] - pos[mapping[1]] - cell_shifts)
44 | .square()
45 | .sum(dim=1)
46 | )
47 |
48 | mask = d2 < cutoff * cutoff
49 | mapping = mapping[:, mask]
50 | mapping_batch = batch_mapping[mask]
51 | shifts_idx = shifts_idx[mask]
52 | return mapping, mapping_batch, shifts_idx
53 |
54 |
55 | @torch.jit.script
56 | def compute_neighborlist_n2(
57 | cutoff: float,
58 | pos: torch.Tensor,
59 | cell: torch.Tensor,
60 | pbc: torch.Tensor,
61 | batch: torch.Tensor,
62 | self_interaction: bool = False,
63 | ):
64 | """Compute the neighborlist for a set of atomic structures using the naive a neighbor search before applying a strict `cutoff`. The atoms positions
65 | `pos` should be wrapped inside their respective unit cells.
66 |
67 | Parameters
68 | ----------
69 | cutoff : float
70 | cutoff radius of used for the neighbor search
71 | pos : torch.Tensor [n_atom, 3]
72 | set of atoms positions wrapped inside their respective unit cells
73 | cell : torch.Tensor [3*n_structure, 3]
74 | unit cell vectors in the format [a_1, a_2, a_3]
75 | pbc : torch.Tensor [n_structure, 3] bool
76 | periodic boundary conditions to apply. Partial PBC are not supported yet
77 | batch : torch.Tensor torch.long [n_atom,]
78 | index of the structure in which the atom belongs to
79 | self_interaction : bool, optional
80 | to keep the center atoms as their own neighbor, by default False
81 |
82 | Returns
83 | -------
84 | Tuple[torch.Tensor, torch.Tensor, torch.Tensor]
85 | mapping : [2, n_neighbors]
86 | indices of the neighbor list for the given positions array, mapping[0/1] correspond respectively to the central/neighbor atom (or node in the graph terminology)
87 | batch_mapping : [n_neighbors]
88 | indices mapping the neighbor atom to each structures
89 | shifts_idx : [n_neighbors, 3]
90 | cell shift indices to be used in reconstructing the neighbor atom positions.
91 | """
92 | n_atoms = torch.bincount(batch)
93 | mapping, batch_mapping, shifts_idx = build_naive_neighborhood(
94 | pos, cell, pbc, cutoff, n_atoms, self_interaction
95 | )
96 | mapping, mapping_batch, shifts_idx = strict_nl(
97 | cutoff, pos, cell, mapping, batch_mapping, shifts_idx
98 | )
99 | return mapping, mapping_batch, shifts_idx
100 |
101 |
102 | @torch.jit.script
103 | def compute_neighborlist(
104 | cutoff: float,
105 | pos: torch.Tensor,
106 | cell: torch.Tensor,
107 | pbc: torch.Tensor,
108 | batch: torch.Tensor,
109 | self_interaction: bool = False,
110 | ):
111 | """Compute the neighborlist for a set of atomic structures using the linked
112 | cell algorithm before applying a strict `cutoff`. The atoms positions `pos`
113 | should be wrapped inside their respective unit cells.
114 |
115 | Parameters
116 | ----------
117 | cutoff : float
118 | cutoff radius of used for the neighbor search
119 | pos : torch.Tensor [n_atom, 3]
120 | set of atoms positions wrapped inside their respective unit cells
121 | cell : torch.Tensor [3*n_structure, 3]
122 | unit cell vectors in the format [a_1, a_2, a_3]
123 | pbc : torch.Tensor [n_structure, 3] bool
124 | periodic boundary conditions to apply. Partial PBC are not supported yet
125 | batch : torch.Tensor torch.long [n_atom,]
126 | index of the structure in which the atom belongs to
127 | self_interaction : bool, optional
128 | to keep the center atoms as their own neighbor, by default False
129 |
130 | Returns
131 | -------
132 | Tuple[torch.Tensor, torch.Tensor, torch.Tensor]
133 | mapping : [2, n_neighbors]
134 | indices of the neighbor list for the given positions array, mapping[0/1] correspond respectively to the central/neighbor atom (or node in the graph terminology)
135 | batch_mapping : [n_neighbors]
136 | indices mapping the neighbor atom to each structures
137 | shifts_idx : [n_neighbors, 3]
138 | cell shift indices to be used in reconstructing the neighbor atom positions.
139 | """
140 | n_atoms = torch.bincount(batch)
141 | mapping, batch_mapping, shifts_idx = build_linked_cell_neighborhood(
142 | pos, cell, pbc, cutoff, n_atoms, self_interaction
143 | )
144 |
145 | mapping, mapping_batch, shifts_idx = strict_nl(
146 | cutoff, pos, cell, mapping, batch_mapping, shifts_idx
147 | )
148 | return mapping, mapping_batch, shifts_idx
149 |
--------------------------------------------------------------------------------
/torch_nl/test_nl.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | from ase.build import bulk, molecule
3 | import numpy as np
4 | from ase.neighborlist import neighbor_list
5 | from ase import Atoms
6 | import torch
7 |
8 | from .neighbor_list import (
9 | compute_neighborlist_n2,
10 | compute_cell_shifts,
11 | compute_neighborlist,
12 | )
13 | from .utils import ase2data
14 | from .geometry import compute_distances
15 |
16 | # triclinic atomic structure
17 | CaCrP2O7_mvc_11955_symmetrized = {
18 | "positions": [
19 | [3.68954016, 5.03568186, 4.64369552],
20 | [5.12301681, 2.13482791, 2.66220405],
21 | [1.99411973, 0.94691001, 1.25068234],
22 | [6.81843724, 6.22359976, 6.05521724],
23 | [2.63005662, 4.16863452, 0.86090529],
24 | [6.18250036, 3.00187525, 6.44499428],
25 | [2.11497733, 1.98032773, 4.53610884],
26 | [6.69757964, 5.19018203, 2.76979073],
27 | [1.39215545, 2.94386142, 5.60917746],
28 | [7.42040152, 4.22664834, 1.69672212],
29 | [2.43224207, 5.4571615, 6.70305327],
30 | [6.3803149, 1.71334827, 0.6028463],
31 | [1.11265639, 1.50166318, 3.48760997],
32 | [7.69990058, 5.66884659, 3.8182896],
33 | [3.56971588, 5.20836551, 1.43673437],
34 | [5.2428411, 1.96214426, 5.8691652],
35 | [3.12282634, 2.72812741, 1.05450432],
36 | [5.68973063, 4.44238236, 6.25139525],
37 | [3.24868468, 2.83997522, 3.99842386],
38 | [5.56387229, 4.33053455, 3.30747571],
39 | [2.60835346, 0.74421609, 5.3236629],
40 | [6.20420351, 6.42629368, 1.98223667],
41 | ],
42 | "cell": [
43 | [6.19330899, 0.0, 0.0],
44 | [2.4074486111396207, 6.149627748674982, 0.0],
45 | [0.2117993724186579, 1.0208820183960539, 7.305899571570074],
46 | ],
47 | "numbers": [
48 | 20,
49 | 20,
50 | 24,
51 | 24,
52 | 15,
53 | 15,
54 | 15,
55 | 15,
56 | 8,
57 | 8,
58 | 8,
59 | 8,
60 | 8,
61 | 8,
62 | 8,
63 | 8,
64 | 8,
65 | 8,
66 | 8,
67 | 8,
68 | 8,
69 | 8,
70 | ],
71 | "pbc": [True, True, True],
72 | }
73 |
74 |
75 | def bulk_metal():
76 | frames = [
77 | bulk("Si", "diamond", a=6, cubic=True),
78 | bulk("Si", "diamond", a=6),
79 | bulk("Cu", "fcc", a=3.6),
80 | bulk("Si", "bct", a=6, c=3),
81 | # test very skewed unit cell
82 | bulk("Bi", "rhombohedral", a=6, alpha=20),
83 | bulk("Bi", "rhombohedral", a=6, alpha=10),
84 | bulk("Bi", "rhombohedral", a=6, alpha=5),
85 | bulk("SiCu", "rocksalt", a=6),
86 | bulk("SiFCu", "fluorite", a=6),
87 | Atoms(**CaCrP2O7_mvc_11955_symmetrized),
88 | ]
89 | return frames
90 |
91 |
92 | def atomic_structures():
93 | frames = (
94 | [
95 | molecule("CH3CH2NH2"),
96 | molecule("H2O"),
97 | molecule("methylenecyclopropane"),
98 | ]
99 | + bulk_metal()
100 | + [
101 | molecule("OCHCHO"),
102 | molecule("C3H9C"),
103 | ]
104 | )
105 | return frames
106 |
107 |
108 | @pytest.mark.parametrize(
109 | "frames, cutoff, self_interaction",
110 | [
111 | (atomic_structures(), rc, self_interaction)
112 | for rc in [1, 3, 5, 7]
113 | for self_interaction in [True, False]
114 | ],
115 | )
116 | def test_neighborlist_n2(frames, cutoff, self_interaction):
117 | """Check that torch_neighbor_list gives the same NL as ASE by comparing
118 | the resulting sorted list of distances between neighbors."""
119 | pos, cell, pbc, batch, n_atoms = ase2data(frames)
120 |
121 | dds = []
122 | mapping, batch_mapping, shifts_idx = compute_neighborlist_n2(
123 | cutoff, pos, cell, pbc, batch, self_interaction
124 | )
125 | cell_shifts = compute_cell_shifts(cell, shifts_idx, batch_mapping)
126 | dds = compute_distances(pos, mapping, cell_shifts)
127 | dds = np.sort(dds.numpy())
128 |
129 | dd_ref = []
130 | for frame in frames:
131 | idx_i, idx_j, idx_S, dist = neighbor_list(
132 | "ijSd", frame, cutoff=cutoff, self_interaction=self_interaction
133 | )
134 | dd_ref.extend(dist)
135 | dd_ref = np.sort(dd_ref)
136 |
137 | np.testing.assert_allclose(dd_ref, dds)
138 |
139 |
140 | @pytest.mark.parametrize(
141 | "frames, cutoff, self_interaction",
142 | [
143 | (atomic_structures(), rc, self_interaction)
144 | # for rc in [3] #[1, 3, 5, 7]
145 | # for self_interaction in [False]
146 | for rc in [1, 3, 5, 7]
147 | for self_interaction in [False, True]
148 | ],
149 | )
150 | def test_neighborlist_linked_cell(frames, cutoff, self_interaction):
151 | """Check that torch_neighbor_list gives the same NL as ASE by comparing
152 | the resulting sorted list of distances between neighbors."""
153 | pos, cell, pbc, batch, n_atoms = ase2data(frames)
154 |
155 | dds = []
156 | mapping, batch_mapping, shifts_idx = compute_neighborlist(
157 | cutoff, pos, cell, pbc, batch, self_interaction
158 | )
159 | cell_shifts = compute_cell_shifts(cell, shifts_idx, batch_mapping)
160 | dds = compute_distances(pos, mapping, cell_shifts)
161 | dds = np.sort(dds.numpy())
162 |
163 | dd_ref = []
164 | for frame in frames:
165 | idx_i, idx_j, idx_S, dist = neighbor_list(
166 | "ijSd", frame, cutoff=cutoff, self_interaction=self_interaction
167 | )
168 | dd_ref.extend(dist)
169 | # nice for understanding if something goes wrong
170 | idx_S = torch.from_numpy(idx_S).to(torch.float64)
171 |
172 | print("idx_i", idx_i)
173 | print("idx_j", idx_j)
174 | missing_entries = []
175 | for ineigh in range(idx_i.shape[0]):
176 | mask = torch.logical_and(
177 | idx_i[ineigh] == mapping[0], idx_j[ineigh] == mapping[1]
178 | )
179 |
180 | if torch.any(torch.all(idx_S[ineigh] == shifts_idx[mask], dim=1)):
181 | pass
182 | else:
183 | missing_entries.append(
184 | (idx_i[ineigh], idx_j[ineigh], idx_S[ineigh])
185 | )
186 | print(missing_entries[-1])
187 | print(
188 | compute_cell_shifts(
189 | cell,
190 | idx_S[ineigh].view((1, -1)),
191 | torch.tensor([0], dtype=torch.long),
192 | )
193 | )
194 |
195 | dd_ref = np.sort(dd_ref)
196 | print(dd_ref[-20:])
197 | print(dds[-20:])
198 | np.testing.assert_allclose(dd_ref, dds)
199 |
--------------------------------------------------------------------------------
/torch_nl/timer.py:
--------------------------------------------------------------------------------
1 | from timeit import default_timer as timer
2 | import numpy as np
3 | from typing import Mapping
4 | from types import GeneratorType
5 |
6 |
7 | def eval_func(func, inp):
8 | if isinstance(inp, Mapping):
9 | inner = lambda inp: func(**inp)
10 | elif isinstance(inp, GeneratorType):
11 | inner = lambda inp: func(*inp)
12 | else:
13 | inner = lambda inp: func(*inp)
14 | return inner
15 |
16 |
17 | def timeit(func, inp, tag="", warmup=10, nit=100):
18 | timer = Timer(tag=tag)
19 | inner = eval_func(func, inp)
20 |
21 | for _ in range(warmup):
22 | inner(inp)
23 | for _ in range(nit):
24 | with timer:
25 | inner(inp)
26 | return timer
27 |
28 |
29 | class Timer(object):
30 | def __init__(self, tag="", logger=None):
31 | self.tag = tag
32 | self.elapsed = []
33 | self.start = None
34 | self.end = None
35 |
36 | def __enter__(self):
37 | self.start = timer()
38 |
39 | def __exit__(self, type, value, traceback):
40 | self.end = timer()
41 | self.elapsed.append(self.end - self.start)
42 |
43 | def mean(self):
44 | return np.mean(self.elapsed)
45 |
46 | def stdev(self):
47 | return np.std(self.elapsed)
48 |
49 | def min(self):
50 | return np.min(self.elapsed)
51 |
52 | def max(self):
53 | return np.max(self.elapsed)
54 |
55 | def samples(self):
56 | return self.elapsed
57 |
58 | def dumps(self):
59 | data = dict(
60 | tag=self.tag,
61 | mean=self.mean(),
62 | stdev=self.stdev(),
63 | min=self.min(),
64 | max=self.max(),
65 | samples=self.samples(),
66 | )
67 | return data
68 |
69 | def __repr__(self) -> str:
70 | timings = self.dumps()
71 | return f'{timings["tag"]} ' + " / ".join(
72 | [
73 | f"{k}={timings[k]*1000:.5f} [ms]"
74 | for k in ["mean", "stdev", "min", "max"]
75 | ]
76 | )
77 |
--------------------------------------------------------------------------------
/torch_nl/utils.py:
--------------------------------------------------------------------------------
1 | import torch
2 | import numpy as np
3 | from torch.types import _dtype
4 |
5 |
6 | def ase2data(frames, device="cpu"):
7 | n_atoms = [0]
8 | pos = []
9 | cell = []
10 | pbc = []
11 | for ff in frames:
12 | n_atoms.append(len(ff))
13 | pos.append(torch.from_numpy(ff.get_positions()))
14 | cell.append(torch.from_numpy(ff.get_cell().array))
15 | pbc.append(torch.from_numpy(ff.get_pbc()))
16 | pos = torch.cat(pos)
17 | cell = torch.cat(cell)
18 | pbc = torch.cat(pbc)
19 | stride = torch.from_numpy(np.cumsum(n_atoms))
20 | batch = torch.zeros(pos.shape[0], dtype=torch.long)
21 | for ii, (st, nd) in enumerate(zip(stride[:-1], stride[1:])):
22 | batch[st:nd] = ii
23 | n_atoms = torch.Tensor(n_atoms[1:]).to(dtype=torch.long)
24 | return (
25 | pos.to(device=device),
26 | cell.to(device=device),
27 | pbc.to(device=device),
28 | batch.to(device=device),
29 | n_atoms.to(device=device),
30 | )
31 |
32 |
33 | def strides_of(v: torch.Tensor) -> torch.Tensor:
34 | v = v.flatten()
35 | stride = v.new_empty(v.shape[0] + 1)
36 | stride[0] = 0
37 | torch.cumsum(v, dim=0, dtype=stride.dtype, out=stride[1:])
38 | return stride
39 |
40 |
41 | def get_number_of_cell_repeats(
42 | cutoff: float, cell: torch.Tensor, pbc: torch.Tensor
43 | ) -> torch.Tensor:
44 | cell = cell.view((-1, 3, 3))
45 | pbc = pbc.view((-1, 3))
46 |
47 | has_pbc = pbc.prod(dim=1, dtype=torch.bool)
48 | reciprocal_cell = torch.zeros_like(cell)
49 | reciprocal_cell[has_pbc, :, :] = torch.linalg.inv(
50 | cell[has_pbc, :, :]
51 | ).transpose(2, 1)
52 | inv_distances = reciprocal_cell.norm(2, dim=-1)
53 | num_repeats = torch.ceil(cutoff * inv_distances).to(torch.long)
54 | num_repeats_ = torch.where(pbc, num_repeats, torch.zeros_like(num_repeats))
55 | return num_repeats_
56 |
57 |
58 | def get_cell_shift_idx(
59 | num_repeats: torch.Tensor, dtype: _dtype
60 | ) -> torch.Tensor:
61 | reps = []
62 | for ii in range(3):
63 | r1 = torch.arange(
64 | -num_repeats[ii],
65 | num_repeats[ii] + 1,
66 | device=num_repeats.device,
67 | dtype=dtype,
68 | )
69 | _, indices = torch.sort(torch.abs(r1))
70 | reps.append(r1[indices])
71 | shifts_idx = torch.cartesian_prod(reps[0], reps[1], reps[2])
72 | return shifts_idx
73 |
--------------------------------------------------------------------------------