nomad-forces-export 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nomad_forces_export/__init__.py +130 -0
- nomad_forces_export/api.py +55 -0
- nomad_forces_export/cli.py +60 -0
- nomad_forces_export/config.py +38 -0
- nomad_forces_export/extract.py +178 -0
- nomad_forces_export/query.py +269 -0
- nomad_forces_export/units.py +32 -0
- nomad_forces_export/write.py +59 -0
- nomad_forces_export-0.1.0.dist-info/METADATA +12 -0
- nomad_forces_export-0.1.0.dist-info/RECORD +13 -0
- nomad_forces_export-0.1.0.dist-info/WHEEL +5 -0
- nomad_forces_export-0.1.0.dist-info/entry_points.txt +2 -0
- nomad_forces_export-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""Build NOMAD search queries and list matching entry_ids, filtering excluded datasets."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import Iterable, Iterator
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
from loguru import logger
|
|
9
|
+
|
|
10
|
+
from nomad_forces_export.api import NomadClient
|
|
11
|
+
from nomad_forces_export.config import (
|
|
12
|
+
BASE_QUERY,
|
|
13
|
+
REQUIRED_ARCHIVE_DATA,
|
|
14
|
+
REQUIRED_METADATA,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class NomadQuery:
|
|
20
|
+
"""Describes a NOMAD entries search.
|
|
21
|
+
|
|
22
|
+
If `entry_ids` is given, it takes precedence over all other filters (elements,
|
|
23
|
+
formula, method, raw_query) -- the query becomes a direct id lookup.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
elements: list[str] | None = None
|
|
27
|
+
formula: str | None = None
|
|
28
|
+
method: str | None = None
|
|
29
|
+
dft_xc_functionals: list[str] | None = None
|
|
30
|
+
program: str | None = None
|
|
31
|
+
entry_ids: list[str] | None = None
|
|
32
|
+
exclude_datasets: list[str] = field(default_factory=list)
|
|
33
|
+
raw_query: dict | None = None
|
|
34
|
+
client: NomadClient | None = None
|
|
35
|
+
|
|
36
|
+
def to_query_dict(self, properties: set[str] | None = None) -> dict:
|
|
37
|
+
"""Build the NOMAD query dict (the `query` field of an API request body)."""
|
|
38
|
+
if self.entry_ids:
|
|
39
|
+
return {'entry_id': {'any': list(self.entry_ids)}}
|
|
40
|
+
|
|
41
|
+
query_dict: dict = BASE_QUERY.copy()
|
|
42
|
+
|
|
43
|
+
for prop in properties or []:
|
|
44
|
+
if prop not in {'energy', 'forces', 'stress'}:
|
|
45
|
+
raise ValueError(f'Unknown property requested: {prop}')
|
|
46
|
+
if prop == 'energy':
|
|
47
|
+
query_dict['quantities:all'].append(
|
|
48
|
+
'run.calculation.energy.total_t0.value'
|
|
49
|
+
)
|
|
50
|
+
else:
|
|
51
|
+
query_dict['quantities:all'].append(
|
|
52
|
+
f'run.calculation.{prop}.total.value'
|
|
53
|
+
)
|
|
54
|
+
if self.elements:
|
|
55
|
+
query_dict['results.material.elements'] = {'all': list(self.elements)}
|
|
56
|
+
if self.formula:
|
|
57
|
+
query_dict['results.material.chemical_formula_hill'] = self.formula
|
|
58
|
+
if self.method:
|
|
59
|
+
query_dict['results.method.method_name'] = self.method
|
|
60
|
+
if self.dft_xc_functionals:
|
|
61
|
+
query_dict['results.method.simulation.dft.xc_functional_names:any'] = list(
|
|
62
|
+
self.dft_xc_functionals
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
if self.program:
|
|
66
|
+
query_dict['results.method.simulation.program_name'] = self.program
|
|
67
|
+
if self.exclude_datasets:
|
|
68
|
+
query_dict['datasets.dataset_name:none'] = list(self.exclude_datasets)
|
|
69
|
+
query_dict['external_db:none'] = list(self.exclude_datasets)
|
|
70
|
+
if self.raw_query:
|
|
71
|
+
query_dict.update(self.raw_query)
|
|
72
|
+
return query_dict
|
|
73
|
+
|
|
74
|
+
def _is_excluded(self, entry: dict) -> bool:
|
|
75
|
+
if not self.exclude_datasets:
|
|
76
|
+
return False
|
|
77
|
+
dataset_names = [d.get('dataset_name', '') for d in entry.get('datasets', [])]
|
|
78
|
+
for excluded in self.exclude_datasets:
|
|
79
|
+
for name in dataset_names:
|
|
80
|
+
if excluded.lower() in name.lower():
|
|
81
|
+
return True
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
def search(
|
|
85
|
+
self,
|
|
86
|
+
max_entries: int | None = None,
|
|
87
|
+
properties: set[str] | None = None,
|
|
88
|
+
) -> Iterator[str]:
|
|
89
|
+
"""Yield entry_ids matching this query, skipping excluded datasets.
|
|
90
|
+
|
|
91
|
+
Paginates through `/entries/query` using NOMAD's `page_after_value` cursor
|
|
92
|
+
until no more pages remain, the cursor stops advancing, or `max_entries`
|
|
93
|
+
yielded entry_ids is reached.
|
|
94
|
+
"""
|
|
95
|
+
query_dict = self.to_query_dict(properties=properties)
|
|
96
|
+
page_after_value = None
|
|
97
|
+
yielded = 0
|
|
98
|
+
page_size = min(10000, max_entries) if max_entries is not None else 10000
|
|
99
|
+
client = self.client or NomadClient()
|
|
100
|
+
while True:
|
|
101
|
+
payload = {
|
|
102
|
+
'query': query_dict,
|
|
103
|
+
'pagination': {'page_size': page_size},
|
|
104
|
+
'required': {'include': REQUIRED_METADATA},
|
|
105
|
+
}
|
|
106
|
+
if page_after_value:
|
|
107
|
+
payload['pagination']['page_after_value'] = page_after_value
|
|
108
|
+
|
|
109
|
+
response = client.post('/entries/query', payload)
|
|
110
|
+
pagination = response.get('pagination', {})
|
|
111
|
+
total_entries = pagination.get('total')
|
|
112
|
+
logger.info(f'query returned total {total_entries})')
|
|
113
|
+
next_page_after_value = pagination.get('next_page_after_value')
|
|
114
|
+
|
|
115
|
+
# If the cursor returned for this page is identical to the cursor we
|
|
116
|
+
# just requested with, the server (or a mock) is not making progress
|
|
117
|
+
# (e.g. returning the same page repeatedly) -- stop before processing
|
|
118
|
+
# this page's data to avoid re-yielding already-seen entries.
|
|
119
|
+
if (
|
|
120
|
+
page_after_value is not None
|
|
121
|
+
and next_page_after_value == page_after_value
|
|
122
|
+
):
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
data = response.get('data', [])
|
|
126
|
+
for entry in data:
|
|
127
|
+
if self._is_excluded(entry):
|
|
128
|
+
continue
|
|
129
|
+
yield entry['entry_id']
|
|
130
|
+
yielded += 1
|
|
131
|
+
if max_entries is not None and yielded >= max_entries:
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
# Stop if there's no cursor or no data (no more pages).
|
|
135
|
+
if not next_page_after_value or not data:
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
page_after_value = next_page_after_value
|
|
139
|
+
|
|
140
|
+
def search_nomad(
|
|
141
|
+
self,
|
|
142
|
+
max_entries: int | None = None,
|
|
143
|
+
properties: set[str] | None = None,
|
|
144
|
+
) -> Iterator[str]:
|
|
145
|
+
"""Yield entry_ids matching this query, skipping excluded datasets.
|
|
146
|
+
|
|
147
|
+
Paginates through `/entries/query` using NOMAD's `page_after_value` cursor
|
|
148
|
+
until no more pages remain, the cursor stops advancing, or `max_entries`
|
|
149
|
+
yielded entry_ids is reached.
|
|
150
|
+
"""
|
|
151
|
+
from nomad.app.v1.models.models import (
|
|
152
|
+
MetadataRequired,
|
|
153
|
+
)
|
|
154
|
+
from nomad.client.archive import ArchiveQuery
|
|
155
|
+
|
|
156
|
+
query_dict = self.to_query_dict(properties=properties)
|
|
157
|
+
page_size = min(10000, max_entries) if max_entries is not None else 10000
|
|
158
|
+
yielded = 0
|
|
159
|
+
page_after_value = None
|
|
160
|
+
|
|
161
|
+
while True:
|
|
162
|
+
archive_query = ArchiveQuery(
|
|
163
|
+
query=query_dict,
|
|
164
|
+
required=MetadataRequired(include=REQUIRED_METADATA), # type: ignore
|
|
165
|
+
page_size=page_size,
|
|
166
|
+
after=page_after_value, # type: ignore
|
|
167
|
+
)
|
|
168
|
+
num_queries = archive_query.fetch()
|
|
169
|
+
if num_queries == 0:
|
|
170
|
+
return
|
|
171
|
+
for entry in archive_query.entry_list():
|
|
172
|
+
page_after_value = entry[0]
|
|
173
|
+
yield entry[0]
|
|
174
|
+
yielded += 1
|
|
175
|
+
if max_entries is not None and yielded >= max_entries:
|
|
176
|
+
return
|
|
177
|
+
|
|
178
|
+
def fetch_archives(
|
|
179
|
+
self,
|
|
180
|
+
entry_ids: Iterable[str],
|
|
181
|
+
batch_size: int = 50,
|
|
182
|
+
save_dir: str | None = None,
|
|
183
|
+
) -> Iterator[dict]:
|
|
184
|
+
"""Fetch full archive data (system + calculation sections) for `entry_ids`.
|
|
185
|
+
|
|
186
|
+
Requests are batched (`batch_size` entry_ids per request) to keep payload
|
|
187
|
+
sizes reasonable. Yields one archive dict (as returned in the response
|
|
188
|
+
`data` list) per entry.
|
|
189
|
+
"""
|
|
190
|
+
client = self.client or NomadClient()
|
|
191
|
+
entry_ids = list(entry_ids)
|
|
192
|
+
required = REQUIRED_ARCHIVE_DATA
|
|
193
|
+
batch_size = min(batch_size, 1000)
|
|
194
|
+
for i in range(0, len(entry_ids), batch_size):
|
|
195
|
+
batch = entry_ids[i : i + batch_size]
|
|
196
|
+
payload = {
|
|
197
|
+
'query': {'entry_id': {'any': batch}},
|
|
198
|
+
'required': required,
|
|
199
|
+
'pagination': {'page_size': batch_size},
|
|
200
|
+
}
|
|
201
|
+
response = client.post('/entries/archive/query', payload)
|
|
202
|
+
time.sleep(1) # avoid overwhelming the server with back-to-back requests
|
|
203
|
+
logger.info(
|
|
204
|
+
f'Batch no {i // batch_size + 1}: {len(response.get("data", []))} archive entries.'
|
|
205
|
+
)
|
|
206
|
+
for archive_entry in response.get('data', []):
|
|
207
|
+
if save_dir:
|
|
208
|
+
os.makedirs(f'{save_dir}/archives', exist_ok=True)
|
|
209
|
+
entry_id = archive_entry.get('entry_id')
|
|
210
|
+
if entry_id:
|
|
211
|
+
with open(
|
|
212
|
+
f'{save_dir}/archives/archive_{entry_id}.json', 'w'
|
|
213
|
+
) as f:
|
|
214
|
+
import json
|
|
215
|
+
|
|
216
|
+
json.dump(archive_entry, f, indent=2)
|
|
217
|
+
yield archive_entry
|
|
218
|
+
|
|
219
|
+
def fetch_archives_nomad(
|
|
220
|
+
self,
|
|
221
|
+
entry_ids: Iterable[str],
|
|
222
|
+
batch_size: int = 50,
|
|
223
|
+
save_dir: str | None = None,
|
|
224
|
+
) -> Iterator[dict]:
|
|
225
|
+
"""Fetch full archive data (system + calculation sections) for `entry_ids`.
|
|
226
|
+
|
|
227
|
+
Requests are batched (`batch_size` entry_ids per request) to keep payload
|
|
228
|
+
sizes reasonable. Yields one archive dict (as returned in the response
|
|
229
|
+
`data` list) per entry.
|
|
230
|
+
"""
|
|
231
|
+
from nomad.client.archive import ArchiveQuery
|
|
232
|
+
|
|
233
|
+
entry_ids = list(entry_ids)
|
|
234
|
+
required = REQUIRED_ARCHIVE_DATA.copy()
|
|
235
|
+
# required.pop(
|
|
236
|
+
# 'workflow2', None
|
|
237
|
+
# ) # workflow2 is not available in the nomad package
|
|
238
|
+
for i in range(0, len(entry_ids), batch_size):
|
|
239
|
+
batch = entry_ids[i : i + batch_size]
|
|
240
|
+
query = {'entry_id': {'any': batch}}
|
|
241
|
+
archive_query = ArchiveQuery(
|
|
242
|
+
query=query,
|
|
243
|
+
# required=required,
|
|
244
|
+
page_size=batch_size,
|
|
245
|
+
batch_size=batch_size,
|
|
246
|
+
)
|
|
247
|
+
number_of_entries = archive_query.fetch()
|
|
248
|
+
logger.info(
|
|
249
|
+
f'Batch no {i // batch_size + 1}: {number_of_entries} archive entries.'
|
|
250
|
+
)
|
|
251
|
+
archive_query._required = required
|
|
252
|
+
archives = archive_query.download(as_plain_dict=True)
|
|
253
|
+
for archive_entry in archives:
|
|
254
|
+
archive = {
|
|
255
|
+
'entry_id': archive_entry['metadata']['entry_id'],
|
|
256
|
+
'upload_id': archive_entry['metadata']['upload_id'],
|
|
257
|
+
'archive': archive_entry,
|
|
258
|
+
}
|
|
259
|
+
if save_dir:
|
|
260
|
+
os.makedirs(f'{save_dir}/archives', exist_ok=True)
|
|
261
|
+
entry_id = archive_entry['metadata']['entry_id']
|
|
262
|
+
if entry_id:
|
|
263
|
+
with open(
|
|
264
|
+
f'{save_dir}/archives/archive_{entry_id}.json', 'w'
|
|
265
|
+
) as f:
|
|
266
|
+
import json
|
|
267
|
+
|
|
268
|
+
json.dump(archive, f, indent=2)
|
|
269
|
+
yield archive
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Unit conversions from NOMAD's SI-based archive units to ASE's units.
|
|
2
|
+
|
|
3
|
+
ASE's internal unit system uses electron-volt (eV) for energy, Angstrom (Ang) for
|
|
4
|
+
length, eV/Ang for force, and eV/Ang^3 for stress/pressure. NOMAD archives store
|
|
5
|
+
raw SI values (Joule, meter, Newton, Pascal). These helpers convert one direction:
|
|
6
|
+
NOMAD (SI) -> ASE units.
|
|
7
|
+
"""
|
|
8
|
+
from ase import units as ase_units
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def joule_to_ev(value: float) -> float:
|
|
12
|
+
"""Convert an energy value in Joules to electron-volts."""
|
|
13
|
+
# ase_units.J is the number of eV in one Joule.
|
|
14
|
+
return value * ase_units.J
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def meter_to_angstrom(value: float) -> float:
|
|
18
|
+
"""Convert a length value in meters to Angstrom."""
|
|
19
|
+
# ase_units.m is the number of Angstrom in one meter.
|
|
20
|
+
return value * ase_units.m
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def newton_to_ev_per_angstrom(value: float) -> float:
|
|
24
|
+
"""Convert a force value in Newtons to eV/Angstrom."""
|
|
25
|
+
# 1 N = 1 J/m, so N -> eV/Ang is ase_units.J / ase_units.m
|
|
26
|
+
return value * ase_units.J / ase_units.m
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def pascal_to_ev_per_angstrom3(value: float) -> float:
|
|
30
|
+
"""Convert a stress/pressure value in Pascals to eV/Angstrom^3."""
|
|
31
|
+
# 1 Pa = 1 J/m^3, so Pa -> eV/Ang^3 is ase_units.J / ase_units.m**3
|
|
32
|
+
return value * ase_units.J / (ase_units.m ** 3)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Write ase.Atoms iterables to ASE-DB and/or extxyz files on disk."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from collections.abc import Iterable
|
|
5
|
+
|
|
6
|
+
from ase import Atoms
|
|
7
|
+
from ase.db import connect
|
|
8
|
+
from ase.io import write as ase_write
|
|
9
|
+
|
|
10
|
+
from nomad_forces_export.config import VALID_FORMATS
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _path_for_format(output_path: str, output_format: str) -> str:
|
|
14
|
+
root, ext = os.path.splitext(output_path)
|
|
15
|
+
if ext not in ('.db', '.xyz', '.extxyz'):
|
|
16
|
+
root = output_path
|
|
17
|
+
if output_format == 'ase_db':
|
|
18
|
+
return output_path if ext == '.db' else f'{root}.db'
|
|
19
|
+
if output_format == 'extxyz':
|
|
20
|
+
return output_path if ext in ('.xyz', '.extxyz') else f'{root}.xyz'
|
|
21
|
+
raise ValueError(f'Unknown output format: {output_format}')
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def write_atoms(
|
|
25
|
+
atoms_iter: Iterable[Atoms],
|
|
26
|
+
output_path: str,
|
|
27
|
+
output_format: str | list[str],
|
|
28
|
+
) -> None:
|
|
29
|
+
"""Write `atoms_iter` to disk in one or more formats.
|
|
30
|
+
|
|
31
|
+
`output_format` is `"ase_db"`, `"extxyz"`, or a list containing both. When a
|
|
32
|
+
list is given, `output_path`'s extension is replaced per-format (e.g.
|
|
33
|
+
`"dataset"` becomes `dataset.db` and `dataset.extxyz`).
|
|
34
|
+
"""
|
|
35
|
+
formats = [output_format] if isinstance(output_format, str) else list(output_format)
|
|
36
|
+
unknown = set(formats) - VALID_FORMATS
|
|
37
|
+
if unknown:
|
|
38
|
+
raise ValueError(f'Unknown output format(s): {sorted(unknown)}')
|
|
39
|
+
|
|
40
|
+
atoms_list = list(atoms_iter)
|
|
41
|
+
|
|
42
|
+
if 'ase_db' in formats:
|
|
43
|
+
db_path = _path_for_format(output_path, 'ase_db')
|
|
44
|
+
if os.path.exists(db_path):
|
|
45
|
+
os.remove(db_path)
|
|
46
|
+
with connect(db_path) as db:
|
|
47
|
+
for atoms in atoms_list:
|
|
48
|
+
key_value_pairs = {
|
|
49
|
+
'nomad_entry_id': atoms.info.get('nomad_entry_id', ''),
|
|
50
|
+
'nomad_upload_id': atoms.info.get('nomad_upload_id', ''),
|
|
51
|
+
}
|
|
52
|
+
db.write(atoms, key_value_pairs=key_value_pairs, data=atoms.info)
|
|
53
|
+
|
|
54
|
+
if 'extxyz' in formats:
|
|
55
|
+
xyz_path = _path_for_format(output_path, 'extxyz')
|
|
56
|
+
if os.path.exists(xyz_path):
|
|
57
|
+
os.remove(xyz_path)
|
|
58
|
+
for atoms in atoms_list:
|
|
59
|
+
ase_write(xyz_path, atoms, format='extxyz', append=True)
|
|
@@ -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,13 @@
|
|
|
1
|
+
nomad_forces_export/__init__.py,sha256=gchMcvlMFEeU5M2ZtfeUU8IrMAuQFo-d41ZgcNJtEPE,4446
|
|
2
|
+
nomad_forces_export/api.py,sha256=edpHofny42aqmw9P0bGbgDauGctipzLQulh9S8xhQrg,2009
|
|
3
|
+
nomad_forces_export/cli.py,sha256=wsNFm-wDFrRKdV2fqdXA4rVKRQ0HUsLwT5v1tA9qsMs,1943
|
|
4
|
+
nomad_forces_export/config.py,sha256=-qfxS0gSAhAv7JsdLnLvyw3BzEdqeyQ5fLIHdm-tKwo,1016
|
|
5
|
+
nomad_forces_export/extract.py,sha256=EAIJRIjq8EWxlUJYYivoIK6DK6QKIYV78NIXamsW8eA,6458
|
|
6
|
+
nomad_forces_export/query.py,sha256=_C99aDnYT4rWtOw61leFRSqA1Vp0USwozfdVqpn2oe8,10630
|
|
7
|
+
nomad_forces_export/units.py,sha256=c4u5Wr6e4UdAL_pr0QRFTen6PpI9nI4ZrianjrpiooM,1239
|
|
8
|
+
nomad_forces_export/write.py,sha256=yE_esOVeeKAXMa72NwR9Ba6zmUDEgk6sU3_0RGEed0E,2154
|
|
9
|
+
nomad_forces_export-0.1.0.dist-info/METADATA,sha256=B32W0K3qekLTlL0326A-czCAxzfZhndqZe54K6YbePc,420
|
|
10
|
+
nomad_forces_export-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
11
|
+
nomad_forces_export-0.1.0.dist-info/entry_points.txt,sha256=NLgBLMIQ-co49PXgcpLawDrvQZHcO2K6WLY9141gPBQ,69
|
|
12
|
+
nomad_forces_export-0.1.0.dist-info/top_level.txt,sha256=eIvx4I2uSmOJ-w-EcHzTIbZ18D1CVaWrUI1nBkrqMmU,20
|
|
13
|
+
nomad_forces_export-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
nomad_forces_export
|