nomad-forces-export 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nomad_forces_export-0.1.0/PKG-INFO +12 -0
- nomad_forces_export-0.1.0/README.md +58 -0
- nomad_forces_export-0.1.0/pyproject.toml +35 -0
- nomad_forces_export-0.1.0/setup.cfg +4 -0
- nomad_forces_export-0.1.0/src/nomad_forces_export/__init__.py +130 -0
- nomad_forces_export-0.1.0/src/nomad_forces_export/api.py +55 -0
- nomad_forces_export-0.1.0/src/nomad_forces_export/cli.py +60 -0
- nomad_forces_export-0.1.0/src/nomad_forces_export/config.py +38 -0
- nomad_forces_export-0.1.0/src/nomad_forces_export/extract.py +178 -0
- nomad_forces_export-0.1.0/src/nomad_forces_export/query.py +269 -0
- nomad_forces_export-0.1.0/src/nomad_forces_export/units.py +32 -0
- nomad_forces_export-0.1.0/src/nomad_forces_export/write.py +59 -0
- nomad_forces_export-0.1.0/src/nomad_forces_export.egg-info/PKG-INFO +12 -0
- nomad_forces_export-0.1.0/src/nomad_forces_export.egg-info/SOURCES.txt +23 -0
- nomad_forces_export-0.1.0/src/nomad_forces_export.egg-info/dependency_links.txt +1 -0
- nomad_forces_export-0.1.0/src/nomad_forces_export.egg-info/entry_points.txt +2 -0
- nomad_forces_export-0.1.0/src/nomad_forces_export.egg-info/requires.txt +6 -0
- nomad_forces_export-0.1.0/src/nomad_forces_export.egg-info/top_level.txt +1 -0
- nomad_forces_export-0.1.0/tests/test_api.py +64 -0
- nomad_forces_export-0.1.0/tests/test_cli.py +57 -0
- nomad_forces_export-0.1.0/tests/test_extract.py +207 -0
- nomad_forces_export-0.1.0/tests/test_fetch_dataset.py +74 -0
- nomad_forces_export-0.1.0/tests/test_query.py +125 -0
- nomad_forces_export-0.1.0/tests/test_units.py +27 -0
- nomad_forces_export-0.1.0/tests/test_write.py +66 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nomad-forces-export
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Fetch and package NOMAD atomistic data for MLIP training
|
|
5
|
+
Author-email: Sharat Patil <patilsha@physik.hu-berlin.de>
|
|
6
|
+
Maintainer-email: Sharat Patil <patilsha@physik.hu-berlin.de>
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Requires-Dist: ase>=3.23
|
|
9
|
+
Requires-Dist: loguru>=0.7.3
|
|
10
|
+
Requires-Dist: requests>=2.28
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# nomad-mlip-data
|
|
2
|
+
|
|
3
|
+
Fetch atomistic structures with energies/forces/stress from
|
|
4
|
+
[NOMAD](https://nomad-lab.eu/prod/v1/gui/search/entries) and package them as ASE-DB or
|
|
5
|
+
extxyz datasets for fine-tuning ASE-calculator-compatible MLIPs.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
uv venv .venv
|
|
11
|
+
source .venv/bin/activate
|
|
12
|
+
uv pip install -e ".[dev]"
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Usage (Python)
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
from nomad_forces_export import NomadQuery, fetch_dataset
|
|
19
|
+
|
|
20
|
+
query = NomadQuery(
|
|
21
|
+
elements=["Si", "O"],
|
|
22
|
+
exclude_datasets=["Alexandria PBEsol"],
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
fetch_dataset(
|
|
26
|
+
query=query,
|
|
27
|
+
properties={"energy", "forces"},
|
|
28
|
+
output_format="ase_db",
|
|
29
|
+
output_path="dataset.db",
|
|
30
|
+
max_entries=5000,
|
|
31
|
+
)
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Usage (CLI)
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
nomad-mlip-data fetch \
|
|
38
|
+
--elements Si O \
|
|
39
|
+
--exclude-dataset "Alexandria PBEsol" \
|
|
40
|
+
--properties energy forces \
|
|
41
|
+
--format ase_db extxyz \
|
|
42
|
+
--output dataset \
|
|
43
|
+
--max-entries 5000
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Testing
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pytest tests/ -v
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
All tests run against recorded NOMAD API fixtures in `tests/fixtures/` — no live
|
|
53
|
+
network access is required.
|
|
54
|
+
|
|
55
|
+
## Main contributors
|
|
56
|
+
| Name | E-mail |
|
|
57
|
+
|------|------------|
|
|
58
|
+
| Sharat Patil | [patilsha@physik.hu-berlin.de](mailto:patilsha@physik.hu-berlin.de)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "nomad-forces-export"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Fetch and package NOMAD atomistic data for MLIP training"
|
|
5
|
+
requires-python = ">=3.10"
|
|
6
|
+
|
|
7
|
+
authors = [
|
|
8
|
+
{ name = "Sharat Patil", email = "patilsha@physik.hu-berlin.de" },
|
|
9
|
+
]
|
|
10
|
+
maintainers = [
|
|
11
|
+
{ name = "Sharat Patil", email = "patilsha@physik.hu-berlin.de" },
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
dependencies = [
|
|
15
|
+
"ase>=3.23",
|
|
16
|
+
"loguru>=0.7.3",
|
|
17
|
+
"requests>=2.28",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.optional-dependencies]
|
|
21
|
+
dev = ["pytest>=7.0"]
|
|
22
|
+
|
|
23
|
+
[project.scripts]
|
|
24
|
+
nomad-forces-export = "nomad_forces_export.cli:main"
|
|
25
|
+
|
|
26
|
+
[build-system]
|
|
27
|
+
requires = ["setuptools>=68"]
|
|
28
|
+
build-backend = "setuptools.build_meta"
|
|
29
|
+
|
|
30
|
+
[tool.setuptools]
|
|
31
|
+
package-dir = {"" = "src"}
|
|
32
|
+
|
|
33
|
+
[tool.setuptools.packages.find]
|
|
34
|
+
where = ["src"]
|
|
35
|
+
include = ["nomad_forces_export*"]
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""nomad_forces_export: fetch and package NOMAD data for MLIP fine-tuning."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import List, Optional, Set, Union
|
|
6
|
+
|
|
7
|
+
from loguru import logger
|
|
8
|
+
|
|
9
|
+
from nomad_forces_export.api import NomadClient
|
|
10
|
+
from nomad_forces_export.extract import to_atoms
|
|
11
|
+
from nomad_forces_export.query import NomadQuery
|
|
12
|
+
from nomad_forces_export.write import write_atoms
|
|
13
|
+
|
|
14
|
+
# logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
'NomadQuery',
|
|
18
|
+
'atoms_generator',
|
|
19
|
+
'create_dataset_from_archives',
|
|
20
|
+
'fetch_dataset',
|
|
21
|
+
'write_atoms',
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def atoms_generator(archives, properties, max_frames: int | None = None):
|
|
26
|
+
n_frames = 0
|
|
27
|
+
n_entries_skipped = 0
|
|
28
|
+
entries_processed = 0
|
|
29
|
+
|
|
30
|
+
def _atoms_from_entry(archive_entry: dict):
|
|
31
|
+
nonlocal n_frames, n_entries_skipped, entries_processed
|
|
32
|
+
frames_for_entry = 0
|
|
33
|
+
for atoms in to_atoms(archive_entry, properties=properties):
|
|
34
|
+
frames_for_entry += 1
|
|
35
|
+
yield atoms
|
|
36
|
+
n_frames += 1
|
|
37
|
+
if max_frames is not None and n_frames >= max_frames:
|
|
38
|
+
logger.info(
|
|
39
|
+
f'Maximum number of frames ({max_frames}) reached; stopping extraction.'
|
|
40
|
+
)
|
|
41
|
+
return
|
|
42
|
+
frames_for_entry = bool(frames_for_entry)
|
|
43
|
+
n_entries_skipped += not frames_for_entry
|
|
44
|
+
entries_processed += frames_for_entry
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
for archive_entry in archives:
|
|
48
|
+
yield from _atoms_from_entry(archive_entry)
|
|
49
|
+
except Exception as e:
|
|
50
|
+
import traceback
|
|
51
|
+
|
|
52
|
+
logger.error(f'Traceback:\n{traceback.format_exc()}')
|
|
53
|
+
logger.error(f'Error occurred while processing entries: {e}')
|
|
54
|
+
logger.info(
|
|
55
|
+
f'Extracted {n_frames} frames from {entries_processed} entries; {n_entries_skipped} entries had no usable frames for requested properties. Total entries processed: {n_entries_skipped + entries_processed}.'
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def fetch_dataset(
|
|
60
|
+
query: NomadQuery,
|
|
61
|
+
properties: set[str],
|
|
62
|
+
output_format: str | list[str],
|
|
63
|
+
output_path: str,
|
|
64
|
+
max_entries: int | None = None,
|
|
65
|
+
max_frames: int | None = None,
|
|
66
|
+
batch_size: int = 50,
|
|
67
|
+
use_nomad_pkg: bool = False,
|
|
68
|
+
) -> None:
|
|
69
|
+
"""Fetch entries matching `query` from NOMAD and write them as a dataset.
|
|
70
|
+
|
|
71
|
+
Searches NOMAD for matching entry_ids (respecting `query.exclude_datasets`),
|
|
72
|
+
fetches full archive data in batches, extracts one `ase.Atoms` per calculation
|
|
73
|
+
frame with the requested `properties`, and writes the result to `output_path`
|
|
74
|
+
in the given `output_format` ("ase_db", "extxyz", or a list of both).
|
|
75
|
+
"""
|
|
76
|
+
if use_nomad_pkg:
|
|
77
|
+
search = query.search_nomad
|
|
78
|
+
fetch_archives = query.fetch_archives_nomad
|
|
79
|
+
else:
|
|
80
|
+
search = query.search
|
|
81
|
+
fetch_archives = query.fetch_archives
|
|
82
|
+
entry_ids = list(
|
|
83
|
+
search(
|
|
84
|
+
max_entries=max_entries,
|
|
85
|
+
properties=properties,
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
logger.info(
|
|
89
|
+
f'found {len(entry_ids)} matching entries after dataset exclusion filtering'
|
|
90
|
+
)
|
|
91
|
+
logger.info(f'Fetching {len(entry_ids)} entries in batches of {batch_size}...')
|
|
92
|
+
archives = fetch_archives(entry_ids=entry_ids, batch_size=batch_size)
|
|
93
|
+
write_atoms(
|
|
94
|
+
atoms_generator(archives, properties=properties, max_frames=max_frames),
|
|
95
|
+
output_path=output_path,
|
|
96
|
+
output_format=output_format,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def create_dataset_from_archives(
|
|
101
|
+
archives: list[dict] | str | Path,
|
|
102
|
+
properties: set[str],
|
|
103
|
+
output_format: str | list[str],
|
|
104
|
+
output_path: str,
|
|
105
|
+
max_frames: int | None = None,
|
|
106
|
+
) -> None:
|
|
107
|
+
"""Create a dataset from a list of NOMAD archive entries.
|
|
108
|
+
|
|
109
|
+
Extracts one `ase.Atoms` per calculation frame with the requested `properties`,
|
|
110
|
+
and writes the result to `output_path` in the given `output_format`
|
|
111
|
+
("ase_db", "extxyz", or a list of both).
|
|
112
|
+
"""
|
|
113
|
+
if isinstance(archives, (str, Path)):
|
|
114
|
+
entries = []
|
|
115
|
+
if isinstance(archives, str):
|
|
116
|
+
archives = Path(archives)
|
|
117
|
+
if archives.is_dir():
|
|
118
|
+
for file in Path(archives).glob('*.json'):
|
|
119
|
+
with open(file) as f:
|
|
120
|
+
entries.append(json.load(f))
|
|
121
|
+
elif archives.is_file():
|
|
122
|
+
with open(archives) as f:
|
|
123
|
+
entries = json.load(f)
|
|
124
|
+
else:
|
|
125
|
+
entries = archives
|
|
126
|
+
write_atoms(
|
|
127
|
+
atoms_generator(entries, properties=properties, max_frames=max_frames),
|
|
128
|
+
output_path=output_path,
|
|
129
|
+
output_format=output_format,
|
|
130
|
+
)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Low-level HTTP client for the NOMAD API with retry/backoff on transient errors."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
|
|
7
|
+
from nomad_forces_export.config import NOMAD_BASE_URL
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class NomadClient:
|
|
11
|
+
"""Thin wrapper around `requests` for calling the NOMAD API.
|
|
12
|
+
|
|
13
|
+
Retries on 5xx responses and on connection/timeout errors, using a fixed
|
|
14
|
+
backoff between attempts. Raises `requests.HTTPError` (or the underlying
|
|
15
|
+
`requests` exception) once retries are exhausted.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
base_url: str = NOMAD_BASE_URL,
|
|
21
|
+
max_retries: int = 3,
|
|
22
|
+
backoff_seconds: float = 5.0,
|
|
23
|
+
timeout: float = 60.0,
|
|
24
|
+
):
|
|
25
|
+
self.base_url = base_url
|
|
26
|
+
self.max_retries = max_retries
|
|
27
|
+
self.backoff_seconds = backoff_seconds
|
|
28
|
+
self.timeout = timeout
|
|
29
|
+
|
|
30
|
+
def post(self, path: str, payload: dict) -> dict:
|
|
31
|
+
"""POST `payload` as JSON to `base_url + path`, returning the parsed JSON body."""
|
|
32
|
+
url = f"{self.base_url}{path}"
|
|
33
|
+
last_exc: Exception | None = None
|
|
34
|
+
for attempt in range(1, self.max_retries + 1):
|
|
35
|
+
try:
|
|
36
|
+
response = requests.post(url, json=payload, timeout=self.timeout)
|
|
37
|
+
response.raise_for_status()
|
|
38
|
+
return response.json()
|
|
39
|
+
except requests.HTTPError as exc:
|
|
40
|
+
if exc.response is None:
|
|
41
|
+
raise
|
|
42
|
+
elif exc.response.status_code == 429:
|
|
43
|
+
# Too Many Requests - wait and retry
|
|
44
|
+
time.sleep(self.backoff_seconds)
|
|
45
|
+
continue
|
|
46
|
+
elif exc.response.status_code < 500:
|
|
47
|
+
raise
|
|
48
|
+
last_exc = exc
|
|
49
|
+
if attempt < self.max_retries:
|
|
50
|
+
time.sleep(self.backoff_seconds)
|
|
51
|
+
except (requests.ConnectionError, requests.Timeout) as exc:
|
|
52
|
+
last_exc = exc
|
|
53
|
+
if attempt < self.max_retries:
|
|
54
|
+
time.sleep(self.backoff_seconds)
|
|
55
|
+
raise last_exc
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Command-line interface for nomad_forces_export: thin wrapper over fetch_dataset."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from nomad_forces_export import NomadQuery, fetch_dataset
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def build_arg_parser() -> argparse.ArgumentParser:
|
|
10
|
+
parser = argparse.ArgumentParser(prog="nomad-mlip-data")
|
|
11
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
12
|
+
|
|
13
|
+
fetch_parser = subparsers.add_parser("fetch", help="Fetch a dataset from NOMAD")
|
|
14
|
+
fetch_parser.add_argument("--elements", nargs="+", default=None)
|
|
15
|
+
fetch_parser.add_argument("--formula", default=None)
|
|
16
|
+
fetch_parser.add_argument("--method", default=None)
|
|
17
|
+
fetch_parser.add_argument("--entry-ids", nargs="+", default=None, dest="entry_ids")
|
|
18
|
+
fetch_parser.add_argument(
|
|
19
|
+
"--exclude-dataset", nargs="+", default=[], dest="exclude_dataset"
|
|
20
|
+
)
|
|
21
|
+
fetch_parser.add_argument(
|
|
22
|
+
"--properties",
|
|
23
|
+
nargs="+",
|
|
24
|
+
default=["energy", "forces"],
|
|
25
|
+
choices=["energy", "forces", "stress"],
|
|
26
|
+
)
|
|
27
|
+
fetch_parser.add_argument(
|
|
28
|
+
"--format", nargs="+", default=["ase_db"], choices=["ase_db", "extxyz"]
|
|
29
|
+
)
|
|
30
|
+
fetch_parser.add_argument("--output", required=True)
|
|
31
|
+
fetch_parser.add_argument(
|
|
32
|
+
"--max-entries", type=int, default=None, dest="max_entries"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
return parser
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def main(argv: list[str] | None = None) -> None:
|
|
39
|
+
parser = build_arg_parser()
|
|
40
|
+
args = parser.parse_args(argv)
|
|
41
|
+
|
|
42
|
+
if args.command == "fetch":
|
|
43
|
+
query = NomadQuery(
|
|
44
|
+
elements=args.elements,
|
|
45
|
+
formula=args.formula,
|
|
46
|
+
method=args.method,
|
|
47
|
+
entry_ids=args.entry_ids,
|
|
48
|
+
exclude_datasets=args.exclude_dataset,
|
|
49
|
+
)
|
|
50
|
+
fetch_dataset(
|
|
51
|
+
query=query,
|
|
52
|
+
properties=set(args.properties),
|
|
53
|
+
output_format=args.format,
|
|
54
|
+
output_path=args.output,
|
|
55
|
+
max_entries=args.max_entries,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
if __name__ == "__main__":
|
|
60
|
+
main(sys.argv[1:])
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
VALID_FORMATS = {'ase_db', 'extxyz'}
|
|
2
|
+
VALID_PROPERTIES = {'energy', 'forces', 'stress'}
|
|
3
|
+
|
|
4
|
+
NOMAD_BASE_URL = 'https://nomad-lab.eu/prod/v1/api/v1'
|
|
5
|
+
|
|
6
|
+
WORKFLOWS = [
|
|
7
|
+
'SinglePoint',
|
|
8
|
+
'single_point',
|
|
9
|
+
'GeometryOptimization',
|
|
10
|
+
'geometry_optimization',
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
REQUIRED_METADATA = ['entry_id', 'upload_id']
|
|
14
|
+
REQUIRED_ARCHIVE_DATA = {
|
|
15
|
+
'metadata': {'entry_id': '*', 'upload_id': '*'},
|
|
16
|
+
'results': {
|
|
17
|
+
'method': '*',
|
|
18
|
+
},
|
|
19
|
+
'workflow2': {'results': {'is_converged_geometry': '*'}},
|
|
20
|
+
'workflow': {'geometry_optimization': {'is_converged_geometry': '*'}},
|
|
21
|
+
'run': {
|
|
22
|
+
'program': '*',
|
|
23
|
+
'method': '*',
|
|
24
|
+
'system': {'atoms': '*', 'is_representative': '*'},
|
|
25
|
+
'calculation': {
|
|
26
|
+
'energy': '*',
|
|
27
|
+
'forces': '*',
|
|
28
|
+
'stress': '*',
|
|
29
|
+
'system_ref': '*',
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
BASE_QUERY = {
|
|
35
|
+
'results.method.method_name:any': ['DFT'],
|
|
36
|
+
'results.method.workflow_name:any': WORKFLOWS,
|
|
37
|
+
'quantities:all': ['run.calculation', 'run.system'],
|
|
38
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Convert a NOMAD archive entry into one ase.Atoms object per calculation frame."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from collections.abc import Iterator
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
from ase import Atoms
|
|
8
|
+
from ase.calculators.singlepoint import SinglePointCalculator
|
|
9
|
+
|
|
10
|
+
from nomad_forces_export.config import VALID_PROPERTIES
|
|
11
|
+
from nomad_forces_export.units import (
|
|
12
|
+
joule_to_ev,
|
|
13
|
+
meter_to_angstrom,
|
|
14
|
+
newton_to_ev_per_angstrom,
|
|
15
|
+
pascal_to_ev_per_angstrom3,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _parse_system_ref(system_ref: str) -> tuple[int, int] | None:
|
|
22
|
+
# system_ref looks like "/run/0/system/3" (leading slash optional) ->
|
|
23
|
+
# extract both the run index and system index.
|
|
24
|
+
parts = system_ref.rstrip('/').split('/')
|
|
25
|
+
try:
|
|
26
|
+
run_index = int(parts[parts.index('run') + 1])
|
|
27
|
+
system_index = int(parts[parts.index('system') + 1])
|
|
28
|
+
except (ValueError, IndexError):
|
|
29
|
+
return None
|
|
30
|
+
return run_index, system_index
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _build_atoms_from_system(system: dict) -> Atoms:
|
|
34
|
+
atoms_data = system['atoms']
|
|
35
|
+
species = atoms_data['species']
|
|
36
|
+
positions_m = np.array(atoms_data['positions'])
|
|
37
|
+
positions_ang = meter_to_angstrom(positions_m)
|
|
38
|
+
magmoms = system.get('magmoms')
|
|
39
|
+
|
|
40
|
+
lattice_vectors_m = atoms_data.get('lattice_vectors')
|
|
41
|
+
periodic = atoms_data.get('periodic', [False, False, False])
|
|
42
|
+
|
|
43
|
+
cell = None
|
|
44
|
+
if lattice_vectors_m is not None:
|
|
45
|
+
cell = meter_to_angstrom(np.array(lattice_vectors_m))
|
|
46
|
+
|
|
47
|
+
return Atoms(
|
|
48
|
+
numbers=species,
|
|
49
|
+
positions=positions_ang,
|
|
50
|
+
cell=cell,
|
|
51
|
+
pbc=periodic,
|
|
52
|
+
magmoms=magmoms,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _extract_energy_ev(calculation: dict) -> float | None:
|
|
57
|
+
energy = calculation.get('energy')
|
|
58
|
+
if not energy:
|
|
59
|
+
return None
|
|
60
|
+
if 'total_t0' in energy:
|
|
61
|
+
return joule_to_ev(energy['total_t0']['value'])
|
|
62
|
+
if 'total' in energy:
|
|
63
|
+
return joule_to_ev(energy['total']['value'])
|
|
64
|
+
if 'free' in energy:
|
|
65
|
+
return joule_to_ev(energy['free']['value'])
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _extract_forces_ev_per_ang(calculation: dict) -> np.ndarray | None:
|
|
70
|
+
forces = calculation.get('forces')
|
|
71
|
+
if not forces or 'total' not in forces:
|
|
72
|
+
return None
|
|
73
|
+
forces_n = np.array(forces['total']['value'])
|
|
74
|
+
return newton_to_ev_per_angstrom(forces_n)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _extract_stress_ev_per_ang3(calculation: dict) -> np.ndarray | None:
|
|
78
|
+
stress = calculation.get('stress')
|
|
79
|
+
if not stress or 'total' not in stress:
|
|
80
|
+
return None
|
|
81
|
+
stress_pa = np.array(stress['total']['value'])
|
|
82
|
+
return pascal_to_ev_per_angstrom3(stress_pa)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def to_atoms(archive_entry: dict, properties: set | list[str]) -> Iterator[Atoms]:
|
|
86
|
+
"""Yield one `ase.Atoms` per calculation frame in a NOMAD archive entry.
|
|
87
|
+
|
|
88
|
+
`properties` is a subset of {"energy", "forces", "stress"}; frames missing any
|
|
89
|
+
requested property are skipped (and logged) rather than raising.
|
|
90
|
+
"""
|
|
91
|
+
unknown = set(properties) - VALID_PROPERTIES
|
|
92
|
+
if unknown:
|
|
93
|
+
raise ValueError(f'Unknown properties requested: {sorted(unknown)}')
|
|
94
|
+
|
|
95
|
+
entry_id = archive_entry.get('entry_id')
|
|
96
|
+
upload_id = archive_entry.get('upload_id')
|
|
97
|
+
runs = archive_entry.get('archive', {}).get('run', [])
|
|
98
|
+
|
|
99
|
+
for run_index, run in enumerate(runs):
|
|
100
|
+
systems = run.get('system', [])
|
|
101
|
+
calculations = run.get('calculation', [])
|
|
102
|
+
method = run.get('method', [{}])[0]
|
|
103
|
+
|
|
104
|
+
for calc_index, calculation in enumerate(calculations):
|
|
105
|
+
system_ref = calculation.get('system_ref')
|
|
106
|
+
if system_ref is not None:
|
|
107
|
+
parsed = _parse_system_ref(system_ref)
|
|
108
|
+
if parsed is None:
|
|
109
|
+
logger.warning(
|
|
110
|
+
f'entry {entry_id}: calculation {calc_index} has an unparseable system_ref {system_ref}, skipping'
|
|
111
|
+
)
|
|
112
|
+
continue
|
|
113
|
+
ref_run_index, system_index = parsed
|
|
114
|
+
if ref_run_index != run_index:
|
|
115
|
+
logger.warning(
|
|
116
|
+
f'entry {entry_id}: calculation {calc_index} has system_ref pointing to a '
|
|
117
|
+
f'different run ({ref_run_index}) than its own run ({run_index}), skipping',
|
|
118
|
+
)
|
|
119
|
+
continue
|
|
120
|
+
else:
|
|
121
|
+
system_index = calc_index
|
|
122
|
+
|
|
123
|
+
if system_index < 0 or system_index >= len(systems):
|
|
124
|
+
logger.warning(
|
|
125
|
+
'entry %s: calculation %d references missing system %d, skipping',
|
|
126
|
+
entry_id,
|
|
127
|
+
calc_index,
|
|
128
|
+
system_index,
|
|
129
|
+
)
|
|
130
|
+
continue
|
|
131
|
+
|
|
132
|
+
if method.get('x_vasp_incar_in'):
|
|
133
|
+
magmom = method['x_vasp_incar_in'].get('MAGMOM')
|
|
134
|
+
if magmom:
|
|
135
|
+
systems[system_index]['magmoms'] = np.array(magmom)
|
|
136
|
+
results: dict = {}
|
|
137
|
+
skip = ''
|
|
138
|
+
|
|
139
|
+
if 'energy' in properties:
|
|
140
|
+
energy_ev = _extract_energy_ev(calculation)
|
|
141
|
+
if energy_ev is None:
|
|
142
|
+
skip = 'energy'
|
|
143
|
+
else:
|
|
144
|
+
results['energy'] = energy_ev
|
|
145
|
+
|
|
146
|
+
if not skip and 'forces' in properties:
|
|
147
|
+
forces = _extract_forces_ev_per_ang(calculation)
|
|
148
|
+
if forces is None:
|
|
149
|
+
skip = 'forces'
|
|
150
|
+
else:
|
|
151
|
+
results['forces'] = forces
|
|
152
|
+
|
|
153
|
+
if not skip and 'stress' in properties:
|
|
154
|
+
stress = _extract_stress_ev_per_ang3(calculation)
|
|
155
|
+
if stress is None:
|
|
156
|
+
skip = 'stress'
|
|
157
|
+
else:
|
|
158
|
+
results['stress'] = stress
|
|
159
|
+
|
|
160
|
+
if skip:
|
|
161
|
+
logger.debug(
|
|
162
|
+
f'entry {entry_id}: calculation {calc_index} missing {skip}, skipping'
|
|
163
|
+
)
|
|
164
|
+
continue
|
|
165
|
+
|
|
166
|
+
atoms = _build_atoms_from_system(systems[system_index])
|
|
167
|
+
atoms.calc = SinglePointCalculator(atoms, **results)
|
|
168
|
+
atoms.info['nomad_entry_id'] = entry_id
|
|
169
|
+
atoms.info['nomad_upload_id'] = upload_id
|
|
170
|
+
atoms.info['is_representative'] = systems[system_index].get(
|
|
171
|
+
'is_representative', False
|
|
172
|
+
)
|
|
173
|
+
atoms.info['is_converged_geometry'] = run.get('workflow2', {}).get(
|
|
174
|
+
'results', {}
|
|
175
|
+
).get('is_converged_geometry', False) or run.get('workflow', {}).get(
|
|
176
|
+
'geometry_optimization', {}
|
|
177
|
+
).get('is_converged_geometry', False)
|
|
178
|
+
yield atoms
|