diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c42c246..f5eba3d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,7 @@ jobs: timeout-minutes: 30 strategy: matrix: - python-version: ["3.8"] + python-version: ["3.10"] aiida-version: ["stable"] services: @@ -50,22 +50,6 @@ jobs: PYTEST_ADDOPTS: "--durations=0" run: pytest --cov aiida_atomistic --cov-append . - docs: - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.8 - uses: actions/setup-python@v2 - with: - python-version: "3.8" - - name: Install python dependencies - run: | - pip install --upgrade pip - pip install -e .[docs] - - name: Build docs - run: cd docs && make - pre-commit: runs-on: ubuntu-latest timeout-minutes: 15 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index c18c3d0..1be769e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,10 +1,10 @@ # This file was created automatically with `myst init --gh-pages` 🪄 💚 -name: MyST GitHub Pages Deploy +name: aiida-atomistic GitHub Pages Deploy on: push: # Runs on pushes targeting the default branch - branches: [develop] + branches: [main, develop] env: # `BASE_URL` determines the website is served from, including CSS & JS assets # You may need to change this to `BASE_URL: ''` @@ -37,10 +37,11 @@ jobs: run: npm install -g mystmd - name: Build HTML Assets run: myst build --html + working-directory: ./docs - name: Upload artifact uses: actions/upload-pages-artifact@v1 with: - path: './_build/html' + path: './docs/_build/html' - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v2 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..1924ee4 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,8 @@ +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v3.2.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files diff --git a/README.md b/README.md index 6c1a26f..3f16317 100644 --- a/README.md +++ b/README.md @@ -7,33 +7,13 @@ AiiDA plugin which contains data and methods for atomistic simulations. - ## Installation ```shell git clone https://github.com/aiidateam/aiida-atomistic . pip install ./aiida-atomistic verdi quicksetup # better to set up a new profile -verdi plugin list aiida.calculations # should now show your calclulation plugins -``` - - -## Usage - -Here goes a complete example of how to submit a test calculation using this plugin. - -A quick demo of how to submit a calculation: -```shell -verdi daemon start # make sure the daemon is running -cd examples -./example_01.py # run test calculation -verdi process list -a # check record of calculation -``` - -The plugin also includes verdi commands to inspect its data types: -```shell -verdi data atomistic list -verdi data atomistic export +verdi plugin list aiida.data # should now show your data plugins ``` ## Development @@ -46,7 +26,6 @@ pip install -e .[pre-commit,testing] # install extra dependencies pre-commit install # install pre-commit hooks pytest -v # discover and run all tests ``` - See the [developer guide](http://aiida-atomistic.readthedocs.io/en/latest/developer_guide/index.html) for more information. ## License diff --git a/conftest.py b/conftest.py index a268307..485d34d 100644 --- a/conftest.py +++ b/conftest.py @@ -29,7 +29,7 @@ def supported_properties(): "charge", "magmom", "kind_name", - "weight", + "weights", ] @@ -48,7 +48,8 @@ def example_structure_dict(): "position": [0.0, 0.0, 0.0], "mass": 63.546, "charge": 1.0, - "magmom": [0,0,0], + "magmom": [0.0,0.0,0.0], + "weights": (1,) } ], } @@ -117,16 +118,31 @@ def example_structure_dict_for_kinds(): [-1.7391821518091137e-16, 2.8403, 1.7391821518091137e-16], [0.0, 0.0, 2.8403]], 'sites': [{'symbol': 'Fe', - 'weights': 55.845, + 'mass': 55.845, 'position': [0.0, 0.0, 0.0], 'charge': 0.0, 'magmom': [2.5, 0.1, 0.1], 'kind_name': 'Fe'}, {'symbol': 'Fe', - 'weights': 55.845, + 'mass': 55.845, 'position': [1.42015, 1.42015, 1.4201500000000002], 'charge': 0.0, 'magmom': [2.4, 0.1, 0.1], 'kind_name': 'Fe'}]} return structure_dict + +@pytest.fixture +def example_structure_dict_alloy(): + """ + Return the dictionary of properties as to be used in the standards tests. + """ + structure_dict ={ + 'pbc': [True, True, True], + 'cell': [[0.0, 1.8, 1.8], [1.8, 0.0, 1.8], [1.8, 1.8, 0.0]], + 'sites': [{'symbol': 'CuAl', + 'position': [0.0, 0.0, 0.0], + 'weights': (0.5,0.5) + }],} + + return structure_dict diff --git a/docs/.gitignore b/docs/.gitignore index a997a7b..c52d00b 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -1,2 +1,2 @@ # MyST build outputs -/_build/ \ No newline at end of file +/_build/ diff --git a/docs/01-structuredata.ipynb b/docs/01-structuredata.ipynb index 3f50d83..90f6395 100644 --- a/docs/01-structuredata.ipynb +++ b/docs/01-structuredata.ipynb @@ -5,34 +5,30 @@ "metadata": {}, "source": [ "---\n", - "title: atomistic.StructureData\n", - "#subtitle: Evolve markdown documents and notebooks into structured data\n", - "author:\n", - " - name: Miki Bonacci\n", + "title: StructureData\n", + "subtitle: A mutable and an immutable StructureData class\n", + "#author:\n", + "# - name: Miki Bonacci\n", " #affiliations: Executable Books; Curvenote\n", " #orcid: 0000-0002-7859-8394\n", - " email: miki.bonacci@psi.ch\n", + "# email: miki.bonacci@psi.ch\n", "license:\n", " code: MIT\n", "#date: 2023/01/23\n", "---\n", "\n", - "\n", - "## A mutable and an immutable `StructureData` classes\n", - "\n", "In `aiida-atomistic` we provide two StructureData classes: \n", "\n", - "1. `StructureData`, the AiiDA data type used to store a structure in the AiiDA database. It is immutable, i.e. after the initialization we cannot modify it. It is just a container for structure information;\n", + "1. `StructureData`, the AiiDA data type used to store a structure in the AiiDA database. It is ***immutable***, i.e. after the initialization we cannot modify it. It is just a container for structure information;\n", "2. `StructureDataMutable`, a python class used to manipulate a structure object before to transform it into the immutable `StructureData`;\n", " \n", - "With respect to the `orm.StructureData`, here we provide additional properties to be attached to the structure, e.g. *charges*, *magmoms*. Kind based definition of the sites is dropped, and the *kind_name* is attached to each site as a property. \n", - "The two `StructureData` and `StructureDataMutable` shares the same data structure; the difference is that the latter can be modified by the user after its initialization, and not strict validation checks are done, at variance with the `StructureData`. Moreover `StructureDataMutable` will have additional `set_*` methods to help the user to update the structure. \n", + "With respect to `orm.StructureData`, here we provide additional properties to be attached to the structure, e.g. *charges*, *magmoms*. Kind based definition of the sites is dropped, and the *kind_name* is attached to each site as a property. \n", + "The two `StructureData` and `StructureDataMutable` share the same data structure; the difference is that the latter can be modified by the user after its initialization, and not strict validation checks are done, at variance with the `StructureData`. Moreover `StructureDataMutable` will have additional `set_*` methods to help the user to update the structure. \n", "The properties are stored under the `properties` attribute of the structure, which is a *pydantic* `BaseModel` subclass, for data validation. \n", "\n", - "\n", "## `StructureData`(s) initialization\n", "\n", - "As both `StructureData` and `StructureDataMutable` share the same data structure, they also share the same inputs for the constructor: a python dictionary. The format of this dictionary exactly reflects how the data are stored in the AiiDA database:" + "As both `StructureData` and `StructureDataMutable` share the same data structure, they also share the same inputs for the constructor: a python dictionary. The format of this dictionary exactly reflects how data are stored in the AiiDA database:" ] }, { @@ -50,8 +46,8 @@ "Mutable cell: [[2.75, 2.75, 0.0], [0.0, 2.75, 2.75], [2.75, 0.0, 2.75]]\n", "Immutable sites: [, ]\n", "Mutable sites: [, ]\n", - "First immutable site: {'symbol': 'Si', 'kind_name': 'Si', 'position': [0.75, 0.75, 0.75], 'mass': 28.0855, 'charge': 0, 'magmom': [0.0, 0.0, 0.0]}\n", - "First mutable site: {'symbol': 'Si', 'kind_name': 'Si', 'position': [0.75, 0.75, 0.75], 'mass': 28.0855, 'charge': 0, 'magmom': [0.0, 0.0, 0.0]}\n" + "First immutable site: {'symbol': 'Si', 'kind_name': 'Si', 'position': [0.75, 0.75, 0.75], 'mass': 28.0855, 'charge': 0, 'magmom': [0.0, 0.0, 0.0], 'weights': (1,)}\n", + "First mutable site: {'symbol': 'Si', 'kind_name': 'Si', 'position': [0.75, 0.75, 0.75], 'mass': 28.0855, 'charge': 0, 'magmom': [0.0, 0.0, 0.0], 'weights': (1,)}\n" ] } ], @@ -96,9 +92,9 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "As we provide the `structure_dict` to the constructor of our two structure data classes, it is immediately used to feed the `properties` model. Each site is represented as `SiteMutable` (`SiteImmutable`) for the mutable (immutable) case. Mutability (immutability) is inherited from the corresponding StructureData class used.\n", + "As we provide the `structure_dict` to the constructor of our two structure data classes, it is immediately used to feed the `properties` model. Each site is store as `SiteMutable` (`SiteImmutable`) object for the mutable (immutable) case. Mutability (immutability) is inherited from the corresponding StructureData class used.\n", "\n", - "The full list of properties can be visualized using the `to_dict()` method of the structure:" + "The full list of properties can be visualized using the `to_dict` method of the structure:" ] }, { @@ -111,18 +107,23 @@ "text/plain": [ "{'pbc': [True, True, True],\n", " 'cell': [[2.75, 2.75, 0.0], [0.0, 2.75, 2.75], [2.75, 0.0, 2.75]],\n", + " 'tot_charge': None,\n", + " 'tot_magnetization': None,\n", + " 'custom': None,\n", " 'sites': [{'symbol': 'Si',\n", " 'kind_name': 'Si',\n", " 'position': [0.75, 0.75, 0.75],\n", " 'mass': 28.0855,\n", " 'charge': 0,\n", - " 'magmom': [0.0, 0.0, 0.0]},\n", + " 'magmom': [0.0, 0.0, 0.0],\n", + " 'weights': (1,)},\n", " {'symbol': 'Si',\n", " 'kind_name': 'Si',\n", " 'position': [0.5, 0.5, 0.5],\n", " 'mass': 28.0855,\n", " 'charge': 0,\n", - " 'magmom': [0.0, 0.0, 0.0]}],\n", + " 'magmom': [0.0, 0.0, 0.0],\n", + " 'weights': (1,)}],\n", " 'cell_volume': 41.59375,\n", " 'dimensionality': {'dim': 3, 'label': 'volume', 'value': 41.59375},\n", " 'charges': [0, 0],\n", @@ -149,6 +150,12 @@ "source": [ "We can see that some properties are generated automatically, like *kinds*, *charges*, *dimensionality* and so on, and some other properties are set by default if not provided, e.g. the *kind_name* of each site.\n", "\n", + ":::{note}\n", + "To visualize the list of properties that you can set, use the `get_property_names` method of the structure classes. This provides a dictionary with three objects, *direct*, *computed* and *sites*: each of them shows properties which are defined directly, computed when the structure is initialized and properties which can be defined for each site.\n", + "To visualize the list of *defined* properties for the structure, you can use the corresponding `get_defined_properties`.\n", + "\n", + "The `to_dict` method is nothing else than a wrapper for the *BaseModel* `model_dump` method of the *properties* attribute.\n", + ":::\n", "\n", "### Initialization from ASE or Pymatgen\n", "\n", @@ -166,12 +173,16 @@ "text/plain": [ "{'pbc': [True, True, True],\n", " 'cell': [[0.0, 1.8, 1.8], [1.8, 0.0, 1.8], [1.8, 1.8, 0.0]],\n", + " 'tot_charge': None,\n", + " 'tot_magnetization': None,\n", + " 'custom': None,\n", " 'sites': [{'symbol': 'Cu',\n", " 'kind_name': 'Cu2',\n", " 'position': [0.0, 0.0, 0.0],\n", " 'mass': 63.546,\n", " 'charge': 1.0,\n", - " 'magmom': [0.0, 0.0, 0.0]}],\n", + " 'magmom': [0.0, 0.0, 0.0],\n", + " 'weights': (1.0,)}],\n", " 'cell_volume': 11.664000000000001,\n", " 'dimensionality': {'dim': 3, 'label': 'volume', 'value': 11.664000000000001},\n", " 'charges': [1.0],\n", @@ -217,29 +228,34 @@ "text/plain": [ "{'pbc': [True, True, True],\n", " 'cell': [[3.84, 0.0, 2.351321854362918e-16],\n", - " [1.92, 2.7152900397563426, -1.919999999999999],\n", + " [1.92, 2.7152900397563426, -1.9199999999999993],\n", " [0.0, 0.0, 3.84]],\n", + " 'tot_charge': None,\n", + " 'tot_magnetization': None,\n", + " 'custom': None,\n", " 'sites': [{'symbol': 'Si',\n", " 'kind_name': 'Si',\n", " 'position': [0.0, 0.0, 0.0],\n", " 'mass': 28.0855,\n", - " 'charge': 0.0,\n", - " 'magmom': [0.0, 0.0, 0.0]},\n", + " 'charge': 1.0,\n", + " 'magmom': [0.0, 0.0, 0.0],\n", + " 'weights': (1,)},\n", " {'symbol': 'Si',\n", - " 'kind_name': 'Si0',\n", - " 'position': [3.84, 1.3576450198781713, 1.9200000000000006],\n", + " 'kind_name': 'Si',\n", + " 'position': [3.84, 1.3576450198781713, 1.9200000000000004],\n", " 'mass': 28.0855,\n", " 'charge': 0.0,\n", - " 'magmom': [0.0, 0.0, 0.0]}],\n", + " 'magmom': [0.0, 0.0, 0.0],\n", + " 'weights': (1,)}],\n", " 'cell_volume': 40.038580810231124,\n", " 'dimensionality': {'dim': 3, 'label': 'volume', 'value': 40.038580810231124},\n", - " 'charges': [0.0, 0.0],\n", + " 'charges': [1.0, 0.0],\n", " 'magmoms': [[0.0, 0.0, 0.0], [0.0, 0.0, 0.0]],\n", " 'masses': [28.0855, 28.0855],\n", - " 'kinds': ['Si', 'Si0'],\n", + " 'kinds': ['Si', 'Si'],\n", " 'symbols': ['Si', 'Si'],\n", " 'positions': [[0.0, 0.0, 0.0],\n", - " [3.84, 1.3576450198781713, 1.9200000000000006]],\n", + " [3.84, 1.3576450198781713, 1.9200000000000004]],\n", " 'formula': 'Si2'}" ] }, @@ -256,7 +272,7 @@ " beta=90, gamma=60)\n", "struct = Structure(lattice, [\"Si\", \"Si\"], coords)\n", "\n", - "struct.add_oxidation_state_by_site([1,0])\n", + "struct.sites[0].properties[\"charge\"]=1\n", "\n", "mutable_structure = StructureDataMutable.from_pymatgen(struct)\n", "structure = StructureData.from_pymatgen(struct)\n", @@ -281,7 +297,7 @@ { "data": { "text/plain": [ - "" + "" ] }, "execution_count": 5, @@ -299,9 +315,9 @@ "metadata": {}, "source": [ "\n", - "## Mutation of a `StructureDataMutable` instance\n", + "## Mutation of a `StructureData` instance\n", "\n", - "Let's suppose you want to update some property in the `StructureData` before to use it in a calculation. You cannot. The way to go is either to use ASE or Pymatgen to modify you object and store it back into `StructureData`, or to use the `StructureDataMutable` and its mutation methods, and then convert it into `StructureData`.\n", + "Let's suppose you want to update some property in the `StructureData` before to use it in a calculation. You cannot. The way to go is either to use ASE or Pymatgen to modify your object and store it back into `StructureData`, or to use the `StructureDataMutable` and its mutation methods, and then convert it into `StructureData`.\n", "The latter method is the preferred one, as you then have support also for additional properties (to be implemented) like hubbard, which is not supported in ASE and Pymatgen.\n", "\n", "`StructureDataMutable` properties can be modified directly, but also the class contains several `set_` methods and more, needed to update a structure. Let's suppose we start from an immutable `StructureData` and we want to update the charges (and the corresponding kinds):" @@ -336,8 +352,17 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "I is also possible to `add_atom`, `pop_atom`, `update_site` and so on.\n", - "Indeed, we can also start from scratch:" + ":::{note} Keeping the provenance\n", + "When starting from a `StructureData`, passing to a `StructureDataMutable` and then generating a new modified `StructureData`, we lose provenance. To keep it, we should do the modification by means of an AiiDA [*calcfunction*](https://aiida.readthedocs.io/projects/aiida-core/en/latest/topics/calculations/concepts.html#calculation-functions), which takes as input(output) the starting(modified) `StructureData`.\n", + ":::" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "It is also possible to `add_atom`, `pop_atom`, `update_site` and so on.\n", + "Indeed, we can start from and empty `StructureDataMutable` (i.e., from scratch):" ] }, { @@ -345,27 +370,40 @@ "execution_count": 7, "metadata": {}, "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/Users/mbonacci/Documents/codes/aiida-atomistic/src/aiida_atomistic/data/structure/models.py:170: UserWarning: using default cell\n", + " warnings.warn(\"using default cell\")\n" + ] + }, { "data": { "text/plain": [ "{'pbc': [True, True, True],\n", " 'cell': [[0.0, 1.8, 1.8], [1.8, 0.0, 1.8], [1.8, 1.8, 0.0]],\n", + " 'tot_charge': None,\n", + " 'tot_magnetization': None,\n", + " 'custom': None,\n", " 'sites': [{'symbol': 'Si',\n", " 'kind_name': 'Si2',\n", " 'position': [0.75, 0.75, 0.75],\n", " 'mass': 28.0855,\n", " 'charge': 1.0,\n", - " 'magmom': [0.0, 0.0, 0.0]},\n", + " 'magmom': None,\n", + " 'weights': (1,)},\n", " {'symbol': 'Si',\n", " 'kind_name': 'Si1',\n", " 'position': [0.5, 0.5, 0.5],\n", " 'mass': 28.0855,\n", " 'charge': 0.0,\n", - " 'magmom': [0.0, 0.0, 0.0]}],\n", + " 'magmom': None,\n", + " 'weights': (1,)}],\n", " 'cell_volume': 11.664000000000001,\n", " 'dimensionality': {'dim': 3, 'label': 'volume', 'value': 11.664000000000001},\n", " 'charges': [1.0, 0.0],\n", - " 'magmoms': [[0.0, 0.0, 0.0], [0.0, 0.0, 0.0]],\n", + " 'magmoms': [None, None],\n", " 'masses': [28.0855, 28.0855],\n", " 'kinds': ['Si2', 'Si1'],\n", " 'symbols': ['Si', 'Si'],\n", @@ -404,38 +442,33 @@ "source": [ "## Slicing a structure\n", "\n", - "It is possible to *slice* a structure, i.e. returning only a part of it (in terms of sites). Let's suppose that you have an heterostructure and you want to obtain only the first layer, composed of the first 4 atoms over 10 total. This works for both `StructureDataMutable` and `StructureData` (we return a new `StructureData` instance)." + "It is possible to *slice* a structure, i.e. returning only a part of it (in terms of sites). The method returns a new sliced `StructureDataMutable` (`StructureData`) instance." ] }, { "cell_type": "code", "execution_count": 8, "metadata": {}, - "outputs": [], - "source": [ - "sliced_structure = mutable_structure[:1]" - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "metadata": {}, "outputs": [ { "data": { "text/plain": [ "{'pbc': [True, True, True],\n", " 'cell': [[0.0, 1.8, 1.8], [1.8, 0.0, 1.8], [1.8, 1.8, 0.0]],\n", + " 'tot_charge': None,\n", + " 'tot_magnetization': None,\n", + " 'custom': None,\n", " 'sites': [{'symbol': 'Si',\n", " 'kind_name': 'Si2',\n", " 'position': [0.75, 0.75, 0.75],\n", " 'mass': 28.0855,\n", " 'charge': 1.0,\n", - " 'magmom': [0.0, 0.0, 0.0]}],\n", + " 'magmom': None,\n", + " 'weights': (1.0,)}],\n", " 'cell_volume': 11.664000000000001,\n", " 'dimensionality': {'dim': 3, 'label': 'volume', 'value': 11.664000000000001},\n", " 'charges': [1.0],\n", - " 'magmoms': [[0.0, 0.0, 0.0]],\n", + " 'magmoms': [None],\n", " 'masses': [28.0855],\n", " 'kinds': ['Si2'],\n", " 'symbols': ['Si'],\n", @@ -443,12 +476,13 @@ " 'formula': 'Si'}" ] }, - "execution_count": 9, + "execution_count": 8, "metadata": {}, "output_type": "execute_result" } ], "source": [ + "sliced_structure = mutable_structure[:1]\n", "sliced_structure.to_dict()" ] }, @@ -456,15 +490,18 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## Automatic kinds generation - TOBE further tested\n", + "## Automatic kinds generation\n", + "\n", + "It is possible to automatically detect kinds when initializing the structure from ASE or Pymatgen. Moreover, the kind can be also generated during the `to_dict` call, such that our output_dictionary will already have the detected kinds. In summary, we can generate our StructureData/StructureDataMutable with automatic kind detection in these three ways:\n", "\n", - "It is possible to generate the kind_names and the corresponding mapped properties for a given structure. \n", - "You can do it by using the `get_kinds` method. By setting `ready_to_use`to True, we provide a list of sites ready to be used in our structure." + "1. new_structuredata = StructureData.from_ase(ase_structure, detect_kinds=True)\n", + "2. new_structuredata = StructureData.from_pymatgen(pymatgen_structure, detect_kinds=True)\n", + "3. new_structuredata = StructureData(**old_structuredata.to_dict(detect_kinds=True))" ] }, { "cell_type": "code", - "execution_count": 10, + "execution_count": 9, "metadata": {}, "outputs": [ { @@ -474,18 +511,23 @@ " 'cell': [[2.8403, 0.0, 1.7391821518091137e-16],\n", " [-1.7391821518091137e-16, 2.8403, 1.7391821518091137e-16],\n", " [0.0, 0.0, 2.8403]],\n", + " 'tot_charge': None,\n", + " 'tot_magnetization': None,\n", + " 'custom': None,\n", " 'sites': [{'symbol': 'Fe',\n", " 'kind_name': 'Fe0',\n", " 'position': [0.0, 0.0, 0.0],\n", " 'mass': 55.845,\n", " 'charge': 0.0,\n", - " 'magmom': [2.5, 0.1, 0.1]},\n", + " 'magmom': [2.5, 0.1, 0.1],\n", + " 'weights': (1.0,)},\n", " {'symbol': 'Fe',\n", " 'kind_name': 'Fe1',\n", " 'position': [1.42015, 1.42015, 1.4201500000000002],\n", " 'mass': 55.845,\n", " 'charge': 0.0,\n", - " 'magmom': [2.4, 0.1, 0.1]}],\n", + " 'magmom': [2.4, 0.1, 0.1],\n", + " 'weights': (1.0,)}],\n", " 'cell_volume': 22.913563806827,\n", " 'dimensionality': {'dim': 3, 'label': 'volume', 'value': 22.913563806827},\n", " 'charges': [0.0, 0.0],\n", @@ -497,38 +539,32 @@ " 'formula': 'Fe2'}" ] }, - "execution_count": 10, + "execution_count": 9, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "import copy\n", - "\n", "Fe_BCC_dictionary = {'pbc': (True, True, True),\n", " 'cell': [[2.8403, 0.0, 1.7391821518091137e-16],\n", " [-1.7391821518091137e-16, 2.8403, 1.7391821518091137e-16],\n", " [0.0, 0.0, 2.8403]],\n", " 'sites': [{'symbol': 'Fe',\n", - " 'weights': 55.845,\n", + " 'mass': 55.845,\n", " 'position': [0.0, 0.0, 0.0],\n", " 'charge': 0.0,\n", " 'magmom': [2.5, 0.1, 0.1],\n", " 'kind_name': 'Fe'},\n", " {'symbol': 'Fe',\n", - " 'weights': 55.845,\n", + " 'mass': 55.845,\n", " 'position': [1.42015, 1.42015, 1.4201500000000002],\n", " 'charge': 0.0,\n", " 'magmom': [2.4, 0.1, 0.1],\n", " 'kind_name': 'Fe'}]}\n", "\n", "mutable_structure = StructureDataMutable(**Fe_BCC_dictionary)\n", - "new_sites = mutable_structure.get_kinds(ready_to_use=True)\n", "\n", - "new_Fe_BCC_dictionary = copy.deepcopy(Fe_BCC_dictionary)\n", - "new_Fe_BCC_dictionary['sites'] = new_sites\n", - "\n", - "new_mutable_structure = StructureDataMutable(**new_Fe_BCC_dictionary)\n", + "new_mutable_structure = StructureDataMutable(**mutable_structure.to_dict(detect_kinds=True))\n", "new_mutable_structure.to_dict()" ] }, @@ -541,7 +577,7 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": 10, "metadata": {}, "outputs": [ { @@ -551,18 +587,23 @@ " 'cell': [[2.8403, 0.0, 1.7391821518091137e-16],\n", " [-1.7391821518091137e-16, 2.8403, 1.7391821518091137e-16],\n", " [0.0, 0.0, 2.8403]],\n", + " 'tot_charge': None,\n", + " 'tot_magnetization': None,\n", + " 'custom': None,\n", " 'sites': [{'symbol': 'Fe',\n", " 'kind_name': 'Fe0',\n", " 'position': [0.0, 0.0, 0.0],\n", " 'mass': 55.845,\n", " 'charge': 0.0,\n", - " 'magmom': [2.5, 0.1, 0.1]},\n", + " 'magmom': [2.5, 0.1, 0.1],\n", + " 'weights': (1,)},\n", " {'symbol': 'Fe',\n", " 'kind_name': 'Fe1',\n", " 'position': [1.42015, 1.42015, 1.4201500000000002],\n", " 'mass': 55.845,\n", " 'charge': 0.0,\n", - " 'magmom': [2.4, 0.1, 0.1]}],\n", + " 'magmom': [2.4, 0.1, 0.1],\n", + " 'weights': (1,)}],\n", " 'cell_volume': 22.913563806827,\n", " 'dimensionality': {'dim': 3, 'label': 'volume', 'value': 22.913563806827},\n", " 'charges': [0.0, 0.0],\n", @@ -574,26 +615,17 @@ " 'formula': 'Fe2'}" ] }, - "execution_count": 11, + "execution_count": 10, "metadata": {}, "output_type": "execute_result" } ], "source": [ "mutable_structure.clear_sites()\n", - "for site in new_sites:\n", + "for site in new_mutable_structure.to_dict()['sites']:\n", " mutable_structure.add_atom(site)\n", " \n", - "new_mutable_structure.to_dict()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Backward compatibility support\n", - "\n", - "We can use the `to_legacy` method to return the corresponding `orm.StructureData` instance, in case a given plugin does not yet support the new `StructureData`.\n" + "mutable_structure.to_dict()" ] }, { @@ -607,15 +639,15 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": 11, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "386\n", - "[[], [], []]\n" + "13\n", + "[]\n" ] } ], @@ -630,7 +662,133 @@ " filters={'attributes.formula': 'Fe2'},\n", " )\n", "\n", - "print(qb.all())" + "print(qb.all()[-1])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## How to define alloys and deal with vacancies\n", + "\n", + "It is possible to define more than one element for a given site, i.e. to define an *alloy*. This can be done by providing as symbol the combination of the symbols, and also the corresponding *weights* tuple:" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'symbol': 'CuAl',\n", + " 'kind_name': 'CuAl',\n", + " 'position': [0.0, 0.0, 0.0],\n", + " 'mass': 45.263768999999996,\n", + " 'charge': 0,\n", + " 'magmom': [0.0, 0.0, 0.0],\n", + " 'weights': (0.5, 0.5)}" + ] + }, + "execution_count": 12, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "structure = StructureDataMutable(**{'pbc': [True, True, True],\n", + " 'cell': [[0.0, 1.8, 1.8], [1.8, 0.0, 1.8], [1.8, 1.8, 0.0]],\n", + " 'sites': [{'symbol': 'CuAl',\n", + " 'position': [0.0, 0.0, 0.0],\n", + " 'weights': (0.5,0.5)\n", + " }],})\n", + "\n", + "structure.properties.sites[0].dict()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "if not provided, the mass is computed accordingly to the symbols and weights. Vacancies are detected when the sum of the weights is less than 1." + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "True\n", + "False\n" + ] + } + ], + "source": [ + "print(structure.is_alloy)\n", + "print(structure.has_vacancies)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## How to add custom properties\n", + "\n", + "It is possible to add custom properties at the `StructureData` level (not at the `Site` level). To do that, it is sufficient to put the corresponding property under the `custom` Field, a dictionary which should contain the custom property names as keys, followed by the corresponding value:" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'electronic_type': 'metal'}" + ] + }, + "execution_count": 14, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "structure = StructureData(**{'pbc': [True, True, True],\n", + " 'cell': [[0.0, 1.8, 1.8], [1.8, 0.0, 1.8], [1.8, 1.8, 0.0]],\n", + " 'sites': [{'symbol': 'Cu',\n", + " 'position': [0.0, 0.0, 0.0],\n", + " }],\n", + " 'custom': {\n", + " 'electronic_type': 'metal',\n", + " }\n", + " })\n", + "\n", + "structure.properties.custom" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + ":::{note}\n", + ":class: dropdown\n", + "Automatic serialization of the custom properties is done when the model is dumped (e.g. when the structure is stored in the AiiDA database). If serialization is not possible, an error is retrieved.\n", + ":::" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Backward compatibility support\n", + "\n", + "We can use the `to_legacy` method to return the corresponding `orm.StructureData` instance starting from a `StructureData`or `StructureDataMutable` instance, if a given plugin does not yet support the new `StructureData`.\n" ] } ], diff --git a/docs/README.md b/docs/README.md index b24b85c..9890694 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,4 +1,4 @@ -# `aiida-atomistic` package +# `aiida-atomistic` package AiiDA plugin which contains data and methods for atomistic simulations within AiiDA. @@ -10,28 +10,10 @@ AiiDA plugin which contains data and methods for atomistic simulations within Ai git clone https://github.com/aiidateam/aiida-atomistic . pip install ./aiida-atomistic verdi quicksetup # better to set up a new profile -verdi plugin list aiida.calculations # should now show your calclulation plugins +verdi plugin list aiida.data # should now show your data plugins ``` -## Usage - -Here goes a complete example of how to submit a test calculation using this plugin. - -A quick demo of how to submit a calculation: -```shell -verdi daemon start # make sure the daemon is running -cd examples -./example_01.py # run test calculation -verdi process list -a # check record of calculation -``` - -The plugin also includes verdi commands to inspect its data types: -```shell -verdi data atomistic list -verdi data atomistic export -``` - ## Development ```shell @@ -43,12 +25,10 @@ pre-commit install # install pre-commit hooks pytest -v # discover and run all tests ``` -See the [developer guide](http://aiida-atomistic.readthedocs.io/en/latest/developer_guide/index.html) for more information. - ## License MIT ## Contact -mikibonacci@psi.ch \ No newline at end of file +mikibonacci@psi.ch diff --git a/docs/myst.yml b/docs/myst.yml index e370aea..cd1bb61 100644 --- a/docs/myst.yml +++ b/docs/myst.yml @@ -12,7 +12,7 @@ project: # Auto-generated by `myst init --write-toc` - file: README.md - file: 01-structuredata.ipynb - + site: template: book-theme # options: diff --git a/examples/proof_of_concept.ipynb b/examples/proof_of_concept.ipynb deleted file mode 100644 index b8bc239..0000000 --- a/examples/proof_of_concept.ipynb +++ /dev/null @@ -1,559 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "2e6c3fdb", - "metadata": {}, - "source": [ - "# The new `StructureData` API" - ] - }, - { - "cell_type": "markdown", - "id": "e1173c2e", - "metadata": {}, - "source": [ - "# The `atomistic.StructureData` and `atomistic.StructureDataMutable` classes" - ] - }, - { - "cell_type": "markdown", - "id": "a5a22af6", - "metadata": { - "lines_to_next_cell": 2 - }, - "source": [ - "Two main rules: (i) immutability, and (ii) site-based. This means that our node will be just a container of the crystal structure + properties, and it cannot really be modified in any way.\n", - "We will only provide `from_*`, `get_*` and `to_*` methods. Each site property will be defined as site-based and not kind-based, at variance with the old `orm.StructureData`. Kinds now can be defined as a property of each site (`kind_name`).\n", - "The idea is to provide another python class which is just the mutable version of the `atomistic.StructureData` used to build, manipulate the crystal structure before the effective AiiDA node initialization. For now, let's call this class `StructureDataMutable`. This two classes have the same data structure, i.e. the same `properties` and the same `from_*`, `get_*` and `to_*` methods. The only difference is that the `atomistic.StructureDataMutable` has also `set_*` methods which can be used to mutate the properties. **Rule**: no property can be modified directly (i.e. accessing it); this is useful to avoid the introduction of inconsistencies in the structure instance." - ] - }, - { - "cell_type": "markdown", - "id": "113769a0", - "metadata": {}, - "source": [ - "# How to initialize the `StructureData`(s)" - ] - }, - { - "cell_type": "markdown", - "id": "578596c8", - "metadata": {}, - "source": [ - "As both `StructureData` and `StructureDataMutable` share the same data structure, they also share the same constructor input parameter, which is just a python dictionary. The format of this dictionary exactly reflects how the data are store in the AiiDA database:" - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "id": "6d343305", - "metadata": {}, - "outputs": [], - "source": [ - "from aiida_atomistic import StructureData\n", - "from aiida_atomistic import StructureDataMutable\n", - "\n", - "from aiida import load_profile\n", - "load_profile()\n", - "\n", - "structure_dict = {\n", - " 'cell':[[2.75,2.75,0],[0,2.75,2.75],[2.75,0,2.75]],\n", - " 'pbc': [True,True,True],\n", - " 'sites':[\n", - " {\n", - " 'symbol':'Si',\n", - " 'position':[3/4, 3/4, 3/4],\n", - " },\n", - " {\n", - " 'symbol':'Si',\n", - " 'position':[1/2, 1/2, 1/2],\n", - " },\n", - " ],\n", - "}\n", - "\n", - "mutable_structure = StructureDataMutable(**structure_dict)\n", - "structure = StructureData(**structure_dict)" - ] - }, - { - "cell_type": "markdown", - "id": "4a1dffce", - "metadata": {}, - "source": [ - "When this dictionary is provided to the constructor, validation check for each of the provided property is done (**for now, only pbc and cell**).\n", - "Then, you can access the properties directly:" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "b2e81a11", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "immutable pbc: [ True True True]\n", - "mutable pbc: [ True True True]\n", - "immutable cell: [[2.75 2.75 0. ]\n", - " [0. 2.75 2.75]\n", - " [2.75 0. 2.75]]\n", - "mutable cell: [[2.75 2.75 0. ]\n", - " [0. 2.75 2.75]\n", - " [2.75 0. 2.75]]\n", - "immutable sites: [, ]\n", - "mutable sites: [, ]\n" - ] - } - ], - "source": [ - "print(\"immutable pbc: \", structure.pbc)\n", - "print(\"mutable pbc: \", structure.pbc)\n", - "\n", - "print(\"immutable cell: \", structure.cell)\n", - "print(\"mutable cell: \", structure.cell)\n", - "\n", - "print(\"immutable sites: \", structure.sites)\n", - "print(\"mutable sites: \", structure.sites)" - ] - }, - { - "cell_type": "markdown", - "id": "f6b09e89", - "metadata": {}, - "source": [ - "the expected output is:\n", - "\n", - "immutable pbc: [ True True True]\n", - "\n", - "mutable pbc: [ True True True]\n", - "\n", - "immutable cell: [[2.75 2.75 0. ]\n", - " [0. 2.75 2.75]\n", - " [2.75 0. 2.75]]\n", - " \n", - "mutable cell: [[2.75 2.75 0. ]\n", - " [0. 2.75 2.75]\n", - " [2.75 0. 2.75]]\n", - " \n", - "immutable sites: [, ]\n", - "\n", - "mutable sites: [, ]" - ] - }, - { - "cell_type": "markdown", - "id": "60dc7b40", - "metadata": {}, - "source": [ - "To inspect the properties of a single site, we can access it:" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "63a319ff", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Si [0.75 0.75 0.75]\n" - ] - } - ], - "source": [ - "print(structure.sites[0].symbol,structure.sites[0].position) # output: Si [0.75 0.75 0.75]" - ] - }, - { - "cell_type": "markdown", - "id": "70e0184e", - "metadata": {}, - "source": [ - "All the properties can be accessed via tab completion, and a list of the supported properties can be accessed via `structure.get_property_names()`.\n", - "For now, other supported properties are `charge` (not yet `tot_charge`), `kind_name`, `mass`.\n", - "For example, we can initialize a charged structure in this way:" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "1a15e6d3", - "metadata": {}, - "outputs": [], - "source": [ - "structure_dict = {\n", - " 'cell':[[2.75,2.75,0],[0,2.75,2.75],[2.75,0,2.75]],\n", - " 'pbc': [True,True,True],\n", - " 'sites':[\n", - " {\n", - " 'symbol':'Si',\n", - " 'position':[3/4, 3/4, 3/4],\n", - " 'charge': +1,\n", - " 'kind_name': 'Si2',\n", - " },\n", - " {\n", - " 'symbol':'Si',\n", - " 'position':[1/2, 1/2, 1/2],\n", - " 'kind_name': 'Si1',\n", - " },\n", - " ],\n", - "}\n", - "\n", - "mutable_structure = StructureDataMutable(**structure_dict)\n", - "structure = StructureData(**structure_dict)" - ] - }, - { - "cell_type": "markdown", - "id": "8512ab4b", - "metadata": {}, - "source": [ - "then, `structure.sites[0].charge` will be equal to 1. When the plugins will be adapted, with this information we can build the correct input file for the corresponding quantum engine." - ] - }, - { - "cell_type": "markdown", - "id": "676d82e8", - "metadata": {}, - "source": [ - "## Initialization from ASE or Pymatgen" - ] - }, - { - "cell_type": "markdown", - "id": "cc2e9e11", - "metadata": {}, - "source": [ - "If we already have an ASE Atoms or a Pymatgen Structure object, we can use the `from_ase` and `from_pymatgen` methods:" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "14a67fdc", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'pbc': (True, True, True),\n", - " 'cell': [[0.0, 1.8, 1.8], [1.8, 0.0, 1.8], [1.8, 1.8, 0.0]],\n", - " 'sites': [{'symbol': 'Cu',\n", - " 'kind_name': 'Cu2',\n", - " 'position': [0.0, 0.0, 0.0],\n", - " 'mass': 63.546,\n", - " 'charge': 1.0,\n", - " 'magmom': [0.0, 0, 0]}]}" - ] - }, - "execution_count": 5, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "from ase.build import bulk\n", - "atoms = bulk('Cu', 'fcc', a=3.6)\n", - "atoms.set_initial_charges([1,])\n", - "atoms.set_tags([\"2\"])\n", - "\n", - "mutable_structure = StructureDataMutable.from_ase(atoms)\n", - "structure = StructureData.from_ase(atoms)\n", - "\n", - "structure.to_dict()" - ] - }, - { - "cell_type": "markdown", - "id": "d15114c1", - "metadata": {}, - "source": [ - "This should have as output:\n", - "\n", - "{'pbc': (True, True, True),\n", - " 'cell': [[0.0, 1.8, 1.8], [1.8, 0.0, 1.8], [1.8, 1.8, 0.0]],\n", - " 'sites': [{'symbol': 'Cu',\n", - " 'kind_name': 'Cu2',\n", - " 'position': [0.0, 0.0, 0.0],\n", - " 'mass': 63.546,\n", - " 'charge': 1.0,\n", - " 'magmom': 0.0}]}\n", - "\n", - "This support also the properties like charges (coming soon: magmoms and so on). In the same way, for pymatgen we can proceed as follows:" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "3a5255df", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'pbc': (True, True, True),\n", - " 'cell': [[3.84, 0.0, 2.351321854362918e-16],\n", - " [1.92, 2.7152900397563426, -1.919999999999999],\n", - " [0.0, 0.0, 3.84]],\n", - " 'sites': [{'symbol': 'Si',\n", - " 'weights': 28.0855,\n", - " 'position': [0.0, 0.0, 0.0],\n", - " 'charge': 1,\n", - " 'kind_name': 'Si'},\n", - " {'symbol': 'Si',\n", - " 'weights': 28.0855,\n", - " 'position': [3.84, 1.3576450198781713, 1.9200000000000006],\n", - " 'charge': 0,\n", - " 'kind_name': 'Si0'}]}" - ] - }, - "execution_count": 6, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "from pymatgen.core import Lattice, Structure, Molecule\n", - "\n", - "coords = [[0, 0, 0], [0.75,0.5,0.75]]\n", - "lattice = Lattice.from_parameters(a=3.84, b=3.84, c=3.84, alpha=120,\n", - " beta=90, gamma=60)\n", - "struct = Structure(lattice, [\"Si\", \"Si\"], coords)\n", - "\n", - "struct.add_oxidation_state_by_site([1,0])\n", - "\n", - "mutable_structure = StructureDataMutable.from_pymatgen(struct)\n", - "\n", - "mutable_structure.to_dict()" - ] - }, - { - "cell_type": "markdown", - "id": "2f7c09dd", - "metadata": {}, - "source": [ - "the output being:" - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "id": "2e562239", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'pbc': (True, True, True),\n", - " 'cell': [[3.84, 0.0, 2.351321854362918e-16],\n", - " [1.92, 2.7152900397563426, -1.919999999999999],\n", - " [0.0, 0.0, 3.84]],\n", - " 'sites': [{'symbol': 'Si',\n", - " 'weights': 28.0855,\n", - " 'position': [0.0, 0.0, 0.0],\n", - " 'charge': 1,\n", - " 'kind_name': 'Si'},\n", - " {'symbol': 'Si',\n", - " 'weights': 28.0855,\n", - " 'position': [3.84, 1.3576450198781713, 1.9200000000000006],\n", - " 'charge': 0,\n", - " 'kind_name': 'Si0'}]}" - ] - }, - "execution_count": 7, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "{'pbc': (True, True, True),\n", - " 'cell': [[3.84, 0.0, 2.351321854362918e-16],\n", - " [1.92, 2.7152900397563426, -1.919999999999999],\n", - " [0.0, 0.0, 3.84]],\n", - " 'sites': [{'symbol': 'Si',\n", - " 'weights': 28.0855,\n", - " 'position': [0.0, 0.0, 0.0],\n", - " 'charge': 1,\n", - " 'kind_name': 'Si'},\n", - " {'symbol': 'Si',\n", - " 'weights': 28.0855,\n", - " 'position': [3.84, 1.3576450198781713, 1.9200000000000006],\n", - " 'charge': 0,\n", - " 'kind_name': 'Si0'}]}" - ] - }, - { - "cell_type": "markdown", - "id": "84f78415", - "metadata": {}, - "source": [ - "Moreover, we also provide `to_ase` and `to_pymatgen` methods to obtain the corresponding instances. Also this methods for now only support charges, among the new properties." - ] - }, - { - "cell_type": "markdown", - "id": "47b9109f", - "metadata": {}, - "source": [ - "# Mutation of a structure" - ] - }, - { - "cell_type": "markdown", - "id": "cad6dd6a", - "metadata": {}, - "source": [ - "Let's suppose you want to update some property in the `StructureData` before to use it in a calculation. You cannot. The way to go is either to use ASE or Pymatgen to modify you object and store it back into `StructureData`, or to use the `StructureDataMutable` and its mutation methods, and then convert it into `StructureData`.\n", - "The latter method is the preferred one, as you then have support also for additional properties (to be implemented) like hubbard, which is not supported by the former.\n", - "`StructureDataMutable` contains several `set_` methods and more, needed to update a structure:" - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "id": "0270f062-0eda-4db9-9f66-5e1d7f4a8525", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "" - ] - }, - "execution_count": 8, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "old_structure = structure\n", - "old_structure.store()" - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "id": "1ae9cd1e", - "metadata": {}, - "outputs": [], - "source": [ - "from aiida import orm\n", - "structure = orm.load_node(old_structure.pk)\n", - "\n", - "mutable_structure = structure.to_mutable_structuredata()\n", - "mutable_structure.set_charges([1])\n", - "mutable_structure.set_kind_names(['Si2'])\n", - "\n", - "new_structure = mutable_structure.to_structuredata()" - ] - }, - { - "cell_type": "markdown", - "id": "d873328e", - "metadata": {}, - "source": [ - "Other available methods are `add_atom`, `pop_atom`, `update_site` and so on.\n", - "Indeed, we can also start from scratch:" - ] - }, - { - "cell_type": "code", - "execution_count": 10, - "id": "7c696f7b", - "metadata": { - "lines_to_next_cell": 2 - }, - "outputs": [], - "source": [ - "mutable_structure = StructureDataMutable()\n", - "mutable_structure.set_cell([[0.0, 1.8, 1.8], [1.8, 0.0, 1.8], [1.8, 1.8, 0.0]])\n", - "mutable_structure.add_atom({\n", - " 'symbol':'Si',\n", - " 'position':[3/4, 3/4, 3/4],\n", - " 'charge': 1,\n", - " 'kind_name': 'Si2'\n", - " })\n", - "\n", - "mutable_structure.add_atom({\n", - " 'symbol':'Si',\n", - " 'position':[1/2, 1/2, 1/2],\n", - " 'charge': 0,\n", - " 'kind_name': 'Si1'\n", - " })" - ] - }, - { - "cell_type": "markdown", - "id": "ae34be40", - "metadata": {}, - "source": [ - "# Slicing a structure" - ] - }, - { - "cell_type": "markdown", - "id": "88e0136a", - "metadata": {}, - "source": [ - "It is possible to *slice* a structure, i.e. returning only a part of it (in terms of sites). Let's that you have an heterostructure and you want to obtain only the first layer, composed of the first 4 atoms over 10 total. This works for both `StructureDataMutable` and `StructureData` (we return a new `StructureData` instance)." - ] - }, - { - "cell_type": "code", - "execution_count": 11, - "id": "5c733eaa", - "metadata": {}, - "outputs": [], - "source": [ - "sliced_structure = structure[:4]" - ] - }, - { - "cell_type": "markdown", - "id": "fccb441f", - "metadata": {}, - "source": [ - "# Backward compatibility support" - ] - }, - { - "cell_type": "markdown", - "id": "646ecb1c", - "metadata": {}, - "source": [ - "We can use the `to_legacy` method to return the corresponding `orm.StructureData` instance, in case a given plugin does not yet support the new `StructureData`." - ] - } - ], - "metadata": { - "jupytext": { - "cell_metadata_filter": "-all", - "main_language": "python", - "notebook_metadata_filter": "-all" - }, - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.11.9" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/examples/proof_of_concept.py b/examples/proof_of_concept.py deleted file mode 100644 index a6f522b..0000000 --- a/examples/proof_of_concept.py +++ /dev/null @@ -1,218 +0,0 @@ -# # The new `StructureData` API - -# # The `atomistic.StructureData` and `atomistic.StructureDataMutable` classes - -# Two main rules: (i) immutability, and (ii) site-based. This means that our node will be just a container of the crystal structure + properties, and it cannot really be modified in any way. -# We will only provide `from_*`, `get_*` and `to_*` methods. Each site property will be defined as site-based and not kind-based, at variance with the old `orm.StructureData`. Kinds now can be defined as a property of each site (`kind_name`). -# The idea is to provide another python class which is just the mutable version of the `atomistic.StructureData` used to build, manipulate the crystal structure before the effective AiiDA node initialization. For now, let's call this class `StructureDataMutable`. This two classes have the same data structure, i.e. the same `properties` and the same `from_*`, `get_*` and `to_*` methods. The only difference is that the `atomistic.StructureDataMutable` has also `set_*` methods which can be used to mutate the properties. **Rule**: no property can be modified directly (i.e. accessing it); this is useful to avoid the introduction of inconsistencies in the structure instance. - - -# # How to initialize the `StructureData`(s) - -# As both `StructureData` and `StructureDataMutable` share the same data structure, they also share the same constructor input parameter, which is just a python dictionary. The format of this dictionary exactly reflects how the data are store in the AiiDA database: - -# + -from aiida_atomistic import StructureData -from aiida_atomistic import StructureDataMutable - -from aiida import load_profile -load_profile() - -structure_dict = { - 'cell':[[2.75,2.75,0],[0,2.75,2.75],[2.75,0,2.75]], - 'pbc': [True,True,True], - 'sites':[ - { - 'symbol':'Si', - 'position':[3/4, 3/4, 3/4], - }, - { - 'symbol':'Si', - 'position':[1/2, 1/2, 1/2], - }, - ], -} - -mutable_structure = StructureDataMutable(**structure_dict) -structure = StructureData(**structure_dict) -# - - -# When this dictionary is provided to the constructor, validation check for each of the provided property is done (**for now, only pbc and cell**). -# Then, you can access the properties directly: - -# + -print("immutable pbc: ", structure.pbc) -print("mutable pbc: ", structure.pbc) - -print("immutable cell: ", structure.cell) -print("mutable cell: ", structure.cell) - -print("immutable sites: ", structure.sites) -print("mutable sites: ", structure.sites) -# - - -# the expected output is: -# -# immutable pbc: [ True True True] -# -# mutable pbc: [ True True True] -# -# immutable cell: [[2.75 2.75 0. ] -# [0. 2.75 2.75] -# [2.75 0. 2.75]] -# -# mutable cell: [[2.75 2.75 0. ] -# [0. 2.75 2.75] -# [2.75 0. 2.75]] -# -# immutable sites: [, ] -# -# mutable sites: [, ] - -# To inspect the properties of a single site, we can access it: - -print(structure.sites[0].symbol,structure.sites[0].position) # output: Si [0.75 0.75 0.75] - -# All the properties can be accessed via tab completion, and a list of the supported properties can be accessed via `structure.get_property_names()`. -# For now, other supported properties are `charge` (not yet `tot_charge`), `kind_name`, `mass`. -# For example, we can initialize a charged structure in this way: - -# + -structure_dict = { - 'cell':[[2.75,2.75,0],[0,2.75,2.75],[2.75,0,2.75]], - 'pbc': [True,True,True], - 'sites':[ - { - 'symbol':'Si', - 'position':[3/4, 3/4, 3/4], - 'charge': +1, - 'kind_name': 'Si2', - }, - { - 'symbol':'Si', - 'position':[1/2, 1/2, 1/2], - 'kind_name': 'Si1', - }, - ], -} - -mutable_structure = StructureDataMutable(**structure_dict) -structure = StructureData(**structure_dict) -# - - -# then, `structure.sites[0].charge` will be equal to 1. When the plugins will be adapted, with this information we can build the correct input file for the corresponding quantum engine. - -# ## Initialization from ASE or Pymatgen - -# If we already have an ASE Atoms or a Pymatgen Structure object, we can use the `from_ase` and `from_pymatgen` methods: - -# + -from ase.build import bulk -atoms = bulk('Cu', 'fcc', a=3.6) -atoms.set_initial_charges([1,]) -atoms.set_tags(["2"]) - -mutable_structure = StructureDataMutable.from_ase(atoms) -structure = StructureData.from_ase(atoms) - -structure.to_dict() -# - - -# This should have as output: -# -# {'pbc': (True, True, True), -# 'cell': [[0.0, 1.8, 1.8], [1.8, 0.0, 1.8], [1.8, 1.8, 0.0]], -# 'sites': [{'symbol': 'Cu', -# 'kind_name': 'Cu2', -# 'position': [0.0, 0.0, 0.0], -# 'mass': 63.546, -# 'charge': 1.0, -# 'magmom': 0.0}]} -# -# This support also the properties like charges (coming soon: magmoms and so on). In the same way, for pymatgen we can proceed as follows: - -# + -from pymatgen.core import Lattice, Structure, Molecule - -coords = [[0, 0, 0], [0.75,0.5,0.75]] -lattice = Lattice.from_parameters(a=3.84, b=3.84, c=3.84, alpha=120, - beta=90, gamma=60) -struct = Structure(lattice, ["Si", "Si"], coords) - -struct.add_oxidation_state_by_site([1,0]) - -mutable_structure = StructureDataMutable.from_pymatgen(struct) - -mutable_structure.to_dict() -# - - -# the output being: - -{'pbc': (True, True, True), - 'cell': [[3.84, 0.0, 2.351321854362918e-16], - [1.92, 2.7152900397563426, -1.919999999999999], - [0.0, 0.0, 3.84]], - 'sites': [{'symbol': 'Si', - 'weights': 28.0855, - 'position': [0.0, 0.0, 0.0], - 'charge': 1, - 'kind_name': 'Si'}, - {'symbol': 'Si', - 'weights': 28.0855, - 'position': [3.84, 1.3576450198781713, 1.9200000000000006], - 'charge': 0, - 'kind_name': 'Si0'}]} - -# Moreover, we also provide `to_ase` and `to_pymatgen` methods to obtain the corresponding instances. Also this methods for now only support charges, among the new properties. - -# # Mutation of a structure - -# Let's suppose you want to update some property in the `StructureData` before to use it in a calculation. You cannot. The way to go is either to use ASE or Pymatgen to modify you object and store it back into `StructureData`, or to use the `StructureDataMutable` and its mutation methods, and then convert it into `StructureData`. -# The latter method is the preferred one, as you then have support also for additional properties (to be implemented) like hubbard, which is not supported by the former. -# `StructureDataMutable` contains several `set_` methods and more, needed to update a structure: - -old_structure = structure -old_structure.store() - -# + -from aiida import orm -structure = orm.load_node(old_structure.pk) - -mutable_structure = structure.to_mutable_structuredata() -mutable_structure.set_charges([1]) -mutable_structure.set_kind_names(['Si2']) - -new_structure = mutable_structure.to_structuredata() -# - - -# Other available methods are `add_atom`, `pop_atom`, `update_site` and so on. -# Indeed, we can also start from scratch: - -# + -mutable_structure = StructureDataMutable() -mutable_structure.set_cell([[0.0, 1.8, 1.8], [1.8, 0.0, 1.8], [1.8, 1.8, 0.0]]) -mutable_structure.add_atom({ - 'symbol':'Si', - 'position':[3/4, 3/4, 3/4], - 'charge': 1, - 'kind_name': 'Si2' - }) - -mutable_structure.add_atom({ - 'symbol':'Si', - 'position':[1/2, 1/2, 1/2], - 'charge': 0, - 'kind_name': 'Si1' - }) -# - - - -# # Slicing a structure - -# It is possible to *slice* a structure, i.e. returning only a part of it (in terms of sites). Let's that you have an heterostructure and you want to obtain only the first layer, composed of the first 4 atoms over 10 total. This works for both `StructureDataMutable` and `StructureData` (we return a new `StructureData` instance). - -sliced_structure = structure[:4] - -# # Backward compatibility support - -# We can use the `to_legacy` method to return the corresponding `orm.StructureData` instance, in case a given plugin does not yet support the new `StructureData`. diff --git a/examples/structure/data/0.108_Mn3Ir.mcif b/examples/structure/data/0.108_Mn3Ir.mcif index 764acfb..48bc381 100644 --- a/examples/structure/data/0.108_Mn3Ir.mcif +++ b/examples/structure/data/0.108_Mn3Ir.mcif @@ -137,4 +137,3 @@ _atom_site_moment.crystalaxis_y _atom_site_moment.crystalaxis_z _atom_site_moment.symmform Mn1 2. -1. -1. mx,mz,mz - diff --git a/examples/structure/data/0.199_Mn3Sn.mcif b/examples/structure/data/0.199_Mn3Sn.mcif index 465fe2b..9c194c1 100644 --- a/examples/structure/data/0.199_Mn3Sn.mcif +++ b/examples/structure/data/0.199_Mn3Sn.mcif @@ -115,4 +115,3 @@ _atom_site_moment.crystalaxis_z _atom_site_moment.symmform Mn1_1 3.00(1) 3.00 0.00000 mx,my,0 Mn1_2 0.00000 -3.00 0.00000 0,my,0 - diff --git a/examples/structure/data/0.200_Mn3Sn.mcif b/examples/structure/data/0.200_Mn3Sn.mcif index 9d213c0..cd63b24 100644 --- a/examples/structure/data/0.200_Mn3Sn.mcif +++ b/examples/structure/data/0.200_Mn3Sn.mcif @@ -115,4 +115,3 @@ _atom_site_moment.crystalaxis_z _atom_site_moment.symmform Mn1_1 -1.73 1.73 0.00000 mx,my,0 Mn1_2 3.46 1.73 0.00000 2my,my,0 - diff --git a/examples/structure/data/Fe.mcif b/examples/structure/data/Fe.mcif index 30bb422..c1794c1 100644 --- a/examples/structure/data/Fe.mcif +++ b/examples/structure/data/Fe.mcif @@ -19,7 +19,7 @@ loop_ _atom_site_fract_y _atom_site_fract_z _atom_site_type_symbol - Fe1 0.0 0.0 0.0 Fe + Fe1 0.0 0.0 0.0 Fe _cell_angle_alpha 109.47122063449069 _cell_angle_beta 109.47122063449069 _cell_angle_gamma 109.47122063449069 @@ -28,7 +28,7 @@ _cell_length_b 2.451045228897639 _cell_length_c 2.451045228897639 loop_ _symmetry_equiv_pos_as_xyz - 'x, y, z' + 'x, y, z' _symmetry_int_tables_number 1 _symmetry_space_group_name_H-M 'P 1' @@ -37,4 +37,4 @@ _atom_site_moment.label _atom_site_moment.crystalaxis_x _atom_site_moment.crystalaxis_y _atom_site_moment.crystalaxis_z -FE1 0.00000 0.00000 2.50000 +FE1 0.00000 0.00000 2.50000 diff --git a/examples/structure/proof_of_concept.py b/examples/structure/proof_of_concept.py new file mode 100644 index 0000000..2722708 --- /dev/null +++ b/examples/structure/proof_of_concept.py @@ -0,0 +1,266 @@ +from aiida import load_profile, orm +load_profile() + +from aiida_atomistic import StructureData, StructureDataMutable + +# ## `StructureData`(s) initialization +# +# As both `StructureData` and `StructureDataMutable` share the same data structure, they also share the same inputs for the constructor: a python dictionary. The format of this dictionary exactly reflects how data are stored in the AiiDA database: + +structure_dict = { + 'cell':[[2.75,2.75,0],[0,2.75,2.75],[2.75,0,2.75]], + 'pbc': [True,True,True], + 'sites':[ + { + 'symbol':'Si', + 'position':[3/4, 3/4, 3/4], + }, + { + 'symbol':'Si', + 'position':[1/2, 1/2, 1/2], + }, + ], +} + +mutable_structure = StructureDataMutable(**structure_dict) +structure = StructureData(**structure_dict) + +print("Immutable pbc: ",structure.properties.pbc) +print("Mutable pbc: ",mutable_structure.properties.pbc) + +print("Immutable cell: ",structure.properties.cell) +print("Mutable cell: ",mutable_structure.properties.cell) + +print("Immutable sites: ",structure.properties.sites) +print("Mutable sites: ",mutable_structure.properties.sites) + +print("First immutable site: ",structure.properties.sites[0].dict()) +print("First mutable site: ",mutable_structure.properties.sites[0].dict()) + +# As we provide the `structure_dict` to the constructor of our two structure data classes, it is immediately used to feed the `properties` model. Each site is store as `SiteMutable` (`SiteImmutable`) object for the mutable (immutable) case. Mutability (immutability) is inherited from the corresponding StructureData class used. +# +# The full list of properties can be visualized using the `to_dict` method of the structure: + +structure.to_dict() + +# %% [markdown] +# We can see that some properties are generated automatically, like *kinds*, *charges*, *dimensionality* and so on, and some other properties are set by default if not provided, e.g. the *kind_name* of each site. +# +# :::{note} +# :class: dropdown +# To visualize the full list of properties, use the `get_property_names` method of the structure classes. +# +# The `to_dict` method is nothing else than a wrapper for the *BaseModel* `model_dump` method of the *properties* attribute. +# ::: +# +# ### Initialization from ASE or Pymatgen +# +# If we already have an ASE Atoms or a Pymatgen Structure object, we can initialize our StructureData by means of the built-in `from_ase` and `from_pymatgen` methods. +# For ASE: + +# %% +from ase.build import bulk +atoms = bulk('Cu', 'fcc', a=3.6) +atoms.set_initial_charges([1,]) +atoms.set_tags(["2"]) + +mutable_structure = StructureDataMutable.from_ase(atoms) +structure = StructureData.from_ase(atoms) + +structure.to_dict() + +# %% [markdown] +# In the Pymatgen case: + +# %% +from pymatgen.core import Lattice, Structure, Molecule + +coords = [[0, 0, 0], [0.75,0.5,0.75]] +lattice = Lattice.from_parameters(a=3.84, b=3.84, c=3.84, alpha=120, + beta=90, gamma=60) +struct = Structure(lattice, ["Si", "Si"], coords) + +struct.sites[0].properties["charge"]=1 + +mutable_structure = StructureDataMutable.from_pymatgen(struct) +structure = StructureData.from_pymatgen(struct) + +mutable_structure.to_dict() + +# %% [markdown] +# Moreover, we also provide `to_ase` and `to_pymatgen` methods to obtain the corresponding instances. +# +# ## Passing from StructureData to StructureDataMutable and viceversa +# + +# %% +mutable_structure.to_immutable() # returns an instance of StructureData +structure.to_mutable() # returns an instance of StructureDataMutable + +# %% [markdown] +# +# ## Mutation of a `StructureData` instance +# +# Let's suppose you want to update some property in the `StructureData` before to use it in a calculation. You cannot. The way to go is either to use ASE or Pymatgen to modify your object and store it back into `StructureData`, or to use the `StructureDataMutable` and its mutation methods, and then convert it into `StructureData`. +# The latter method is the preferred one, as you then have support also for additional properties (to be implemented) like hubbard, which is not supported in ASE and Pymatgen. +# +# `StructureDataMutable` properties can be modified directly, but also the class contains several `set_` methods and more, needed to update a structure. Let's suppose we start from an immutable `StructureData` and we want to update the charges (and the corresponding kinds): + +# %% +mutable_structure = structure.to_mutable() + +mutable_structure.set_charges([1, 0]) +mutable_structure.set_kind_names(['Si2','Si1']) + +new_structure = mutable_structure.to_immutable() + +print(f"new charges, kinds:\n{new_structure.properties.charges}, {new_structure.properties.kinds}") + +# %% [markdown] +# :::{note} Keeping the provenance +# When starting from a `StructureData`, passing to a `StructureDataMutable` and then generating a new modified `StructureData`, we lose provenance. To keep it, we should do the modification by means of an AiiDA [*calcfunction*](https://aiida.readthedocs.io/projects/aiida-core/en/latest/topics/calculations/concepts.html#calculation-functions), which takes as input(output) the starting(modified) `StructureData`. +# ::: + +# %% [markdown] +# It is also possible to `add_atom`, `pop_atom`, `update_site` and so on. +# Indeed, we can start from and empty `StructureDataMutable` (i.e., from scratch): + +# %% +mutable_structure = StructureDataMutable() +mutable_structure.set_cell([[0.0, 1.8, 1.8], [1.8, 0.0, 1.8], [1.8, 1.8, 0.0]]) +mutable_structure.add_atom({ + 'symbol':'Si', + 'position':[3/4, 3/4, 3/4], + 'charge': 1, + 'kind_name': 'Si2' + }) + +mutable_structure.add_atom({ + 'symbol':'Si', + 'position':[1/2, 1/2, 1/2], + 'charge': 0, + 'kind_name': 'Si1' + }) + +mutable_structure.to_dict() + +# %% [markdown] +# ## Slicing a structure +# +# It is possible to *slice* a structure, i.e. returning only a part of it (in terms of sites). The method returns a new sliced `StructureDataMutable` (`StructureData`) instance. + +# %% +sliced_structure = mutable_structure[:1] +sliced_structure.to_dict() + +# %% [markdown] +# ## Automatic kinds generation +# +# It is possible to automatically detect kinds when initializing the structure from ASE or Pymatgen. Moreover, the kind can be also generated during the `to_dict` call, such that our output_dictionary will already have the detected kinds. In summary, we can generate our StructureData/StructureDataMutable with automatic kind detection in these three ways: +# +# 1. new_structuredata = StructureData.from_ase(ase_structure, detect_kinds=True) +# 2. new_structuredata = StructureData.from_pymatgen(pymatgen_structure, detect_kinds=True) +# 3. new_structuredata = StructureData(**old_structuredata.to_dict(detect_kinds=True)) + +# %% +Fe_BCC_dictionary = {'pbc': (True, True, True), + 'cell': [[2.8403, 0.0, 1.7391821518091137e-16], + [-1.7391821518091137e-16, 2.8403, 1.7391821518091137e-16], + [0.0, 0.0, 2.8403]], + 'sites': [{'symbol': 'Fe', + 'mass': 55.845, + 'position': [0.0, 0.0, 0.0], + 'charge': 0.0, + 'magmom': [2.5, 0.1, 0.1], + 'kind_name': 'Fe'}, + {'symbol': 'Fe', + 'mass': 55.845, + 'position': [1.42015, 1.42015, 1.4201500000000002], + 'charge': 0.0, + 'magmom': [2.4, 0.1, 0.1], + 'kind_name': 'Fe'}]} + +mutable_structure = StructureDataMutable(**Fe_BCC_dictionary) + +new_mutable_structure = StructureDataMutable(**mutable_structure.to_dict(detect_kinds=True)) +new_mutable_structure.to_dict() + +# %% [markdown] +# We can also directly put our new sites in the starting `mutable_structure`: + +# %% +mutable_structure.clear_sites() +for site in new_mutable_structure.to_dict()['sites']: + mutable_structure.add_atom(site) + +mutable_structure.to_dict() + +# %% [markdown] +# ## How to Query StructureData using properties +# +# Thanks to the additional computed properties in our `StructureData` (*formula*, *symbols*, *kinds*, *masses*, *charges*, *magmoms*, *positions*, *cell_volume*, *dimensionality*), we can easily query for a structure: + +# %% +from aiida.orm import QueryBuilder + +stored = new_mutable_structure.to_immutable().store() +print(stored.pk) + +qb = QueryBuilder() +qb.append(StructureData, + filters={'attributes.formula': 'Fe2'}, + ) + +print(qb.all()[-1]) + +# %% [markdown] +# ## How to define alloys and deal with vacancies +# +# It is possible to define more than one element for a given site, i.e. to define an *alloy*. This can be done by providing as symbol the combination of the symbols, and also the corresponding *weights* tuple: + +# %% +structure = StructureDataMutable(**{'pbc': [True, True, True], + 'cell': [[0.0, 1.8, 1.8], [1.8, 0.0, 1.8], [1.8, 1.8, 0.0]], + 'sites': [{'symbol': 'CuAl', + 'position': [0.0, 0.0, 0.0], + 'weights': (0.5,0.5) + }],}) + +structure.properties.sites[0].dict() + +# %% [markdown] +# if not provided, the mass is computed accordingly to the symbols and weights. Vacancies are detected when the sum of the weights is less than 1. + +# %% +print(structure.is_alloy) +print(structure.has_vacancies) + +# %% [markdown] +# ## How to add custom properties +# +# It is possible to add custom properties at the `StructureData` level (not at the `Site` level). To do that, it is sufficient to put the corresponding property under the `custom` Field, a dictionary which should contain the custom property names as keys, followed by the corresponding value: + +# %% +structure = StructureData(**{'pbc': [True, True, True], + 'cell': [[0.0, 1.8, 1.8], [1.8, 0.0, 1.8], [1.8, 1.8, 0.0]], + 'sites': [{'symbol': 'Cu', + 'position': [0.0, 0.0, 0.0], + }], + 'custom': { + 'electronic_type': 'metal', + } + }) + +structure.properties.custom + +# %% [markdown] +# :::{note} +# :class: dropdown +# Automatic serialization of the custom properties is done when the model is dumped (e.g. when the structure is stored in the AiiDA database). If serialization is not possible, an error is retrieved. +# ::: + +# %% [markdown] +# ## Backward compatibility support +# +# We can use the `to_legacy` method to return the corresponding `orm.StructureData` instance starting from a `StructureData`or `StructureDataMutable` instance, if a given plugin does not yet support the new `StructureData`. +# diff --git a/pyproject.toml b/pyproject.toml index d2aa06d..d544d67 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,9 @@ requires-python = ">=3.7" dependencies = [ "aiida-core>=2.0,<3", "voluptuous", - "ase" + "ase", + "pymatgen>=2022.1.20", + "pydantic>=2.6" ] [project.urls] @@ -36,7 +38,8 @@ testing = [ "wheel~=0.31", "coverage[toml]", "pytest~=6.0", - "pytest-cov" + "pytest-cov", + "six", ] pre-commit = [ "pre-commit~=2.2", @@ -55,7 +58,6 @@ docs = [ [project.entry-points."aiida.data"] "atomistic.StructureData" = "aiida_atomistic.data.structure.structure:StructureData" -"atomistic.StructureDataMutable" = "aiida_atomistic.data.structure.mutable:StructureDataMutable" [project.entry-points."aiida.calculations"] "atomistic" = "aiida_atomistic.calculations:DiffCalculation" diff --git a/src/aiida_atomistic/data/__init__.py b/src/aiida_atomistic/data/__init__.py index 5d3f38f..aad7f46 100644 --- a/src/aiida_atomistic/data/__init__.py +++ b/src/aiida_atomistic/data/__init__.py @@ -4,4 +4,4 @@ AiiDA plugin which contains data and methods for atomistic simulations """ -__version__ = "0.1.0a0" \ No newline at end of file +__version__ = "0.1.0a0" diff --git a/src/aiida_atomistic/data/structure/mixin.py b/src/aiida_atomistic/data/structure/mixin.py index 8ba55db..adf9104 100644 --- a/src/aiida_atomistic/data/structure/mixin.py +++ b/src/aiida_atomistic/data/structure/mixin.py @@ -51,10 +51,26 @@ _atomic_masses = {el["symbol"]: el["mass"] for el in elements.values()} _atomic_numbers = {data["symbol"]: num for num, data in elements.items()} +_default_values = { + "charges": 0, + "magmoms": [0, 0, 0], +} + class GetterMixin: - + + @property + def is_alloy(self): + return any(_.is_alloy for _ in self.properties.sites) + + @property + def has_vacancies(self): + return any(_.has_vacancies for _ in self.properties.sites) + @classmethod - def from_ase(cls, aseatoms: ASE_ATOMS_TYPE): + def from_ase( + cls, + aseatoms: ASE_ATOMS_TYPE, + detect_kinds: bool = False): """Load the structure from a ASE object""" if not has_ase: @@ -73,20 +89,31 @@ def from_ase(cls, aseatoms: ASE_ATOMS_TYPE): structure = cls(**data) + if detect_kinds: + data["sites"] = structure.get_kinds(ready_to_use=True) + + structure = cls(**data) + return structure @classmethod - def from_file(cls, filename, format="cif", **kwargs): + def from_file( + cls, + filename, + format="cif", + detect_kinds: bool = False, + **kwargs): """Load the structure from a file""" ase_read = ase_io.read(filename, format=format, **kwargs) - return cls.from_ase(aseatoms=ase_read) + return cls.from_ase(aseatoms=ase_read, detect_kinds=detect_kinds) @classmethod def from_pymatgen( cls, pymatgen_obj: t.Union[PYMATGEN_MOLECULE, PYMATGEN_STRUCTURE], + detect_kinds: bool = False, **kwargs, ): """Load the structure from a pymatgen object. @@ -98,14 +125,19 @@ def from_pymatgen( raise ImportError("The pymatgen package cannot be imported.") if isinstance(pymatgen_obj, PYMATGEN_MOLECULE): - structure = cls._from_pymatgen_molecule(pymatgen_obj) + structure = cls._from_pymatgen_molecule(pymatgen_obj, detect_kinds=detect_kinds) else: - structure = cls._from_pymatgen_structure(pymatgen_obj) + structure = cls._from_pymatgen_structure(pymatgen_obj, detect_kinds=detect_kinds) return structure @classmethod - def _from_pymatgen_molecule(cls, mol: PYMATGEN_MOLECULE, margin=5): + def _from_pymatgen_molecule( + cls, + mol: PYMATGEN_MOLECULE, + margin=5, + detect_kinds: bool = False, + ): """Load the structure from a pymatgen Molecule object. :param margin: the margin to be added in all directions of the @@ -125,13 +157,17 @@ def _from_pymatgen_molecule(cls, mol: PYMATGEN_MOLECULE, margin=5): - min(x.coords.tolist()[2] for x in mol.properties.sites) + 2 * margin, ] - structure = cls.from_pymatgen_structure(mol.get_boxed_structure(*box)) + structure = cls._from_pymatgen_structure(mol.get_boxed_structure(*box), detect_kinds=detect_kinds) structure.properties.pbc = [False, False, False] return structure @classmethod - def _from_pymatgen_structure(cls, struct: PYMATGEN_STRUCTURE): + def _from_pymatgen_structure( + cls, + struct: PYMATGEN_STRUCTURE, + detect_kinds: bool = False, + ): """Load the structure from a pymatgen Structure object. .. note:: periodic boundary conditions are set to True in all @@ -210,7 +246,7 @@ def build_kind_name(species_and_occu): site_info = { "symbol": site.specie.symbol, - "weights": site.species.weight, + "mass": site.species.weight, "position": site.coords.tolist(), "charge": site.properties.get("charge", 0.0), 'magmom': site.properties.get("magmom").moment if "magmom" in site.properties.keys() else [0,0,0] @@ -223,10 +259,15 @@ def build_kind_name(species_and_occu): structure = cls(**inputs) + if detect_kinds: + inputs["sites"] = structure.get_kinds(ready_to_use=True) + + structure = cls(**inputs) + return structure def to_dict( - self, + self, detect_kinds: bool = False ): """ @@ -237,17 +278,15 @@ def to_dict( :return: The structure as a dictionary. :rtype: dict """ - - dict_repr = copy.deepcopy(self.properties.model_dump()) - + if detect_kinds: dict_repr["sites"] = self.get_kinds(ready_to_use=True) - + # dict_repr = get_serialized_data(dict_repr) - + return dict_repr - + def get_site_property(self, property_name): """Return a list with length equal to the number of sites of this structure, where each element of the list is the property of the corresponding site. @@ -262,24 +301,23 @@ def get_property_names(self, domain=None): Args: domain (str, optional): restrict the domain of the printed property names. Defaults to None, but can be also 'site'. """ - pass - + return {'direct': list(self.properties.model_fields.keys()), 'computed': list(self.properties.model_computed_fields.keys()), 'site':list(Site.model_fields.keys())} def get_charges(self,): return self.get_site_property("charge") - + def get_magmoms(self,): return self.get_site_property("magmom") - + def get_kind_names(self,): return self.get_site_property("kind_name") - + def get_positions(self,): return self.get_site_property("position") - + def get_symbols(self,): return self.get_site_property("symbol") - + def get_cell_volume(self): """Returns the three-dimensional cell volume in Angstrom^3. @@ -523,7 +561,7 @@ def get_kinds(self, kind_tags=[], exclude=["weight"], custom_thr={}, ready_to_us kind_numeration.append(i) kind_names[where] = f"{element}{kind_numeration[-1]}" - + check_array[where] = i #print(f"site {where} is {element}{kind_numeration[-1]}") @@ -536,7 +574,7 @@ def get_kinds(self, kind_tags=[], exclude=["weight"], custom_thr={}, ready_to_us kind_names[i] if not kind_tags[i] else kind_tags[i] for i in range(len(kind_tags)) ] - + kinds_dictionary["index"] = kind_numeration kinds_dictionary["symbol"] = symbols.tolist() kinds_dictionary["position"] = self.get_site_property("position").tolist() @@ -552,13 +590,13 @@ def get_kinds(self, kind_tags=[], exclude=["weight"], custom_thr={}, ready_to_us for index_kind in kinds_dictionary["index"]: dict_site = {} for k,v in kinds_dictionary.items(): - if k not in ["symbol","position","index"]: + if k not in ["symbol","position","index"]: dict_site[k] = v[index_kind].tolist() if isinstance(v[index_kind], np.ndarray) else v[index_kind] for value in ["symbol","position"]: dict_site[value] = kinds_dictionary[value][index_kind] new_sites.append(dict_site) return new_sites - + return kinds_dictionary def to_ase(self): @@ -871,7 +909,7 @@ def _parse_xyz(self, inputstring): if atoms is None: raise TypeError("The data does not contain any XYZ data") - self.clear_kinds() + #self.clear_kinds() self.properties.pbc = (False, False, False) for sym, position in atoms: @@ -1048,7 +1086,9 @@ def _get_object_pymatgen_structure(self, **kwargs): # add "kind_name" as a properties to each site, whenever # the kind_name cannot be automatically obtained from the symbols additional_kwargs["site_properties"] = { - "kind_name": self.get_site_property("kind_name") + "kind_name": self.properties.kinds, + "charge": self.properties.charges, + "magmom": self.properties.magmoms } if kwargs: @@ -1099,7 +1139,16 @@ def _get_object_pymatgen_molecule(self, **kwargs): species.append({site.symbol: weight}) positions = [list(site.position) for site in self.properties.sites] - return Molecule(species, positions) + mol = Molecule(species, positions) + + additional_kwargs["site_properties"] = { + "kind_name": self.properties.kinds, + "charge": self.properties.charges, + "magmom": self.properties.magmoms + } + + for prop,value in additional_kwargs.items(): + mol.add_site_property(prop, value) def _get_dimensionality( self, @@ -1187,25 +1236,26 @@ def _to_kinds(self, property_name, symbols, thr: float = 0): kinds_values: list of the associated property value to each kind detected. """ symbols_array = np.array(symbols) - + if isinstance(self.get_site_property(property_name)[0], list) or isinstance(self.get_site_property(property_name)[0], np.ndarray): #reference_array = np.array(self.get_site_property(property_name)[0]) # I take the difference to detect also the case [1,0,0] != [-1,0,0] #prop_array = np.array([np.linalg.norm(row-reference_array) for row in self.get_site_property(property_name)]) prop_array = np.array(self.get_site_property(property_name)) shape_1 = len(prop_array[0]) kinds_values = np.zeros((len(symbols_array),shape_1)) - else: + else: prop_array = np.array(self.get_site_property(property_name)) kinds_values = np.zeros(len(symbols_array)) - if thr == 0 or not thr: return np.array(range(len(prop_array))), prop_array # list for the value of the property for each generated kind. if isinstance(prop_array[0], np.ndarray): - indexes = np.array([np.linalg.norm(row-prop_array[0])/ thr for row in prop_array], dtype=int) + # here, to deal with set of 3D indexes and avoid to deal with directions of the vectors, + # I transform the set of indexes into string, so I can compared them in the np.where + indexes = np.array([np.array2string(np.array((row-prop_array[0])/ thr, dtype=int)) for row in prop_array]) else: indexes = np.array((prop_array - np.min(prop_array)) / thr, dtype=int) @@ -1215,14 +1265,14 @@ def _to_kinds(self, property_name, symbols, thr: float = 0): for index in set_indexes: where_index_in_indexes = np.where(indexes == index)[0] kinds_values[where_index_in_indexes] = prop_array[where_index_in_indexes[0]] - + # here we reorder from zero the kinds. list_set_indexes = list(set_indexes) kinds_labels = np.zeros(len(symbols_array), dtype=int) for i in range(len(list_set_indexes)): kinds_labels[np.where(indexes == list_set_indexes[i])[0]] = i - + return kinds_labels, kinds_values def __getitem__(self, index): @@ -1241,10 +1291,34 @@ def __getitem__(self, index): def __len__( self, ): - return len(self.properties.sites) - + return len(self.properties.sites) + + def get_defined_properties(self, exclude_defaults=True): + """ + Get the defined properties of the structure. + + Args: + exclude_defaults (bool): Whether to exclude properties with default values. + + Returns: + list: A list of defined properties. + """ + defined_properties = [] + + for prop, value in self.properties.model_dump(exclude_defaults=exclude_defaults).items(): + if isinstance(value, list): + if value.count(_default_values.get(prop, None)) == len(value): + # Skip charges, magmoms if not defined for any site. + continue + else: + defined_properties.append(prop) + elif value is not None: + defined_properties.append(prop) + + return defined_properties + class SetterMixin: - + def set_pbc(self, value): """Set the periodic boundary conditions.""" the_pbc = _get_valid_pbc(value) @@ -1273,7 +1347,7 @@ def set_charges(self, value): else: for site_index in range(len(value)): self.update_site(site_index, charge=value[site_index]) - + def set_magmoms(self, value): if not len(self.properties.sites) == len(value): raise ValueError( @@ -1325,4 +1399,4 @@ def pop_atom(self, index=None): raise IndexError("pop_atom index out of range") def clear_sites(self,): - self.properties.sites = [] \ No newline at end of file + self.properties.sites = [] diff --git a/src/aiida_atomistic/data/structure/models.py b/src/aiida_atomistic/data/structure/models.py index 7bb7488..c1dca50 100644 --- a/src/aiida_atomistic/data/structure/models.py +++ b/src/aiida_atomistic/data/structure/models.py @@ -2,7 +2,7 @@ import functools import json import typing as t -from pydantic import BaseModel, Field, field_validator, PrivateAttr, computed_field, model_validator +from pydantic import BaseModel, Field, field_validator, ConfigDict, computed_field, model_validator, field_serializer import numpy as np import warnings @@ -61,127 +61,242 @@ _atomic_numbers = {data["symbol"]: num for num, data in elements.items()} class StructureBaseModel(BaseModel): - - #sites: t.Optional[t.List[SiteMutable]] - pbc: t.Optional[t.List[bool]] = Field(min_length=3, max_length=3, default_factory=lambda: [True, True, True]) - cell: t.Optional[t.List[t.List[float]]] = Field(default_factory=lambda: _DEFAULT_CELL) - + """ + A base model representing a structure in atomistic simulations. + + Attributes: + pbc (Optional[List[bool]]): Periodic boundary conditions in the x, y, and z directions. + cell (Optional[List[List[float]]]): The cell vectors defining the unit cell of the structure. + tot_charge (Optional[float]): The total charge of the structure. + tot_magnetization (Optional[float]): The total magnetization of the structure. + """ + + pbc: t.Optional[t.List[bool]] = Field(min_length=3, max_length=3, default = None) + cell: t.Optional[t.List[t.List[float]]] = Field(default = None) + tot_charge: t.Optional[float] = Field(default = None) + tot_magnetization: t.Optional[float] = Field(default = None) + custom: t.Optional[dict] = Field(default=None) + class Config: from_attributes = True frozen = False - arbitrary_types_allowed=True - + arbitrary_types_allowed = True + @field_validator('pbc') @classmethod def validate_pbc(cls, v: t.List[bool]) -> t.Any: - + """ + Validate the periodic boundary conditions. + + Args: + v (List[bool]): The periodic boundary conditions in the x, y, and z directions. + + Returns: + Any: The validated periodic boundary conditions. + + Raises: + ValueError: If the periodic boundary conditions are not a list or not of length 3. + """ + if not isinstance(v, list): if cls._mutable.default: warnings.warn("pbc should be a list") else: raise ValueError("pbc must be a list") return v - + if len(v) != 3: if cls._mutable.default: warnings.warn("pbc should be a list of length 3") else: raise ValueError("pbc must be a list of length 3") return v - + if not cls._mutable.default: return freeze_nested(v) - + return v - + @field_validator('cell') @classmethod def validate_cell(cls, v: t.List[t.List[float]]) -> t.Any: - + """ + Validate the cell vectors. + + Args: + v (List[List[float]]): The cell vectors defining the unit cell of the structure. + + Returns: + Any: The validated cell vectors. + + Raises: + ValueError: If the cell vectors are not a list. + """ + if not isinstance(v, list): if cls._mutable.default: warnings.warn("cell should be a 3x3 list") else: raise ValueError("cell must be a 3x3 list") return v - + if not cls._mutable.default: return freeze_nested(v) - + return v - + + @model_validator(mode='before') + def check_minimal_requirements(cls, data): + """ + Validate the minimal requirements of the structure. + + Args: + data (dict): The input data for the structure. + + Returns: + dict: The validated input data. + + Raises: + ValueError: If the structure does not meet the minimal requirements. + """ + if not data.get("sites", None) and not cls._mutable: + raise ValueError("The structure must contain at least one site") + elif not data.get("sites", None) and cls._mutable: + pass + else: + _check_valid_sites(data["sites"]) + if not data.get("cell", None): + # raise ValueError("The structure must contain a cell") + warnings.warn("using default cell") + data["cell"] = _DEFAULT_CELL + if not data.get("pbc", None): + # raise ValueError("The structure must contain periodic boundary conditions") + data["pbc"] = [True,True,True] + return data + @computed_field - @property def cell_volume(self) -> float: + """ + Compute the volume of the unit cell. + + Returns: + float: The volume of the unit cell. + """ return calc_cell_volume(self.cell) - + @computed_field - @property def dimensionality(self) -> dict: + """ + Determine the dimensionality of the structure. + + Returns: + dict: A dictionary indicating the dimensionality of the structure. + """ return get_dimensionality(self.pbc, self.cell) - + @computed_field - @property def charges(self) -> FrozenList[float]: + """ + Get the charges of the sites in the structure. + + Returns: + FrozenList[float]: The charges of the sites. + """ return FrozenList([site.charge for site in self.sites]) - + @computed_field - @property def magmoms(self) -> FrozenList[FrozenList[float]]: + """ + Get the magnetic moments of the sites in the structure. + + Returns: + FrozenList[FrozenList[float]]: The magnetic moments of the sites. + """ return FrozenList([site.magmom for site in self.sites]) - + @computed_field - @property def masses(self) -> FrozenList[float]: + """ + Get the masses of the sites in the structure. + + Returns: + FrozenList[float]: The masses of the sites. + """ return FrozenList([site.mass for site in self.sites]) - + @computed_field - @property def kinds(self) -> FrozenList[str]: + """ + Get the kinds of the sites in the structure. + + Returns: + FrozenList[str]: The kinds of the sites. + """ return FrozenList([site.kind_name for site in self.sites]) - + @computed_field - @property def symbols(self) -> FrozenList[str]: + """ + Get the atomic symbols of the sites in the structure. + + Returns: + FrozenList[str]: The atomic symbols of the sites. + """ return FrozenList([site.symbol for site in self.sites]) - + @computed_field - @property def positions(self) -> FrozenList[FrozenList[float]]: + """ + Get the positions of the sites in the structure. + + Returns: + FrozenList[FrozenList[float]]: The positions of the sites. + """ return FrozenList([site.position for site in self.sites]) - + @computed_field - @property def formula(self) -> str: + """ + Get the chemical formula of the structure. + + Returns: + str: The chemical formula of the structure. + """ return get_formula(self.symbols) - + class MutableStructureModel(StructureBaseModel): - + """ + A mutable structure model that extends the StructureBaseModel class. + + Attributes: + _mutable (bool): Flag indicating whether the structure is mutable or not. + sites (List[SiteImmutable]): List of immutable sites in the structure. + """ + _mutable = True - - sites : t.Optional[t.List[SiteMutable]]= Field(default_factory=list) - + + sites: t.Optional[t.List[SiteMutable]] = Field(default_factory=list) + class ImmutableStructureModel(StructureBaseModel): - + """ + A class representing an immutable structure model. + + This class inherits from `StructureBaseModel` and provides additional functionality for handling immutable structures. + + Attributes: + _mutable (bool): Flag indicating whether the structure is mutable or not. + sites (List[SiteImmutable]): List of immutable sites in the structure. + + Config: + from_attributes (bool): Flag indicating whether to load attributes from the input data. + frozen (bool): Flag indicating whether the model is frozen or not. + arbitrary_types_allowed (bool): Flag indicating whether arbitrary types are allowed or not. + """ + _mutable = False - - sites : t.List[SiteImmutable] - + sites: t.List[SiteImmutable] + class Config: from_attributes = True frozen = True - arbitrary_types_allowed=True - - @model_validator(mode='before') - def check_minimal_requirements(cls, data): - if not data.get("sites", None): - raise ValueError("The structure must contain at least one site") - else: - _check_valid_sites(data["sites"]) - if not data.get("cell", None): - raise ValueError("The structure must contain a cell") - if not data.get("pbc", None): - raise ValueError("The structure must contain periodic boundary conditions") - - # check sites not one over the other. see the append_atom method. - return data + arbitrary_types_allowed = True diff --git a/src/aiida_atomistic/data/structure/site.py b/src/aiida_atomistic/data/structure/site.py index 224c0d7..adb51cb 100644 --- a/src/aiida_atomistic/data/structure/site.py +++ b/src/aiida_atomistic/data/structure/site.py @@ -1,7 +1,8 @@ import numpy as np import typing as t -from pydantic import BaseModel, Field, field_validator, computed_field,model_validator +import re +from pydantic import BaseModel, Field, ConfigDict, field_validator, computed_field,model_validator try: import ase # noqa: F401 @@ -18,6 +19,7 @@ from aiida_atomistic.data.structure.utils import ( create_automatic_kind_name, freeze_nested, + check_is_alloy ) @@ -36,31 +38,27 @@ "charge": 0, "magmom": [0, 0, 0], "hubbard": None, - "weight": 1, + "weight": (1,) } class SiteCore(BaseModel): - - symbol: t.Literal[_valid_symbols] + """This class contains the core information about a given site of the system. + + It can be a single atom, or an alloy, or even contain vacancies. + + """ + model_config = ConfigDict(from_attributes = True, frozen = False, arbitrary_types_allowed = True) + + symbol: t.Optional[str] # validation is done in the check_is_alloy kind_name: t.Optional[str] - position: t.Optional[list] = Field(min_length=3, max_length=3) - mass: t.Optional[float] + position: t.List[float] = Field(min_length=3, max_length=3) + mass: t.Optional[float] = Field(gt=0) charge: t.Optional[float] = Field(default=0) magmom: t.Optional[t.List[float]] = Field(min_length=3, max_length=3, default=[0.0, 0.0, 0.0]) - - class Config: - from_attributes = True - frozen=False - arbitrary_types_allowed=True + weights: t.Optional[t.Tuple[float, ...]] = Field(default=(1,)) - """This class contains the information about a given site of the system. - - It can be a single atom, or an alloy, or even contain vacancies. - """ - @field_validator('position','magmom') def validate_list(cls, v: t.List[float]) -> t.Any: - if not cls._mutable.default: return freeze_nested(v) else: @@ -70,19 +68,49 @@ def validate_list(cls, v: t.List[float]) -> t.Any: def check_minimal_requirements(cls, data): if "symbol" not in data and cls._mutable.default: data["symbol"] = "H" + + # here below we proceed as in the old Kind, where we detect if + # we have an alloy (i.e. more than one element for the given site) + alloy_detector = check_is_alloy(data) + if alloy_detector: + data.update(alloy_detector) + if "mass" not in data: data["mass"] = _atomic_masses[data["symbol"]] elif not data["mass"]: data["mass"] = _atomic_masses[data["symbol"]] elif data["mass"]<=0: raise ValueError("The mass of an atom must be positive") - + if "kind_name" not in data: data["kind_name"] = data["symbol"] - + return data - - + + @property + def is_alloy(self): + """Return whether the Site is an alloy, i.e. contains more than one element + + :return: boolean, True if the kind has more than one element, False otherwise. + """ + return len(self.weights) != 1 + + @property + def alloy_list(self): + """Return the list of elements in the given site which is defined as an alloy + """ + return re.sub( r"([A-Z])", r" \1", self.symbol).split() + + @property + def has_vacancies(self): + """Return whether the Structure contains vacancies, i.e. when the sum of the weights is less than one. + + .. note:: the property uses the internal variable `_SUM_THRESHOLD` as a threshold. + + :return: boolean, True if the sum of the weights is less than one, False otherwise + """ + return not 1.0 - sum(self.weights) < _SUM_THRESHOLD + @classmethod def atom_to_site( cls, @@ -93,7 +121,8 @@ def atom_to_site( charge: t.Optional[float] = None, magmom: t.Optional[t.List[float]] = None, mass: t.Optional[float] = None, - ) -> dict: + weights: t.Optional[t.Tuple[float, ...]] = None + ) -> dict: """Convert an ASE atom or dictionary to a dictionary object which the correct format to describe a Site.""" if aseatom is not None: @@ -117,15 +146,16 @@ def atom_to_site( else: if position is None: raise ValueError("You have to specify the position of the new atom") - - if symbol is None: + + if symbol is None: raise ValueError("You have to specify the symbol of the new atom") - + # all remaining parameters kind_name = symbol if kind_name is None else kind_name - charge = 0 if charge is None else charge - magmom = [0,0,0] if magmom is None else magmom + charge = None if charge is None else charge + magmom = None if magmom is None else magmom mass = _atomic_masses[symbol] if mass is None else mass + weights = None if weights is None else weights new_site = cls( symbol=symbol, @@ -137,15 +167,21 @@ def atom_to_site( ) return new_site - + def update(self, **new_data): - for field, value in new_data.items(): - setattr(self, field, value) - + """Update the attributes of the SiteCore instance with new values. + + :param new_data: keyword arguments representing the attributes to be updated + """ + for field, value in new_data.items(): + setattr(self, field, value) + def set_automatic_kind_name(self, tag=None): """Set the type to a string obtained with the symbols appended one after the other, without spaces, in alphabetical order; if the site has a vacancy, a X is appended at the end too. + + :param tag: optional tag to be appended to the kind name """ name_string = create_automatic_kind_name(self.symbol, self.weight) if tag is None: @@ -157,12 +193,10 @@ def to_ase(self, kinds): """Return a ase.Atom object for this site. :param kinds: the list of kinds from the StructureData object. - - .. note:: If any site is an alloy or has vacancies, a ValueError - is raised (from the site.get_ase() routine). + :return: ase.Atom object representing this site + :raises ValueError: if any site is an alloy or has vacancies """ from collections import defaultdict - import ase # I create the list of tags @@ -194,33 +228,28 @@ def __str__(self): # The Classes which are exposed to the user: class SiteMutable(SiteCore): - + """ + A mutable version of the `SiteCore` class. + + This class represents a site in a crystal structure that can be modified. + + Attributes: + _mutable (bool): Flag indicating if the site is mutable. + """ + _mutable = True - - symbol: t.Literal[_valid_symbols] - kind_name: t.Optional[str] - position: t.Optional[t.List[float]] = None - mass: t.Optional[float] = None - charge: t.Optional[float] = 0 - magmom: t.Optional[t.List[float]] = Field(min_length=3, max_length=3, default=[0.0, 0.0, 0.0]) - - class Config: - from_attributes = True - frozen= False - arbitrary_types_allowed=True - + + class SiteImmutable(SiteCore): - + """ + A class representing an immutable site in a crystal structure. + + This class inherits from the `SiteCore` class and adds the functionality to create an immutable site. + An immutable site cannot be modified once it is created. + + Attributes: + _mutable (bool): A flag indicating whether the site is mutable or immutable. + """ + model_config = ConfigDict(from_attributes = True, frozen = True, arbitrary_types_allowed = True) + _mutable = False - - symbol: t.Literal[_valid_symbols] - kind_name: t.Optional[str] - position: t.List[float] = Field(min_length=3, max_length=3) - mass: t.Optional[float] = Field(gt=0) - charge: t.Optional[float] = Field(default=0) - magmom: t.Optional[t.List[float]] = Field(min_length=3, max_length=3, default=[0.0, 0.0, 0.0]) - - class Config: - from_attributes = True - frozen= True - arbitrary_types_allowed=True \ No newline at end of file diff --git a/src/aiida_atomistic/data/structure/structure.py b/src/aiida_atomistic/data/structure/structure.py index fe772f6..3bb9dc8 100644 --- a/src/aiida_atomistic/data/structure/structure.py +++ b/src/aiida_atomistic/data/structure/structure.py @@ -59,36 +59,42 @@ _atomic_masses = {el["symbol"]: el["mass"] for el in elements.values()} _atomic_numbers = {data["symbol"]: num for num, data in elements.items()} +_default_values = { + "charges": 0, + "magmoms": [0, 0, 0], +} class StructureData(Data, GetterMixin): - + def __init__(self, **kwargs): - + self._properties = ImmutableStructureModel(**kwargs) super().__init__() - - for prop, value in self.to_dict().items(): - self.base.attributes.set(prop, value) - - @property + + defined_properties = self.get_defined_properties() # exclude the default ones. We do not need to store them into the db. + for prop, value in self.properties.model_dump(exclude_defaults=True).items(): + if prop in defined_properties: + self.base.attributes.set(prop, value) + + @property def properties(self): if self.is_stored: return ImmutableStructureModel(**self.base.attributes.all) else: return self._properties - - def to_mutable(self): - return StructureDataMutable(**self.to_dict()) - + + def to_mutable(self, detect_kinds: bool = False): + return StructureDataMutable(**self.to_dict(detect_kinds=detect_kinds)) + class StructureDataMutable(GetterMixin, SetterMixin): - + def __init__(self, **kwargs): - + self._properties = MutableStructureModel(**kwargs) - - @property + + @property def properties(self): return self._properties - - def to_immutable(self): - return StructureData(**self.to_dict()) + + def to_immutable(self, detect_kinds: bool = False): + return StructureData(**self.to_dict(detect_kinds=detect_kinds)) diff --git a/src/aiida_atomistic/data/structure/utils.py b/src/aiida_atomistic/data/structure/utils.py index 7429cfe..935d0ab 100644 --- a/src/aiida_atomistic/data/structure/utils.py +++ b/src/aiida_atomistic/data/structure/utils.py @@ -1,5 +1,6 @@ import copy import functools +import re import numpy as np @@ -31,39 +32,94 @@ _atomic_numbers = {data["symbol"]: num for num, data in elements.items()} _dimensionality_label = {0: '', 1: 'length', 2: 'surface', 3: 'volume'} - class ObservedArray(np.ndarray): """ This is a subclass of numpy.ndarray that allows to observe changes to the array. - In this way, full flexibility of StructureDataMutable is achieved and at the same - time we can keep track of all the changes. + In this way, full flexibility of StructureDataMutable is achieved and at the same + time we can keep track of all the changes. """ + def __new__(cls, input_array): - # Convert input_array to an instance of ObservedArray + """ + Create a new instance of ObservedArray. + + Parameters: + - input_array: array-like + The input array to be converted to an instance of ObservedArray. + + Returns: + - obj: ObservedArray + The new instance of ObservedArray. + """ obj = np.asarray(input_array).view(cls) return obj def __setitem__(self, index, value): + """ + Set the value of an item in the ObservedArray. + + Parameters: + - index: int or tuple + The index or indices of the item(s) to be set. + - value: any + The value to be assigned to the item(s). + + Returns: + None + """ super(ObservedArray, self).__setitem__(index, value) def __array_finalize__(self, obj): - # This method is called when the view is created or sliced - if obj is None: return + """ + Finalize the creation of the ObservedArray. + + This method is called when the view is created or sliced. + + Parameters: + - obj: ObservedArray or None + The object being finalized. + + Returns: + None + """ + if obj is None: + return def freeze_nested(obj): + """ + Recursively freezes a nested dictionary or list by converting it into an immutable object. + + Args: + obj (dict or list): The nested dictionary or list to be frozen. + + Returns: + AttributesFrozendict or FrozenList: The frozen version of the input object. + + """ if isinstance(obj, dict): return AttributesFrozendict({k: freeze_nested(v) for k, v in obj.items()}) if isinstance(obj, list): return FrozenList(freeze_nested(v) for v in obj) else: return obj - + class FrozenList(list): - + """ + A subclass of list that represents an immutable list. + + This class overrides the __setitem__ method to raise a ValueError + when attempting to modify the list. + + Usage: + >>> my_list = FrozenList([1, 2, 3]) + >>> my_list[0] = 4 + ValueError: This list is immutable + """ + def __setitem__(self, index, value): raise ValueError("This list is immutable") - - + + def _get_valid_cell(inputcell): """Return the cell in a valid format from a generic input. @@ -113,16 +169,17 @@ def _get_valid_pbc(inputpbc): return the_pbc def _check_valid_sites(input_sites): - + num_sites = len(input_sites) + for i in range(num_sites): for j in range(num_sites): - if j == i: + if j == i: continue if np.allclose(input_sites[i]["position"], input_sites[j]["position"], atol=1e-3): raise ValueError(f"Sites {i+1} and {j+1} cannot have the same position") - - return + + return def has_ase(): @@ -216,9 +273,13 @@ def _create_symbols_tuple(symbols): this is converted to a tuple with one single element. """ if isinstance(symbols, str): - symbols_list = (symbols,) + symbols_list = re.sub( r"([A-Z])", r" \1", symbols).split() else: symbols_list = tuple(symbols) + + for symbol in symbols_list: + if symbol not in _valid_symbols: + raise ValueError(f"Some or all of the symbols provided are not correct: {symbols_list}") return symbols_list @@ -764,3 +825,39 @@ def create_automatic_kind_name(symbols, weights): if has_vacancies(weights): name_string += "X" return name_string + +def set_symbols_and_weights(new_data): + """Set the chemical symbols and the weights for the site. + + .. note:: Note that the kind name remains unchanged. + """ + symbols_tuple = _create_symbols_tuple(new_data["symbol"]) + weights_tuple = _create_weights_tuple(new_data["weights"]) + if len(symbols_tuple) != len(weights_tuple): + raise ValueError('The number of symbols and weights must coincide.') + validate_symbols_tuple(symbols_tuple) + + validate_weights_tuple(weights_tuple, _SUM_THRESHOLD) + new_data["alloy"] = symbols_tuple + new_data["weights"] = weights_tuple + + if not "mass" in new_data.keys() or np.isnan(new_data.get("mass", None)): + # Weighted mass + w_sum = sum(weights_tuple) + normalized_weights = (i / w_sum for i in weights_tuple) + element_masses = (_atomic_masses[sym] for sym in symbols_tuple) + new_data["mass"] = sum(i * j for i, j in zip(normalized_weights, element_masses)) + +def check_is_alloy(data): + """Check if the data is an alloy or not. + + :param data: the data to check. The dict of the SiteCore model. + :return: True if the data is an alloy, False otherwise. + """ + new_data = copy.deepcopy(data) + if len(new_data.get("weights", [1,])) == 1: + if new_data["symbol"] not in _valid_symbols: + raise ValueError(f'his is not a valid element: {new_data["symbol"]}') + return None + set_symbols_and_weights(new_data) + return new_data diff --git a/tests/data/test_structure.py b/tests/data/test_structure.py index 7e650a5..3021991 100644 --- a/tests/data/test_structure.py +++ b/tests/data/test_structure.py @@ -7,7 +7,6 @@ from pydantic import ValidationError - """ General tests for the atomistic StructureData. The comments the test categories should be replaced by the pytest.mark in the future. @@ -15,7 +14,6 @@ # StructureData initialization: - def test_structure_initialization(example_structure_dict): """ Testing that the StructureDataMutable is initialized correctly when: @@ -34,7 +32,7 @@ def test_structure_initialization(example_structure_dict): # (1.2) Empty StructureData: cannot be done with pytest.raises(ValidationError): structure = StructureData() - + # (2) for structure_type in [StructureDataMutable, StructureData]: structure = structure_type(**example_structure_dict) @@ -60,7 +58,9 @@ def test_dict(example_structure_dict): for derived_property in structure.properties.model_computed_fields.keys(): returned_dict.pop(derived_property, None) - + for property_to_delete in ["custom", "tot_charge", "tot_magnetization"]: + returned_dict.pop(property_to_delete, None) + assert ( returned_dict == example_structure_dict ), f"The dictionary returned by the method, {returned_dict}, \ @@ -77,7 +77,7 @@ def test_structure_ASE_initialization(): structure = structure_type.from_ase(atoms) assert isinstance(structure, structure_type) - + atoms = bulk('Cu', 'fcc', a=3.6) atoms.set_initial_charges([1,]) atoms.set_initial_magnetic_moments([[0,0,1]]) @@ -86,7 +86,27 @@ def test_structure_ASE_initialization(): assert structure.properties.charges == [1] assert structure.properties.magmoms == [[0,0,1]] - + +def test_structure_Pymatgen_initialization(): + """ + Testing that the StructureData/StructureDataMutable is initialized correctly when Pymatgen object is provided. + """ + + from pymatgen.core import Lattice, Structure, Molecule + + + coords = [[0, 0, 0], [0.75,0.5,0.75]] + lattice = Lattice.from_parameters(a=3.84, b=3.84, c=3.84, alpha=120, + beta=90, gamma=60) + + struct = Structure(lattice, ["Si", "Si"], coords) + struct.sites[0].properties["charge"]=1 + + for structure_type in [StructureDataMutable, StructureData]: + structure = structure_type.from_pymatgen(struct) + + assert structure.properties.charges == [1, 0] + assert structure.properties.magmoms == [[0.0, 0.0, 0.0], [0.0, 0.0, 0.0]] def test_mutability(): atoms = bulk("Cu", "fcc", a=3.6) @@ -118,16 +138,16 @@ def test_mutability(): # test StructureDataMutable mutability assert np.array_equal(m.properties.pbc,np.array([True, True, True])) - + m.set_pbc([False, False, False]) assert not any(m.properties.pbc) # check StructureData and StructureDataMutable give the same properties. # in this way I check that it works well. m.set_pbc([True, True, True]) - + returned_dict = s.to_dict() - + assert returned_dict == m.to_dict() # check append_atom works properly @@ -142,7 +162,7 @@ def test_mutability(): }, index=0, ) - + assert np.array_equal(m.get_charges(), np.array([0,0])) def test_computed_fields(example_structure_dict): @@ -153,7 +173,7 @@ def test_computed_fields(example_structure_dict): assert structure.properties.charges == [1.0] assert structure.properties.cell_volume == 11.664000000000001 assert structure.properties.dimensionality == {'dim': 3, 'label': 'volume', 'value': 11.664000000000001} - + if isinstance(structure, StructureDataMutable): structure.add_atom( { @@ -168,7 +188,7 @@ def test_computed_fields(example_structure_dict): ) assert structure.properties.charges == [0.0, 1.0] - + def test_model_validator(example_wrong_structure_dict,example_nomass_structure_dict): for structure_type in [StructureDataMutable, StructureData]: if isinstance(structure_type, StructureData): @@ -176,11 +196,11 @@ def test_model_validator(example_wrong_structure_dict,example_nomass_structure_d structure = structure_type(**example_wrong_structure_dict) elif isinstance(structure_type, StructureDataMutable): structure = structure_type(**example_wrong_structure_dict) - + structure = structure_type(**example_nomass_structure_dict) assert structure.properties.masses == [63.546] assert structure.properties.sites[0].mass == 63.546 - + ## Test the get_kinds() method. @@ -217,60 +237,21 @@ def kinds_properties(): return properties - -@pytest.mark.skip -def test_get_kinds(example_properties, kinds_properties): +def test_get_kinds(example_structure_dict_for_kinds): # (1) trivial system, defaults thr - structure = StructureData(properties=example_properties) - - kinds, kinds_values = structure.get_kinds() - - assert kinds == ["Li0", "Li1"] - assert kinds_values["charge"] == [1, 0] - - # (2) trivial system, custom thr - structure = StructureData(properties=example_properties) - - kinds, kinds_values = structure.get_kinds(custom_thr={"charge": 0.1}) - - assert kinds == ["Li0", "Li1"] - assert kinds_values["charge"] == [1, 0] - - # (3) trivial system, exclude one property - structure = StructureData(properties=example_properties) + for structure_type in [StructureData, StructureDataMutable]: + structure = structure_type(**example_structure_dict_for_kinds) - kinds, kinds_values = structure.get_kinds(exclude=["charge"]) + new_structure = structure_type(**structure.to_dict(detect_kinds=True)) - assert kinds == ["Li0", "Li0"] - assert kinds_values["mass"] == structure.properties.mass.value - assert not "charge" in kinds_values.keys() + assert new_structure.properties.kinds == ['Fe0', 'Fe1'] + assert new_structure.properties.magmoms == [[2.5, 0.1, 0.1], [2.4, 0.1, 0.1]] - # (4) non-trivial system, default thr - structure = StructureData(properties=kinds_properties) +def test_alloy(example_structure_dict_alloy): - kinds, kinds_values = structure.get_kinds(exclude=["charge"]) + for structure_type in [StructureData, StructureDataMutable]: + structure = structure_type(**example_structure_dict_alloy) - assert kinds == ["Li1", "Li1", "Cu2", "Cu2"] - assert kinds_values["mass"] == structure.properties.mass.value - assert not "charge" in kinds_values.keys() - - # (5) non-trivial system, custom thr - structure = StructureData(properties=kinds_properties) - - kinds, kinds_values = structure.get_kinds(custom_thr={"charge": 0.6}) - - assert kinds == ["Li0", "Li1", "Cu2", "Cu2"] - assert kinds_values["mass"] == structure.properties.mass.value - assert kinds_values["charge"] == [1.0, 0.0, 0.0, 0.0] - - -# Tests to be skipped because they require the implementation of the related method: - - -@pytest.mark.skip -def test_structure_pymatgen_initialization(): - """ - Testing that the StructureData is initialized correctly when Pymatgen Atoms object is provided. - """ - pass + assert structure.properties.masses == [45.263768999999996] + assert structure.properties.symbols == ["CuAl"]