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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ nomad-forces-export = nomad_forces_export.cli:main
@@ -0,0 +1 @@
1
+ nomad_forces_export