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.
Files changed (25) hide show
  1. nomad_forces_export-0.1.0/PKG-INFO +12 -0
  2. nomad_forces_export-0.1.0/README.md +58 -0
  3. nomad_forces_export-0.1.0/pyproject.toml +35 -0
  4. nomad_forces_export-0.1.0/setup.cfg +4 -0
  5. nomad_forces_export-0.1.0/src/nomad_forces_export/__init__.py +130 -0
  6. nomad_forces_export-0.1.0/src/nomad_forces_export/api.py +55 -0
  7. nomad_forces_export-0.1.0/src/nomad_forces_export/cli.py +60 -0
  8. nomad_forces_export-0.1.0/src/nomad_forces_export/config.py +38 -0
  9. nomad_forces_export-0.1.0/src/nomad_forces_export/extract.py +178 -0
  10. nomad_forces_export-0.1.0/src/nomad_forces_export/query.py +269 -0
  11. nomad_forces_export-0.1.0/src/nomad_forces_export/units.py +32 -0
  12. nomad_forces_export-0.1.0/src/nomad_forces_export/write.py +59 -0
  13. nomad_forces_export-0.1.0/src/nomad_forces_export.egg-info/PKG-INFO +12 -0
  14. nomad_forces_export-0.1.0/src/nomad_forces_export.egg-info/SOURCES.txt +23 -0
  15. nomad_forces_export-0.1.0/src/nomad_forces_export.egg-info/dependency_links.txt +1 -0
  16. nomad_forces_export-0.1.0/src/nomad_forces_export.egg-info/entry_points.txt +2 -0
  17. nomad_forces_export-0.1.0/src/nomad_forces_export.egg-info/requires.txt +6 -0
  18. nomad_forces_export-0.1.0/src/nomad_forces_export.egg-info/top_level.txt +1 -0
  19. nomad_forces_export-0.1.0/tests/test_api.py +64 -0
  20. nomad_forces_export-0.1.0/tests/test_cli.py +57 -0
  21. nomad_forces_export-0.1.0/tests/test_extract.py +207 -0
  22. nomad_forces_export-0.1.0/tests/test_fetch_dataset.py +74 -0
  23. nomad_forces_export-0.1.0/tests/test_query.py +125 -0
  24. nomad_forces_export-0.1.0/tests/test_units.py +27 -0
  25. 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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