├── .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 | ![](benchmark/diamond_benchmark.png) 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\n", 300 | "\n", 313 | "\n", 314 | " \n", 315 | " \n", 316 | " \n", 317 | " \n", 318 | " \n", 319 | " \n", 320 | " \n", 321 | " \n", 322 | " \n", 323 | " \n", 324 | " \n", 325 | " \n", 326 | " \n", 327 | " \n", 328 | " \n", 329 | " \n", 330 | " \n", 331 | " \n", 332 | " \n", 333 | " \n", 334 | " \n", 335 | " \n", 336 | " \n", 337 | " \n", 338 | " \n", 339 | " \n", 340 | " \n", 341 | " \n", 342 | " \n", 343 | " \n", 344 | " \n", 345 | " \n", 346 | " \n", 347 | " \n", 348 | " \n", 349 | " \n", 350 | " \n", 351 | " \n", 352 | " \n", 353 | " \n", 354 | " \n", 355 | " \n", 356 | " \n", 357 | " \n", 358 | " \n", 359 | " \n", 360 | " \n", 361 | " \n", 362 | " \n", 363 | " \n", 364 | " \n", 365 | " \n", 366 | " \n", 367 | " \n", 368 | " \n", 369 | " \n", 370 | " \n", 371 | " \n", 372 | " \n", 373 | " \n", 374 | " \n", 375 | " \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 | " \n", 389 | " \n", 390 | " \n", 391 | " \n", 392 | " \n", 393 | " \n", 394 | " \n", 395 | " \n", 396 | " \n", 397 | " \n", 398 | " \n", 399 | " \n", 400 | " \n", 401 | " \n", 402 | " \n", 403 | " \n", 404 | " \n", 405 | " \n", 406 | " \n", 407 | " \n", 408 | " \n", 409 | " \n", 410 | " \n", 411 | " \n", 412 | " \n", 413 | " \n", 414 | " \n", 415 | " \n", 416 | " \n", 417 | " \n", 418 | " \n", 419 | " \n", 420 | " \n", 421 | " \n", 422 | " \n", 423 | " \n", 424 | " \n", 425 | " \n", 426 | " \n", 427 | " \n", 428 | " \n", 429 | " \n", 430 | " \n", 431 | " \n", 432 | " \n", 433 | " \n", 434 | " \n", 435 | " \n", 436 | " \n", 437 | " \n", 438 | "
tagmeanstdevminmaxn_atomn_neighbor_per_atom_avg
0ASE0.0024980.0000410.0024390.002685828
1ASE0.0031270.0000200.0031010.0031681628
2ASE0.0031620.0000630.0031010.0033741628
3ASE0.0031450.0000430.0031000.0032971628
4ASE0.0044290.0000500.0043740.0045773228
........................
495torch_nl O(n) GPU0.0050460.0001900.0049300.00634860029
496torch_nl O(n) GPU0.0059240.0000940.0058090.00647680029
497torch_nl O(n) GPU0.0058860.0000380.0057950.00594380029
498torch_nl O(n) GPU0.0059130.0000680.0058030.00629280029
499torch_nl O(n) GPU0.0068390.0006310.0066510.011249100029
\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+6Ne3npE5J4givBp3erWFalGefrpp7F48WJs3LjxlkdklrRs2TI8+eSTDDwrwOvfjsXFxbms7TQajUhOTi4z+LRfK9n21KlTEATBZY3De++9h3nz5uG9997DI488UkW9J7q1hKu5SE7PR7BO7TYHYJBOheT0/Grd7PNQ6wjcKqwVbtQjIqpK9iTppX15eQIWgC3N08qVK11OLroVSZLQvHlzLFiwoJp6dmfyevA5cOBA7Ny50+n4rA0bNsBoNLokuy0uKioKsbGxTic2AMCXX36Jzp07Oy36Xbt2LaZNm4YFCxbU6EXSdGfKMphgtspQK9z/cdMoRJglGVkGU7X1oW2DIDQJ05VZp0mYDm0bBFVbH4jo7vSXv/zFKYl6ya+9e/d6u4sAgH79+rkk478VURQxa9ascp/iRDZen2ObMmUKli9fjiFDhmD27NlIT0/HjBkzMHbsWKeRz4kTJ2LVqlVOqQQWLFiAUaNGITo6Gv369cOmTZuwY8cObN++3VFn7969ePLJJ3H//fejX79+Tuki6tSpg+joaM+8KN21QnRqqBQCTFYJWlHhct1olaASBYTo3CeorgqiKOCNR+Px3Be/Q28wo/g4gwAgSKfCG4/GM8cnEVW5efPm4fnnny/1eosWLTzYG6oJvB58BgUFYdeuXZg2bRqGDRsGnU6H0aNHu+zCs1qtLhn8R4wYAYPBgIULF2Lp0qVo1qwZ1q1b50gwDwC7d++G2Wx2nGpUnH0nIFF1alUvANHhfkhMzUNEgOg09S7LMvQGM+Ii/dGqXkC19qNrszC8O6YDlu/8E39czoXFKkOpENCuQQCmPdCi2nfcE9HdqUmTJmjSpIm3u0E1iNd3u9dG9g1H3O1O5XVzt7sVQToVNAoRRqsEvYd2u9ut+CkZ7+5ORm6RGbIMCAIQoFXhud7RmNSDswBERFT9GHxWAoNPqgynPJ+SDJXouTyfgC3wXLL9T1gl24inKNhON7JYZShEAa8MaMEAlIiIqh2Dz0pg8EmVJUkyEq7mIstgQohOjVb1AjyyztJikdBx4Y/IMZihVgoQhZubnyRZgskiI1CnwuFZfaFUen0fIhER3cG8vuaT6G4iigLiG1Ts9Iqq8P3xVOQVmm+MeDoHl6IgQqmQkFdoxvfHUzG0Q/2bFyUJSDsGGDIBXSgQ0RZgwmwiIroNDD6J7gJX9AZIAJSlDLKKAmC9Uc/h3F7gl2VAxhlAMgOiCghrDnR/CWja0xPdJiKiOxCHMIhqAEmSceJyDvaevo4Tl3MgSVW7GqZ+kA4ibGs83T5ftqVcqh90Ixfoub3A5unAtQRA7Qv41bX9eC3BVn6uZuTlIyKi2ocjn0Re5rQRySpDpaj6jUiD20Ri/uYE5BjMEAXJZc2nxWpb8zm4TaRtqv2XZYAxH/CPtG2JBwCVD6DUAnmptutN7ucUPBERVRj/5SDyInsKpsTUXPhqlAj318BXo0Riah5mbTiBfWczquQ5SqWI53pFQyEKMFlkWCTJFnRKts1GClHAc72ibZuN0o7Zptp9gm8GnnaCYCvPOGOrR0REVEEc+STyIItFwvfHU3FFb0BkoA++/f0y8o0WRARoHcnntaICEQEi0nKNeH9vMro0Da2SHfH2NErv7klGXqEZVtim2gN1KjzXq1ieT0OmbY2nUuP+RkoNUKS31SMiIqogBp9EHrLip2RH4CcBEGRAFmzHbwolRhgFQUCQToXk9HwkXM2tsh3yk3pE46muUY4AuH6QDoPbRDqnV9KF2jYXWYy2qfaSLEbbdV1olfSJiIjuLgw+iUpRfJTSbZBWASUTvCsFwCLJkCQgs8AElUJEHX/nkUaNQkSOJCPLYKqK13FQKkXndEolRbS17Wq/lmBb41k8MJZloDAbqNvKVo+IiKiCGHwSuVFylFIEMH9zgvP0dDlZLBLe3ZMMqyQ7JXhXijKskgQAuJ5vRJif8wio0SpBJQoI0amr6rXKRxRt6ZQ2T7dtLvIJtk21W4y2wFPjb7vOzUZERFQJ/NeDqAT7KGWOwQxRFKBWCBBFATkGM5Zs/xMrfkqu0P1KS/AuwJZfEwCskgx9odlxTZZl6A1mRIf7oVW9gKp4rYpp2hMY9JZthNNUAORfs/1YtxUwaBnzfBIRUaVx5JOomNJGKUUBEAXbzvB39yTjqa5R5Z6CLy3BuyAIUIoiTFbb6KfBZEGgVgWjVYLeYIafRoFne0Z75PhNt5r2tKVT4glHRERUhRh8EhVT6WMoy1A8wXvJOFIhClDIgFWyXU/PN0IlCoiL9K/SPJ+VJopAvfbe7QMREd1RGHxSrSdJMhKu5iLLYEKITo1W9QIqPVpYqWMob9GXWyV4lyQg0EeJleM7Icdoue13uCVjPvDl44D+MhDUABi9FtD4Vc+ziIiISmDwSbVaVZ8OVNYoJeDmGMpy9uW5XtFYsv1PmCwylAoJomC7l8VqS/A+tWdTpGQaHDvr4yL8qyf4fKcTkHH65md9CrCoPhAWAzz/W9U/j4iIqARBluWqPUT6LtC0aVMAwLlz57zck7ub/XSgvCIzlKIISZYhCgIskgR/rQoLh8ZXOAC1WCR0XPgjcgxmpzWfgG2U0mSxHUN5eFZfpzWf9r7kGy3wUSkgCDeyEpmt8NMosXBoPBKu5jh20MuwBbH+Pirc0ygIRy7qnXbW+/uoKrWzvkwlA8+SGIASEZEHcOSTaiVJkvH+3mRczytCoVmCVOxbKFEAiszWSp0OZD+GsqxRSscxlCX6km0wwWKVkVNohizb0mOqFSLMVgnv703Gqqc6uyR4v5ZThKX/c87/Kclw7KwHUDUBqDG/7MATsF035nMKnoiIqhW3rVKtlHA1F8cu6VFgcg48AVvwVmCScOySHglXcyt870k9ovHKgBYI1KkgSTLMVhmSZBvxfGVAC5dgMOFqLk5dzUWB0QqjRYIoCDc2LAkwWiQUGK04dTUXCVdzHQnen+/THIPbROKDn2/urFeKom1TkyhCrRRglWw76y0W6XZ+qmy+fLxq6xEREVUSRz6pVrqeV4S8Iovjc8lDeAAgr8iC63lFACp+NOWkHtEY36UJPvjpHC5kFaBxiC+e6dEUarXCpW5mvhG5RWbIsgyVUoQAwdEnQQGYLRJyi8zIzDc6tauOnfWl0l+u2npERESVxOCTaqWTV3JhH/AUXPJn2gJQ+Ua9PnF1K3x/181DmfjtQpbbjUzZBjMkSYYoCo7A09EX2BLUS5KMbIPZ6drt7qyvkKAGts1F5alHRERUjTjtTrVT8d+5JbfMyaXUKyf75qFTV3JQZLHCZLWiyGLFqas5mLXhBPadzXCqH+SrsgWYsoyS+/dkWbZthBIFBPmqnK4V31nvTlk76yts9NqqrUdERFRJDD6pVmoYpHOkQpLt/3fjyx7LiYKtXkXYNw+l5RQh02BGVoEZOYUWZBWYkVlgRlpOEd7fmwypWMQY5qtBgFYJURBglmRHECrJMsySbQd+gFaJMF+N07MGt4mEv48KFqsMSXZe1ynJEixWGf4+KgxuE1mhd3BL42fbzV6WsBhuNiIiomrH4JNqpcFtIhHgYxtJtM9aO6bhb/wY4CZwkyQZJy7nYO/p6zhxOccpiARsm4eOnM9CUSmbfIosEo6cz3LayNSqXgBa1guEj0oJrdKW8slyIwjVKkX4qJRoWS/Q5Yx2+856hSjAZJFhkSRb0CnZUjq521l/W57/rfQAlGmWiIjIQ7jmk2ql4imRrJIMhWhbXylDhlUClG4Ct/IkpL+WWwiDuezd5QazhGu5hYi/sZFJFAU82zP6Rs5RC4J91VAIAqyyjEKTFf5aZalntNt3ztvzf1phC54DddWQ5xOwBZg84YiIiLyISeYrgUnma44VPyW7TdxeMnArngQ+WKeGWiHCZJWQbTDDT6NwJKR/7dsTWHPo4i2fO7ZzI7wxLN6pzCm4lWSoxPKftmSxSE75Pwe3iay6EU8iIqIahCOfVKtN6hHtkri9ZOBmX8eZb7QgIkAL4cb2eK2oQESAiLRcoyMhfZHZUtqjnLir17VZGLo0Da3UOfP2/J9EVDZJlpCYlQh9kR5B2iDEhcS5pCojopqNwSfVercK3BKu5iI5PR/BOrUj8LQTBAFBOhWS0/ORcDUXjUPLN/1c3npEVHUOph7EJyc+QUpuCiySBUpRiaiAKEyMn4h7I+/1dveIqJwYfNIdL8tggtkqQ61wPzqiUYjIkWRkGUx4pkdT/GfXGVhKy38E23rSZ3o0dSkvz5pSIqqcg6kHsWD/AhSYCxCoCYRaoYbJasLp7NNYsH8B5tw3hwEoUS3BuQq644Xo1FApBJis7jcSGa0SVKKAEJ0aarUCozqWnWh9VMcGLicd2deUJqbmwlejRLi/Br4aJRJT89zmBiWi8pNkCZ+c+AQF5gKE68KhVWohCiK0Si3CdeEoMBfgkxOfuKQsI6KaicEn3fFa1QtAdLgfsg1mt0ng9QYzosP9HKmQ3hjWBmM7N4SyxFpNpShgbOeGeGNYG6fykmtKtSoFRFGAVqVARIAG+UarS25QIiq/xKxEpOSmIFAT6HbpTKAmECm5KUjMSvRSD4moIjjtTne84qmQ0nKNCNKpoFGIMFol6G/sdi+ZCumNYW0wd1Crcp3tXpE1pfENKn7OPNHdTl+kh0WyQK1Qu72uVqiRa8qFvkjv2Y4RUaUw+KS7QtdmYVg4NN6xJjPnRiqkuEj/UtdkqtUKvNC3+S3vXZE1pURUcUHaIChFJUxWE7RKrct1k9UEpahEkDbI850jogpj8El3jdtJhVSW4mtKtaLryGjxNaVEVHFxIXGICojC6ezT0Cg0TjMMsiwjx5iDmOAYxIXEebGXRFReXPNJdJsquqaUiCpGFERMjJ8IX5Uv0g3pKLIUQZIlFFmKkG5Ih6/KFxPjJzLfJ1EtwROOKoEnHNVO+85m4L09Z5GUlgezRYZKKSA2wh9TezW77VRIN09QsrpdU2o/QYmIKo95PonuDAw+K4HBZ+2z72wGXvrqD2QVmFD8d7wgACG+aiwb2a5KAtDKHq9JROXDE46Iaj8Gn5XA4LN2kSQZQ979BQlXcyEIgFIUIQCQAVgkCbJsmzrf9Fz3217/KUlyla8pJSIiupNwwxHd8U5cycGfafkQAKhE0bFZwf7ZbJXwZ1o+TlzJQduGQbf1LFEUmE6JiIioDJyroDve0Ut6WCQJCoXgNg+nQiHAIkk4eknvnQ4SERHdRTjySXc8wb6wRIZtuLMkuUQ9b7CYgF+WAdkpQHAU0P0lQMnUTEREdOdh8El3vHaNgqBSiLBYJYiiDKFYBCpDhlWSoVKIaNcoyDsd/H46cPRzQLLcLPvpn0D7J4DBb3mnT0RERNWE0+50x4uvH4iYun6QAZgtEiRZhgwZkizDbJEgA4ip64f4+l5Yq/n9dODIZ86BJ2D7fOQz23UiIqI7CINPqjWKiix47dsTGPvxAbz27QkUFVlu3Qi2TUCvPhSHOv4aiKIAqyTBbJFglSSIooBwfw1efSjO87vSLSbbiKedINz8sjv6ua0e3ZIkS0jITMCvV35FQmYCJFnydpeIiMgNplqqBKZaqhyDwYwZ64/jYlYBGoX44s3hbaDTqcrVduLKQ9iZdN2l/IHYOvhkQudy3cOWZD4Zf6blwWSVoFaIaBHhj6m9vJSHc88SYM9C238LbgJf+x/NXrOAXq94rl+10J2cfJx5LYnoTsPgsxIYfFbc0Hd/wdFLOS7l7RsGYsNz3ctsW1rgaVeRANTreTiLbyxKOwlcO2ErLyv4bDsaGPqB5/pYyxxMPYgF+xegwFyAQE0g1Ao1TFYTcow58FX5Ys59c2ptAHonB9VEdPeqEd8+nz59GgMGDICvry/Cw8Px4osvorCwsFxtV61ahdjYWGi1WrRu3Rpff/21S53XX38d/fr1Q2BgIARBwOHDh6v6Fe4oJpMV//nxDP761R/4z49nYDJZb+t+pQWeAHD0Ug6GvvtLqW2LiixlBp4AsDPpeoWm4OMbBKJnTB3ENwj0bOD5/XRgYaRttPPYlzcDz1sJjqrWbtVE5Z1Cl2QJn5z4BAXmAoTrwgEABrMBABCuC0eBuQCfnPikVk7B24Pq09mnoRSU0Cq0UApKnM4+jQX7F+Bg6kFvd5GIqFK8vttdr9ejT58+aNy4MdavX4/09HTMmDEDmZmZWL16dZltv/nmG0yYMAEzZ85E//79sXHjRowaNQqBgYHo37+/o96HH36I6Oho9OvXD+vXr6/uV6rVXvv2ONYdvgyLdHNA/D+7zmBUxwZ4Y1ibCt/PYDCXGnjaHb2UA4PB7HYKft6WhHI9Z96WBCwe3rbC/fMY+8ai0siy8+infdRTVNrSLt1FKjLal5iViJTcFKgValzMuwij1ehIqaVRaBCgDkBKbgoSsxLRKrSVd16oEuxBtd6oh8Vqgd6ohwxbpgaNqIFZMuOTE5+gU0QnTsETUa3j9eDzww8/RHZ2Nv744w+EhdnW3SmVSowdOxavvfYa4uLiSm07e/ZsjBgxAosWLQIA9O7dG0lJSZgzZ45T8Hnx4kWIoog9e/Yw+CzDa98ex5pDl1zKLZLsKK9oAPrS+mPlrvfhEx1dyn+/oC9X+/LW8wp3G4vsiq96cbcCpv0Td1W+z9Km0O2jfSWn0PVFehjMBhSaCyFBgkJUQBRESJBQZCmCyWKCj8oH+iK9916qEhKzEvFn9p8oMBVAggQZN39vFFoLIVpF/Jn9Z60LqomIgBow7b5161b07dvXEXgCwPDhw6HRaLB169ZS26WkpCApKQmjR492Kh8zZgwOHTqEjIwMR5koev01azyTyYp1hy87PrvbeL3u8OUKT8GfuZZ3W/W0akW52pe3nlf8suxmKqWSazvdrfUEbCOe9zx1V+X5LDmFrlVqIQoitEptqVPogZpAFFmKYJWtUIkqiDf+ShMhQiWqYJWtKLIUIVBTu448zSrMQo4xB1ZYHSOe9v/JkGGFFbnGXGQVZnm7q0REFeb1qCwxMdFldFOj0SA6OhqJiYlltgPg0rZly5aQZRlJSUlV39k72Ac/nXNMtZcWH1kkGR/85LzJymKRsOH3K3hn1xls+P0KLBbntXXBvuUbtSut3ph7G5arfXnreUV2Svnq1W1t21zUaxYwK/WuCjyBm1PogZpAt8egBmoCHVPodvYRQUEQUHLvpCzLjvsUHzmsDbKMWbDKtm/0hBLHctk/W2QLsowMPomo9vH6tHt2djaCgoJcyoODg5GVVfpfrNnZ2QDg0jY4OBgAymxbHvYd7e5cunQJDRvW4GCnEi5kFVS43oqfkvHu7mTkFpkdSxbnf5+A53pHY1KPaADA3we0wKgPb70x4u8DWrgtf6x9Q/zfhpOwlLFfRCna6tVY5d0wFPfIXZ1SSV+kh0WyQK1w/42IWqFGrinXaQo915gLrVKLQkshzJIZoiA6Rgcl2TYNr1VqkWvM9dBbVI18Y77jv+0jn8U/u6tHRFRbeH3kE4DLKAfgPGpRkbb20Y/ytKWbGof4Vqjeip+SsXhbEvSFZkiybY+HJAP6QjMWb0vCip+SAQCdGofCT1P2lLifRoFOjUMhSTJOXM7B3tPXceJyDiRJhlIp4pUBsWW2f2VALJTKGvFb2ba+c88SYMMzth8tJtuGIfHG93kl13XexRuLSgrSBkEpKmGyuk+qb7KaoBSVCNIGObXRqXTwV/sDgm000CybYZEtgAD4q/2hU+mc2tQGgiC4BJz2/znqQODfc0RUK3l95DM4ONgxilmcXq8vc7ORfYQzOzsbdevWdWpX/HpllZXDs6xR0drqmR5N8Z9dZ2CR5FI3XitFAc/0aAqLRcLbO8/AeqNcsP/fjSDUKgNv7zyDp7pGQakU8dETHTH+s0MwW12nPlUKAR890REHzmXi/b3JSE7Ph9kqQ6UQEB3uh2d73hxFXb7rDHKLbq45DdAqMK1Pc8d1ryvrjPb2T9zc7c6NRW7FhcQhKiAKp7NPQ6PQOAVWsiwjx5iDmOAYxIXEObUJ0YQgKTsJAgQoRSUEWYAsyJBlGXqjHrHBsU5taoM2ddpAJapglswQIEDCzaF/ESJkyFCJKrSpU/EMFERE3ub14aK4uDiXtZ1GoxHJycllBp/2ayXbnjp1CoIgIDa27NEycqZWKzCqYwPHZ1m++WU3qmMDqNUKbDp2FfnGG+vRBMAxQFNsg1K+0YpNx64CALo2C8OqpzqjS5NAaJQCFAKgVgCtI/0wo18MzmUU4NVvjyMxNRe+GiXC/TXw1SiRmJqHWRtOYN/ZDEzqEY3f/68/lo1sh7/1j8Gyke3w+//1r1mBZ1lntAO2DURiie/37sKNRaURBRET4yfCV+WLdEM6iixFkGTbrvV0Qzp8Vb6YGD/RfWoh2RagioLo2PEuyze+G6qFWoa2RLOgZo4lBEpRCZWoglJUOqbhmwU1Q8vQlt7uKhFRhXl95HPgwIF4/fXXkZmZidDQUADAhg0bYDQaMXDgwFLbRUVFITY2FuvWrcPQoUMd5V9++SU6d+7stHueyseeRunLQ5dQfImlCGB054aO639c1Jfrfn9c1GP4PbaAtmuzMHRp2g0JV3Pxy9kMbD+ZiguZBizfdRYmiwRARoNgHbQq2xS9VlQgIkBEWq4R7+9NRpemoVAqRQztUL+K3rYKlSeV0tHPbZuIHvrnzROOgqNsU+13+YhncfdG3os5981x5PnMNeVCKSoRExxTap7PLGMW6vrWRa4pF0ar0bYbXgB8VD4IUAcgy5hV61ISiYKIGR1nYNYvs5BdlA1JlmzBtAAoRSWCtcGY0XEGc3wSUa3k9eBzypQpWL58OYYMGYLZs2c7ksyPHTvWaeRz4sSJWLVqFSyWmyNLCxYswKhRoxwJ5Ddt2oQdO3Zg+/btTs/Yu3cvrl+/joQEW8LyXbt24fz582jSpAk6dnTNLXk3axLmCz+tErnFTgzy0yrRJOzmmlCfYms4yzqc1afEWk9RFJBXZMZHPyVDbzC7DEpdyipEo1ABfhrbb0tBEBCkUyE5PR8JV3MR36CGpsu5VSolWbZd/2WZbUPRXbypqDzujbwXnSI6les8c/smpTCfMARrg1FkKXIkptcqtZBkCRmFGbUuzydg+3lY2H0hPj7xMc5kn4FZMkMlqtA8uDmejn+ax2sSUa3l9eAzKCgIu3btwrRp0zBs2DDodDqMHj0aS5YscapntVphtTrnmBwxYgQMBgMWLlyIpUuXolmzZli3bp1TgnkAmDt3Lvbu3ev4/Mortn/8x48fj5UrV1bPi9VCK35KxqJtSZBKRIW5RRYs2mZLXTWpRzQeah2BFT+dK3NGUwDwUOsIpzJJkvHaxhPINpgddYCbM6NWWcaV7ELE1PVzrPfTKETkSDKyDO43odQI5U2lVN56BFEQyzVSWXyTklaphVapdbrubpNSbVKRQJyIqLbwevAJADExMfjhhx/KrLNy5Uq3geL48eMxfvz4Mtvu2bPnNnp3d7BYJLy184xL4GknycBbNzYRtW0QhLoBGqTlGku9X90ADdo2CHIqO35Jj/OZtnO3i29SKs5klWAwWeCrsR21abRKUIkCQnQ1eGq6vKmU7sIz2qtbZTYp1TblDcSJiGoLfvtcy7lLT1QZm45dRYHx5siyuxOOCoptIsopNJd5P3fXtyWk3ZymF0r8WExuoW0KW5Zl6A1mRIf7oVW9gHK/i8cxlZLX3NYmJSIi8ooaMfJJlbPvbEap6Ym6NqvYhqujl26muypt2aK9XqNAHxSay8j6DqDQLOHIuSx0ahbqKCswWdzWLTkAapVkFJqt0BvM8NMo8GzPaIhiDc5nqFQzlZIXVXSTEhEReReDz1pq39kMzNpwAvlGC4J1aqgVIkxWyZGeaOHQ+AoFoL7q8v1W8FUrsfh/f5ar7uL//Yn1zbo6PrdvGIzVBy4CgHMu0RLRp0mSYDBaEBfpX6lA2ivsqZJK5vkUlbbAk6mUqhXXRhIR1R4MPmshSZLx/t5k5BstiAjQOta5uUtPVN4Rw4daReCjn8/ZBu1kOE+H25PJC7Z6O06lleueWQXOa0KHtK2HOd+ddEzvuxsg9FGJ+M/o9gj306JVvQDvjnga84EvHwf0l4GgBsDotYDGr/T6g99iKqUqYpEs2JayDakFqYj0jcRDUQ9BWTJHaglcG0lEVDsw+KyFEq7mIjk9H8E6tcvxepVNT9SmYRCahOqQkmGwxZ7FAkP7fzYJ1aFNwyDEhPsjJcNwy3vGhPs7fVYqRUx/oLnbHfUAIArAjH4xeCC2rutFT3unE5Bx+uZnfQqwqD4QFgM8/1vp7ZRqplK6TStPrsTHJz5GnjnPcczukkNL8HT805jQeoK3u0dERLeJc1K1UJbBBLNVhlrh/pdPoxBhrmB6IlEU8PqQ1gjQ2r4fkYt9CQCCdSosGNwam/64iti6unLdc9ljbV3KJvWIxqsPxSLQRwnxxr1FAIE+Srz6UGzNOLGoZOBZXMZp23WqFitPrsTbv7+NXFMuREGEWlRDFETkmnLx9u9vY+XJld7uIhER3SaOfNZCITo1VAoBJqsErahwuV5aeiKLRcL3x1NxRW9A/SAdBreJhFJpC2D3nc3Ahz+dg0ohQKUQYJFkCAB0aiXaNghEk1Adpq09itwic5mJ5e3aNwyETqdye21Sj2g81TWq1L54lTG/9MDTLuO0rV5ZU/BUYRbJgo9PfAyrbIVavDmqr4QSClEBk2TCxyc+xriW4245BU93LlmSUHQqEdbsbCiCg6FtGQdBrAF/dxBRufFv8FqoVb0ARIf7ITE1DxEBoktuQ73BjLhIf6f0RCt+SsY7u88ir9ACCbbRxnnfn8TzvZuhVb1Ax+alEF8N6vprkWe0IKfQAl+NAo1DfLD2t0uwljOLU/uGgdjwXPcy69TYozK/fLz89SZsrt6+3GW2pWxDnjkPClHhdjmJQlQgz5yHbSnbMDh6sJd6Sd5UcOAAMj5aAVNKCmSzGYJKBXVUFMImT4Jvly7e7h4RlRODz1pIFAU82zMaszacQFquEUE6FTQKEUar5DY9kbuTiyQAOYUWLNyahCZhOpfNS0E6NQJ9VEjLLcK6w5cdgaebfUhQCECLCD80CvHDm8PblDriWSvoL1dtPSq31IJUyLIMheA6mg8ACihgla1ILUj1cM+oJig4cACpc+dCyi+AIigIgloN2WSC8fRppM6di8j58xmAEtUSnKuopbo2C8PCofGIi/SHwWhBer7RkZ6oeJqlW51cJAM4n2FAkI8KkiThQmYBTqfn4UJmASRJgigIzoGncPPLHohaZWBi92h88MQ9tTvwBGy72quyHpVbpG8kBEGAFVa3162wQhAERPpGerhn5G2yJCHjoxWQ8gugrFsXolYLQRQharVQhodDKjAg46MVkKWy8w8TUc3Akc9arGuzMHRpGoqEq7nIMpgQolO7pCdyd3KRnX3tpgzgQmYBiueNN5olnErLh1Omo5JZj4rl5zx6SY/h99SCgMxiKjsV0ui1tl3ttzJ6bfX18S71UNRDWHJoCXJNuS5T77IswypZEaAOwENRD3mxl+QNRacSYUpJsY14uluSERgIU0oKik4lwqc1020R1XQMPms5SZJxNj3fsXEnLsLfKfgsfnIR4D63JgCUdmBReU/r9FW5nyqtUb6f7poE/qd/OieB1/jZ0imVtekoLOau2WwkyZLHErcrRSWejn8ab//+NkySCQpRYZtqhxVWyQqFoMDT8U/fdZuNJIsFuVu2wnz1KlT16iHg4YEQlXfXz4E1O9u2xlPtPmeuoFZDzsmBNTvb7XUiqlnurr/B7jArfkrGf3aeQV6xkc05m07ghQeaO1IWlffkovJwOpUINwNZAcCA+Igqe061+H76zeMvi5MsN8vtAejzv5WebulWeT5rgfIGlAdTDzqOrLRIFihFJaICoqr1yEp7Hk97nk+rbJtqD1AH3JV5PjM//RQZH34EKS/P8Qfw2sKFCJsyGaF/+Yu3u+cxiuBgCCoVZJMJglbrcl02mSCoVFAEB3uhd0RUUYIslydxDhXXtGlTAMC5c+e81ocVPyXjja1JpV5/baAtZ+YfF7Lx6Pv7qrUvUWE67JzRq+aev24xAQsjb454uougRSUwK9V5Cr6iJxzVAgdTD2LFsRVIyk6CWTJDJaoQGxyLSW0nOQWUB1MPYsH+BSgwFyBQEwi1Qg2T1YQcYw58Vb6Yc9+caj0z3WwxYfePn0CffglB4Q3Ru+9EqG5xUtSdloIn89NPkf7vNwGrmzWwCgXC/zrjrglAZUnCxacnwXj6NJTh4S5LMizp6dDExKDRxytq9a850d2CwWcleDv4tFgktJizDdYy1tYrRODPBQ9BkmTEzNl+288UBVucVvw3iwAgSKfCu2M61Ozz1/csAfYstP234CZAtv8R6DXrjj6d6GDqQfxtz9+QY8qBXOxXUoCAQHUglvZainsj74UkS3jmf8/gdPZphOtc/6FPN6QjJjgGH/T7oFqm4CuTTudOS8EjWSw4fW8XyAUFtgI33zAJvr6IOXjgrpmCd+x2LzBAERjo2O1uzcmB6OuLyPnzauWvNdHdiN8i1kLf/H6pzMATAKwScP+/dmHYB79WyTPvaRyMrtGhCPJRQadWIMhHha7RoTU/8ARsm4uqsl4tJMkSXt//OvQmvVPgCQAyZOhNery+/3XHlHxKbgoCNYFuN3cEagKRkpuCxKzEKu+nPcAw/vknRJ0Oyjp1IOp0jnQ6BQcOVEmbmi5n8xb3gWexz3JBAXI2b/Fwz7zHt0sXRM6fD01MDCSDAZbr1yEZDNDExDDwJKpl7o5vme8wXx66VK56qTlGpOYYq+SZq57sBK1WWebO+horOKpq69VCJzJO4GLexTLrXMy7iBMZJ5BvyodFskCtUEOQZERcLYSuwAqDrwJp9XygVqiRa8qFvkhfpX0snk5HER4OGI2QDAYICgUUderAev06Mj5aAV3nzo6p1eJtxDp1IOXlQc7Lg6BSQQwLg5SR4dKmNig6dqz89R4dUs29qTl8u3SBrnPnO2p5BdHdiMFnLWQwuc+DWF2KH5UZ3yDQo88ul1ulT+r+km1Xu2QpfdeUqLTV85Lq3lX+44UfHSOegiyjSRoQUCgj10fA+QhAFgTIkPHjhR8xIGoAlKISDf/Uo89ePUKvG6GwyrAqBGTW0WBXzyDomygRpA2qsv4BN9PpCGo1zBcuQDaZAEkCRBGCWu02nY69jSzLMJ8+batvJ4oQg4JqZQoeUaer0np3EkEUa9WvJRG5YvBZC93TOAhn0vOr9J4BWgVyi1yD2vIclelV5UmfpFTbPtt3tbtb5tz+CeeA1YMOph7Ex8c/xhn9GccmoOZBzfF0m6erbFOPwWwAALQ6L+HR/RLqZQFKK2BRAFdDgI33iUhoIsJgNiAuJA490oJx/7enoDMBhb5KWBUCFFYZ4amFeOTbQgSPaYm4kLgq6ZudNTsbUkEBpIIC5002kgTZYoHFaITo6+uUTseanQ1rTg5kg8H1hpIEKSsLsk5X61Lw+Pfvh6xPP72x0Lq0NBMC/Pv3804HiYhuA+cqaqF5D1f9d/2zB7XCqTn9MaBVBFpG+mNAqwicmtO/5geeRz5zDjyBm+mTvp9+s2zwW8A9T9lGOIsTlbZye6DqYQdTD2LWz7NwJP0I9EY9CswF0Bv1OJJ+BLN+noWDqQer5DmhPqFodV7C5O0SGqcDWiOgMdl+bHwdmLxdQqvzEkJ9QiHIwKP7JfiYgCx/wKQEJNH2Y5Y/4GOyXReqeKuiGBgIyWBwv7sbAKxWSAYDxMCbo++Cvz/kwsKbdQTh5tcNcmEhBH//qu1sNfOJj4eqUaObBfYgtNg3TqpGjeATH++F3hER3R6OfNZAJpMVH/x0DheyCtA4xBfP9GgKi0XCjPXHcTGrAI1CfNGzeRj2nsmokudpVSKGtqsPpVLEB0/cUyX3rBZOqY/qA+d/uXnN3cjQ0c+Bh/55c0Rz8Fu2z2VN0XuQJEt48/CbyCjKgCgDTa8B/gYgTwek1LUioygDbx5+E18O+vK2p+D9FL54dL+E0FxAVTK2MwK6IltAaRrmi6JTidBezYIQWheFUi6MViNkWYYAAVqlD3xDA6C5mlX1U9my5Bx4uvs1tVpt9W4wnz/vNBLoRBAcAZv5/HmgXduq62s1E0QRkfPn4fJLL0HS5ziP1gsCxKAgRM6fx7WORFQrMfisYV779jjWHb4MS7Gjhd780TnZ+anUPAClT5VXhAjgr/1ioFTW8H/ESiZ91xfbmV5a0CFZbIFm8fRJSrVX0ykVX9uZUZiBs/qzaJUiYch+C5qk2QJDswI4HwFsuk+JP4WzSMhMQHzY7Y1wGZOS0Oaym8DzBpUViL0M/JaUBGtENGSzGbqgOmgkBqPIUgirbDthSKv0ASQJloLrVT6VXXTiZLnr6draAklzaqpTkFna9LQ5NbVK++oJvl26oMGyZbj+4UcwJibaEqmr1dDExaHOlMnc3U1EtRaDzxrktW+PY005d7IDQG6RFUFaEfqiW+RdckMAEKBV4vk+zRynIdVYpZ02ZFcy6CjOw+mTykp0XnJtp0WyoPm5Iry4UYJfoe3XBAC0ZqD1eaDJNQveelTGiesnbjv4rJuvhtpSdh21xVav+GkyolYLH6WPUz2pOk+TEQRAobBtHCoeUIqi7Uty/r2uqlevXG1U9epVfV89gLu7iehOxOCzhjCZrFh3+HKF21Uk8PTTKDD/kdZIzSlE/SAdBreJrPkjnsb8sgNPu9IC0NtIn1TRE3Psic4Lkk9DNpsgqNTwjY5B2ORJONlYwKyfZyHXkIGHf7UiXC8hLUBGz1OA/40liyUT+PsXAhO3W3HtsYp/c1FS0/3lC8Kb7k+Bdnwc1FFRMJ4+DUGjcUkyb83JgSYmBtqWVbvhyKdtGwhKJWSr1XaGd4md67LZDEGphE/bNo7igIcH4trChZBycwG1GkKx6WlZEACTCWJAAAIeHlilffUk7u4mojsNg88a4oOfzjlNtVeUfeaxNGqFgI+e6FizE8JLEpB2DDBkArpQIKKtbY2n/TKARLUKeoUCQVYr4kxm9zvmykifVN6URgUHDuD6hx/BdOaMY7pT3bx5qdOdBQcOIOW1mSjKyYJRsACSDJgFFJz4DXmvncWGQX545GAq+hyXoXATS5b8pZNhC0AjswHL+SzgNmMPXVYhispZTxBFhE2ehNS5c2FJT3d7mkzY5ElVPvqmbdUK6ubNbVPMNwJN+29s2WwGZBnq5s2hbXXzJ0NUKhE2ZTLS31wGmEyQlcqbI6RmM6BQIGzK5LvmFCAiotqAfyPXEOczbz91UmkBaJivCv8ZXcNPIjq317Y+M+MMIJkBUQWENXeMeh7UavBJYABSVCpYBEApA1FmMybm5OLeohuJ9Eu+fIn0SQdTD+KTIx+h+bYTCMk0IytUhbcfisfEeyY7pTQqOHAAV/7+CqzZ2ZAtN3ODWnNycCU5GfX/ucQpAJUlCeeWL4X5+jVoTcDNzIsyJJhhMqZj0BfpCMmzl94kFPuxtAA07GAy8HCFf0adKENCKlTPfpqM48jKnBwIKhU0MTHVdmSlIIqo+/LfcPWVV2DJyoZstTpNoStDQlD35b+5BL32880zPvwIUl4eYLHYNuUEBCBsyuS75vxzIqLagme7V0J1nO0+a/1xfPFb+dd7luRun4XdSw80x4v9Yip972pRPDG8IAApvwKWQsAnGFBqAIsRKMwGinJwUAUsCAuBAQLi0iQEGmTk6AQkRojQQcac61lokyrBahSh0EjQhsoQOjzhlD7pYOpBnHxlGjofznMaebSKwKGO/mi9ZDnujbwXsiQhZcRIFCUklNp1batWiPr6K0cQZDh5AonjHofPjSUQ7oJLu5J/2IQyrtmvy0MfRKtFb7m5Wn75v/+OS2PGun1G8Wc1/GIN/Dp0uNmnCi49qAr25QvG06dvbrIpR9ArWSzI3bIV5qtXoapXDwEPD+SIJxFRDcS/mWuI6HC/22pffMlj8RFQpSjg2Z41ZEOR1QKc/AY48IFtel0uMf+sDQFUNza3qHwApRaSyYRPAlVocFHA4H0yGqYJUFgFWBXApQgZR6MFZCcH42KWFrLFAkGlhCa+Peo8+Dh8b9xWkiWk/N9M3HfINvRYPABTSMB9h/Jw/P9motPHO1F0MgFFp06V+RpFp06h8GQCdG1sm4CSk49A6ybwtH8Wiv13WUqOftrbRXbueYuWt+bbrh3E4GBIN3aou3uOGBwM33btnPvkhfWGld1kIyqVCBryiId6SURElcXg04uK5/PMzK/cGewKEbDeiOHcjWGP6tgAarXiNnpZRX79D0w//htXdwqwFCghaAPhG26GtVABtZ8VIXH5EAuzUJQpwKoMh8JHhDZcicTgOlCmFmDKNzJUxfefWICml4Gml2XIEGGFyXbBZELhwUO4/OefaLBsGXy7dEFi2jG02p8GoPTgsNX+NCSmHUP4kaNlL54FAFlGwZHfHMHn2UM/ooWbe98OR8Dqo0XQ4MG3fz9RRINlb+LSs1NtSddLXvfxQYNlb9aYXdTcZENEdOdi8Okl7vJ5VpQA4PO/3Istx6+63EspChjVsQHeGNam9BtUgcKsLFweOQrWrCwoQkLQ4Kt18Cm5vvDX/+Ds9LdhziuWsqdACVOm1vEx46Q/FForBBGQkQtBIUAdrEB+JxX+8j2gkoqPCzr/t8t6SVmGNVuP1LnzEL1tKwpXrYNOKj04lGEbATWsWoe0w6dQnlA9bctG1HnKtpbQIptceuVOWes6i9dxEEVETJtWZVPHvl26oOH77yHjgw9RePy4bVOPSgWfNm0Q9swU5o0kIiKPYPDpBRXN51mah+Mj0LVZGLo2C8PcQa1cTkWq6IinpagI1xctRtH585ALCqCKioK2SROETHoaCrUaVpMJWSs+hunSJagbNkTmZ59Bzr+5UcpiMOB8124Q/P0R+9shW6HVgrPT/wNznuqWz7cWKSCoJKgDJMiyEsYMC/w3m2Ef1CwjTHNiD+hMFy+i8MQJaNPKlwxdm5aNVP0l1C9H3TT9Jdgzb4ZGxUHCCYgofeq8XOzrJQQBor8/6jwzpco3yzBvJBEReRuDTw+rbD7PkgQAE+9v6visVivwQt/mZbYpa5Ty4jPPoGDPXkddGUDRyZPIA5Dx3ntQNW4M84ULpZ+7XYycl4ekTp0R+9shmH5ZCXPezd9mpY0M2oM0ySyiQJKhUAMahQhrhgVyxUI4x3MEWUbujh+QFaZBefb5Z4VpcCFSifrl+OW5EHnznbqPfxV/vPcVfAtvBr72IFS68d8WEVBJpQenpod7oEmPhz2yWYZT2kRE5E0MPj1s1qaTtzXVbtckTIe2DYLcXnO3Q/nPe7tAzstz1Ck+Sqm7p4Mj8HTXM9lqhencuVICJ/d7tOW8PBRmZeHa6/8pVqvs0Up76JZnEpCnkaARZYRBcJ/Ls5wyMi8j6bE43PfF/6AoZepdgG3Xe9KAOJzqIOG+33aWGe7KAE6OvpmaSa3WImtEb/is3g1RAqwCIAuAIAMKGZBEIHVcb0Rky1Bu2QOx2NpVSQQsD/dCu3+9fxtvSUREVHsw+PSgfWczsO3Erc+YFmQJ0TlXEWAsQK7GF8mBtqMBo3OuItBYADkwEH97ZDBEUYAxNxdX/jIR5tRUCAoFlE2awHz5MqTMTFu+Q6USMJlK3UQj5+WVGXg61S1Wp7T1jTcDUgGXR46CkGu5ZeBZsrXaAoiyjCJBQKFShq+5HE3d3gk4XteEyOAG2N1GQN8/5FJHHne3ERAV3ADj2o7HN9H3oGNy6fc+Eg0s7PdPp7IBs97DdkxF6Ne74VNkCzxlAAYfIHNEbwyY9R4AwPyPIpxevhimixehbtQILabNhEqjdfMUIiKiOxODTw+RJBnv702GVCwIFCULel45hjqGbFzXBCCiMAstsy4iUp+KELMBSkiwAMhX6qCBFT4WI0TIENRq6LJ/wp9nzkLKynJ6jjU93fnB5Zgmvz3OQaV9BFOADGtWFjQRgUCuvkJ3tKhEiEoVBIjQ+xRWOPi096hQDZzrWA8zox5CryFLICPL5YQhqwjsaiPg6yEh2BP1EJSiEj9M7QC89zvuSXbNw3kkGvhhagc8qdKhpAGz3oPpb0X4ddUSFF65CJ/6jdBt/CtQq28GlyqNFq3+Nq9iL0RERHQHYfDpIQlXc5Gcno/IAA1SMgow6cT36H/hIHysZqCMVY1KAFpLgeOzAAAmE4oOHqpUP0rbDFO1Jw0IkCFDqVPi9/nPIu6JRRVqrfeVoRJECLIMXytQoMItA9CSP3+SAHzdTcB99dpCKSrxdPzTeNv8Nlb2N+HRAwLqZgPXgoGNXWRAqcaL8U9DKdr+OKx5eA3GYiyWpf2OJ/9nO+IyNRj4bz+gZUQHrHl4Tan9UKu16D1pboXel4iI6G7C4NNDsgwmmK0yOmWdw2u7/4vI/Ixyty3vdpuyNvO4XctZ7h7c+l7uagY8mIOvr23FUyFAw6xbtwCAfA0AQYDSJMHHYEGRRom3H5Ewao+EFm5WLKT7Az5mwNd4Y6pbAAo0wIb7BOzpHoB/RNnOpZzQegIA4OMTH+Pb7nmQZRmCIMBf5Y+n4592XLdb8/AaGMwGvNb0NezLv4wGfg3wa/c3oHMz4klERETlx+M1K6Eyx2ueuJyDZf9cg/H7vkCd/MwKPe9WwacnfwHLm0YoTw38+zkZprqxSM5JxtIPLWUGoJIAZPsCsgioJRGyUoHMOhrs7BmIH+tcAwCozBY89x0QoQfSgoB3HwHMKiVESULXBKBOrozrAQL2tQIEhQrTO0x3CSotkgXbUrYhtSAVkb6ReOjGVDsRERF5BoPPSqhM8Gm1WLH94VGoe+UsdJbyn2ZU9sYezwaeJZ9XWvCZpwaenqGAnyTgwdjhWH9mPQBAbbLg5a+B8Bwg3Q843RgIywXSggR81xWQRBFN0oCWYgMUBaiQGqnFtaLrqKuri3P6cyiSilyepRAU0Cq0KLQUQoYMAQL81e5HM4mIiMj7OOTjIaakJDQ2ZKBQqFjioLJO5akqRSKgkW49wioBTmmPrCKwsznQJg0IMAC5OmDmBKDQx5bcXucbhpmdZzqCT5NaiTfGlv2MjEb+SPb3h8lqQk7RdfiqfDGj4wwAwIdHP8TJrJOwSBYoRSVah7TGlPZTcE/deziaSUREVEvwX2gPsWZnQy1bAZ0GyCl0uV7+tZRVyz5K+fBBCUP3yfB3MyhrBbCzHbCyH/DI/pvT3t/dB1gVpZ+iNKXts9AqtehZvyf2Xtlbaj0AECEizCcMoigiozADSlGJmOAYTIyfiHsjbTk1O0V0QmJWIvRFegRpgxAXEgfxRjA/OPr2zz8nIiKi6sdp90qozLR74ckEXJ42DbKlCNbr2SieVt2m4qf4VNTpOkCAyf0oJQDb2slTQLheRqN0wKgE0kJuHWS6Iwgifn/id8cI5PM/Pu82AK3rUxePtXgMf2n9FyhFZanBJREREd0ZOPLpIdqWcVBHBKEoMREQZcgS4ImA0+7PSGDO+LIDSEkU8Utr99faFBXhs7Tr+DTQ///bu/+oqOq8D+DvgYEZGH4NIISKQJKIqEilHH+cNtEwQN2CxwRsVw6Y+rhp6Sm1NiXNJxVtV9e2XNNd7Ul9/ImVsIoHsOOuWOmW/WDQ4gAh/iCBGRllEJjv8wfLzesAmTsMP+b9OofDmc/93Ov9fpTxw/fe+V5UOjnhuoMDTru63JWlkIa05JElskvfb09+G6ZmE7I+z0JlfSUC3QOxdPRSqJXyBdYjfPjYRyIior6MM5/34X5mPmE24+aaKSg7WomGRsCl8T97bCTQ2lD+TzKwYi/gc6P1UY3fDASuegLBtYC/4d+fCp8GNDl13Hg6ms0Y1NyMMmdni22uLS0orLyM1gWG7nxquQI7vX2wzdMD9fhpEU53J3fMHTmXH/YhIiKidrH5vA/31Xxe/gKfHnwW/3tdYMJnAkHVgKYBcGjt42ByAG64AVe1rU1l0PV/N4/uwCV/wF8PBF9rvf/yqs/PN5T3SmE2473q64g2NcII4AU/X1x2UqJ/UzM2V1+HW1ui7xDgv4uAbw4ChkrAMxAY/l9oVoAf9iEiIqJ71iOaz4sXL2LRokU4deoUNBoNUlJSsG7dOri43H1Z19KuXbuwdu1alJeXIzQ0FJmZmZgxY4Ysp6mpCStXrsTOnTthMBgQHR2NzZs3Y+TIkfd1vvfTfJq/y8P8ky/iS0cFTIrWRtLjpoB7A2B0AQwaBcr9AaHomkvxSiEQfLsJZU5KtCgUcBQCkY23scBwA9GmRsA7FKj9vp0d1cDLpYDKzXIbERER0S/U7VNUer0eMTExCAoKwqFDh1BdXY0lS5agpqYGH3zwQaf7Hjx4EGlpaVi+fDliY2Nx5MgRzJw5E56enoiNjZXyFi9ejPfffx9vvfUWgoODkZWVhUmTJuHrr7/GAw880NVDBADoWowoc3SAI8wQCgeUPQB09T2fDgCchECkqRFzDTcw2tQInbMT9I6O8GppQfjtptZL/66+wKJzQKMR2JsM6C8BXgOBlP9j00lERERW1e0zn+vXr8fq1atRUVEBX19fAMCePXswa9YsFBcXIzw8vMN9w8PDMWLECOzfv1+KTZkyBQaDAWfOnAEAVFVVISgoCH/605+wYMECAEB9fT1CQkIwZ84crFu37hef8/3MfP7z0im8lr8QaLqN60rrfILbwWyG2cHyWO4tLdh67UfU391ktsfVF1haapXzISIiIvo53b6OTW5uLiZPniw1ngCQlJQElUqF3NzcDvcrKytDSUkJUlJSZPHU1FR89tlnuH699dnpeXl5aGlpQXJyspTj7u6OadOmIScnx8qj6ZiXizeUKg9oFP/ZfZoKAIG3m1BUXonzP1Thn+WViDCZ4NvcjAiTCf8sr8TpyssYebsJ4xtMiGhrPH2HAMsqWr+rvH56zcaTiIiIbKjbL7vrdDqkp6fLYiqVCoMHD4ZOp+t0PwAWM6PDhg2DEAIlJSWYMGECdDod/P394e3tbZG3e/dumM1mOLQze2ht4d7hCPEeiovmb+DVZIS+da2lTrmazRhmakTCzZuoVTohoLkJcTcbfvpL8x0Cjzn5+L9d04DaKsDBEYhIAjxDgMtnAH2V5eXz5z/vsjESERER/Zxubz7r6urg5eVlEddqtaitre10PwAW+2q1WgCQ9u3s+E1NTTAajfDw8LDY3nZpvT2VlZUIDAzscHt7HBQOyBiRgdVFq3HT0RnuTQ2ob7llkRekUGGqJhTpN/RwvnEVcA8ChowD9BXApU8BdwA+QfKGcl7nTw8iIiIi6im6vfkEAEU7n/AWQrQb/7l9225hvTPe0fE72tZVogOisXLsSuz4egfKbpRB2aRCk7kJaqUa4weMx8qxK+HsaLnWJhEREVFf0e3Np1arlWYx76TX6zv9sFHbDGddXR38/f1l+925vbPjOzk5QaPRtHv8zj5M1Nms6M+JDoju9BnlRERERH1Zt3c84eHhFvd2NjY2orS09Gc/6Q7AYt/i4mIoFAoMHTpUyquurra4hF9cXIywsDCb3O95NweFAyJ8IjB+wHhE+ESw8SQiIiK70e1dT3x8PPLz81FTUyPFsrOz0djYiPj4+A73CwkJwdChQ7Fv3z5ZfO/evRgzZoz06fnY2Fg4ODjIlmMyGo34+OOPkZCQYOXREBEREVFnuv2y+7x587Blyxb8+te/xooVK6RF5mfNmiWb+czIyMCuXbvQ3NwsxVavXo2ZM2di8ODBeOKJJ/Dhhx8iLy8Px44dk3IGDBiA+fPnY9myZVAqlQgKCsLGjRsBAC+++KLNxklEREREPaD59PLyQkFBARYuXIjExES4uroiJSUF69evl+W1tLSgpaVFFpsxYwZu3bqFN998Exs3bkRoaCj27dsne7oRAPzhD3+Am5sbXnvtNenxmvn5+TZ7uhERERERter2Jxz1RvfzhCMiIiIi6gH3fBIRERGR/WDzSUREREQ2w+aTiIiIiGyGzScRERER2QybTyIiIiKyGTafRERERGQzbD6JiIiIyGa4zud9cHFxQXNzMwIDA7v7VIiI6D4FBgbik08+6e7TILI73f6Eo95IpVLd136VlZUAwKb131gPOdZDjvX4CWshx3oQ9W6c+bQhPhlJjvWQYz3kWI+fsBZyrAdR78Z7PomIiIjIZth8EhEREZHNsPkkIiIiIpth80lERERENsPmk4iIiIhshs0nEREREdkMl1oiIiIiIpvhzCcRERER2QybTyIiIiKyGTafRERERGQzbD6JiIiIyGbYfNrAxYsX8eSTT0Kj0cDPzw8vvPACGhoauvu0rOrAgQN46qmnEBgYCI1Gg5EjR+Ldd9+F2WyW5eXm5iIqKgpqtRqhoaF455132j3exo0bERwcDLVajdGjR+PkyZM2GEXXMRqNGDhwIBQKBc6ePSvbZk812bFjByIjI6FWq+Hn54fp06fLtttTLY4cOYLo6Gh4eHjA398fiYmJuHDhgkVeX6vJ999/j/nz52PUqFFQKpUYPnx4u3nWHHd9fT3mzZsHHx8fuLm5Yfr06aioqLDmsIjolxDUperq6sSAAQPEuHHjxN///nexa9cu4ePjI2bNmtXdp2ZV0dHR4plnnhF79+4VBQUFYsWKFUKpVIqXXnpJyjl9+rRQKpUiPT1dFBQUiDfeeEM4ODiI9957T3asDRs2CCcnJ7FhwwaRn58vkpOThVqtFl999ZWth2U1S5cuFf7+/gKA+Pzzz6W4PdUkMzNTeHh4iPXr14uTJ0+Kw4cPi7lz50rb7akWJ06cEAqFQvzmN78ReXl5Yv/+/WLYsGFi4MCBwmAwSHl9sSZHjhwRAwcOFElJSWLEiBEiIiLCIsfa405ISBABAQFiz5494ujRo+Lhhx8WoaGh4tatW106ViJqH5vPLrZu3Trh6uoqfvzxRym2e/duAUAUFxd345lZV3V1tUVs8eLFQq1WC5PJJIQQ4sknnxRjxoyR5Tz33HMiICBAtLS0CCGEMJlMwtPTU7z88stSTnNzswgPDxczZ87swhF0HZ1OJzQajdi6datF82kvNSkuLhaOjo7i+PHjHebYSy2EECIjI0MEBwcLs9ksxT799FMBQOTm5kqxvliTtvMWQojZs2e323xac9xnzpwRAEROTo4Uq6ioEEqlUrz77rtWGxcR3Ttedu9iubm5mDx5Mnx9faVYUlISVCoVcnNzu/HMrKtfv34WsaioKJhMJtTW1qKxsREFBQVITk6W5cyaNQtXrlzBF198AQA4ffo0DAYDUlJSpBxHR0fMnDkTubm5EL1wWdpFixZh/vz5CAsLk8XtqSY7d+7Egw8+iNjY2Ha321MtAKCpqQnu7u5QKBRSzMvLCwCkMfTVmjg4dP7fjrXHnZubCy8vL8TFxUl5gwYNwoQJE5CTk2OtYRHRL8Dms4vpdDqEh4fLYiqVCoMHD4ZOp+ums7KNU6dOwdvbG35+figtLcXt27ctajFs2DAAkGrR9n3o0KEWefX19aiqqrLBmVvPwYMHcf78eaxcudJimz3V5MyZMxgxYgTeeOMN+Pn5wdnZGb/61a/w5ZdfArCvWgBARkYGdDodtmzZAr1ej/Lycrz00ksIDw/HpEmTANhfTdpYe9w6nQ5hYWGyRr8tr6+/BxP1VGw+u1hdXZ00o3EnrVaL2tpa25+QjZw9exZ/+9vfsHjxYjg6OqKurg4ALGqh1WoBQKpFXV0dVCoVXFxcOs3rDW7duoUlS5Zg7dq18PDwsNhuTzW5evUq8vLysHv3bmzduhWHDx/GrVu38MQTT0Cv19tVLQDgscceQ3Z2Nn7/+99Dq9UiJCQEpaWlyMvLg0qlAmBf/z7uZO1x2+t7MFFPxubTBu7+jRtovbTWXrwvuHr1KpKSkjBmzBgsW7ZMtq2jMd8Z76hene3fE61Zswb+/v5IS0vrNM8eamI2m2E0GnHo0CEkJiZi6tSp+Oijj1BfX49t27ZJefZQC6D1kvGzzz6L9PR05Ofn4/Dhw3B1dUVcXBxu3Lghy7WXmtzNmuO2t/dgop6OzWcX02q10m/yd9Lr9dJv6H2JwWBAXFwcXF1d8dFHH8HJyQnAT7MRd9ei7XXbdq1WC5PJBJPJJMvT6/WyvJ6uoqICb731FlatWoUbN25Ar9fDaDQCaF12yWg02lVNvL294e/vj4iICCkWEBCAoUOH4ttvv7WrWgCt9wHHxMRg06ZNiImJwdNPP43c3FxcvHgR27dvB2B/PzNtrD1ue3sPJuoN2Hx2sfDwcIv7ihobG1FaWmpxT1NvZzKZMH36dFy7dg3Hjh2Dj4+PtG3w4MFwdna2qEVxcTEASLVo+95enru7OwYMGNCVQ7CasrIy3L59GwkJCdBqtdBqtZg2bRoAYOLEiZg8ebJd1aSjf+tCCDg4ONhVLYDW8x01apQs1q9fP/Tv3x+lpaUA7O9npo21xx0eHo4LFy5YfPCquLi4z70HE/UWbD67WHx8PPLz81FTUyPFsrOz0djYiPj4+G48M+tqbm7GM888g/Pnz+PYsWMICgqSbVepVIiJicH+/ftl8b179yIgIABRUVEAgHHjxsHT0xP79u2TclpaWrB//37Ex8f3mstko0aNQmFhoezrj3/8IwBg69ateOedd+yqJlOnTsW1a9fwzTffSLGqqiqUlJQgMjLSrmoBAEFBQTh37pwsdvXqVVRVVSE4OBiA/f3MtLH2uOPj46HX63H8+HEpr7KyEv/4xz+QkJBggxERkYVuWN7JrrQtMj9+/Hhx7Ngx8f777wtfX98+t8j83LlzBQCRlZUlioqKZF9ti2a3LRw9Z84cUVhYKNasWdPpwtEbN24UBQUFIjU1tccumP1LFBYWdrjIfF+vSXNzs3j44YfFQw89JPbt2yeys7NFVFSUGDBggDAajUII+6mFEEJs2bJFABC/+93vpEXmR40aJbRarbh8+bKU1xdrcvPmTXHgwAFx4MAB8fjjj4vAwEDpddt6wdYed0JCgujfv7/Yu3evyMnJEY888ggXmSfqRmw+beDChQsiNjZWuLq6Cl9fX7Fw4cI+96YXFBQkALT7VVhYKOXl5OSIyMhI4ezsLB588EHx9ttvWxzLbDaLrKwsMWjQIKFSqcSjjz4qCgoKbDiartFe8ymE/dTk2rVrIjU1VXh6egpXV1cRFxcnSkpKZDn2Uguz2Sz+8pe/iMjISKHRaIS/v7+YNm1au81iX6tJWVmZzd8rDAaDeO6554RWqxUajUZMmzZNlJeXd+UwiagTCiF62ArERERERNRn8Z5PIiIiIrIZNp9EREREZDNsPomIiIjIZth8EhEREZHNsPkkIiIiIpth80lERERENsPmk4iIiIhshs0nEckUFxfj9ddfR3l5eXefChER9UFsPolIpri4GKtWrWLzSUREXYLNJxERERHZDJtPoh7u9ddfh0KhwLfffouUlBR4enrC398f6enpMBgM93ycs2fPIjk5GcHBwXBxcUFwcDBSUlJQUVEh5ezcuRMzZswAAEycOBEKhQIKhQI7d+6Ucv76178iMjISarUa3t7eePrpp6HT6WR/VlpaGtzc3FBSUoIpU6ZAo9EgICAA69atAwCcOXMGEyZMgEajwZAhQ7Br167/oEJERNSbsPkk6iWSkpIwZMgQHDp0CMuXL8eePXuwePHie96/vLwcYWFh2LRpE44fP47169fjypUrGD16NK5fvw4ASEhIwJtvvgkA+POf/4yioiIUFRUhISEBALB27VpkZGQgIiIChw8fxubNm/HVV19h7Nix+O6772R/XlNTExITE5GQkIAPP/wQcXFxeOWVV/Dqq69i9uzZSE9PR3Z2NsLCwpCWloZz585ZqVJERNSjCSLq0TIzMwUAkZWVJYsvWLBAqNVqYTab7+u4zc3Nwmg0Co1GIzZv3izFDxw4IACIwsJCWX5dXZ1wcXER8fHxsvgPP/wgVCqVSE1NlWKzZ88WAMShQ4ekWFNTk+jXr58AIP71r39J8ZqaGuHo6CiWLFlyX+MgIqLehTOfRL3E9OnTZa9HjhwJk8mE6urqe9rfaDRi2bJlCA0NhVKphFKphJubG27evGlx2bw9RUVFaGhoQFpamiweGBiImJgY5Ofny+IKhQLx8fHSa6VSidDQUAQEBCAqKkqKe3t7w8/PT3b5n4iI+i5ld58AEd0bHx8f2WuVSgUAaGhouKf9U1NTkZ+fjxUrVmD06NHw8PCQGsR7OUZNTQ0AICAgwGJb//79ceLECVnM1dUVarVaFnN2doa3t7fF/s7OzjCZTPc0DiIi6t3YfBLZAYPBgKNHjyIzMxPLly+X4o2Njaitrb2nY7Q1v1euXLHYdvnyZfj6+lrnZImIqE/jZXciO6BQKCCEkGZL22zfvh0tLS2yWEczqmPHjoWLiws++OADWfzSpUsoKCjApEmTuuDMiYior+HMJ5Ed8PDwwGOPPYYNGzbA19cXwcHB+OSTT7Bjxw54eXnJcocPHw4A2LZtG9zd3aFWqxESEgIfHx+sWLECr776Kn77298iJSUFNTU1WLVqFdRqNTIzM7thZERE1Ntw5pPITuzZswcTJ07E0qVLkZiYiLNnz+LEiRPw9PSU5YWEhGDTpk04f/48Hn/8cYwePRoff/wxAOCVV17B9u3bcf78eTz11FN4/vnnERERgdOnT+Ohhx7qjmEREVEvoxBCiO4+CSIiIiKyD5z5JCIiIiKb4T2fRL2c2WyG2WzuNEep5I86ERH1DJz5JOrl0tPT4eTk1OkXERFRT8F7Pol6ufLycunZ7B159NFHbXQ2REREnWPzSUREREQ2w8vuRERERGQzbD6JiIiIyGbYfBIRERGRzbD5JCIiIiKbYfNJRERERDbD5pOIiIiIbIbNJxERERHZDJtPIiIiIrKZ/weS0uAV5qEyPgAAAABJRU5ErkJggg==\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 | --------------------------------------------------------------------------------