marathon-train 0.2.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.
Files changed (52) hide show
  1. marathon/README.md +209 -0
  2. marathon/__init__.py +3 -0
  3. marathon/data/README.md +27 -0
  4. marathon/data/__init__.py +27 -0
  5. marathon/data/batching.py +236 -0
  6. marathon/data/properties.py +54 -0
  7. marathon/data/sample.py +199 -0
  8. marathon/data/sizes.py +19 -0
  9. marathon/data/splits.py +23 -0
  10. marathon/elemental.py +50 -0
  11. marathon/emit/README.md +47 -0
  12. marathon/emit/__init__.py +15 -0
  13. marathon/emit/checkpoint.py +199 -0
  14. marathon/emit/log.py +229 -0
  15. marathon/emit/plot.py +191 -0
  16. marathon/emit/pretty.py +110 -0
  17. marathon/emit/properties.py +85 -0
  18. marathon/evaluate/README.md +71 -0
  19. marathon/evaluate/__init__.py +5 -0
  20. marathon/evaluate/loss.py +123 -0
  21. marathon/evaluate/metrics.py +592 -0
  22. marathon/evaluate/predict.py +63 -0
  23. marathon/evaluate/properties.py +8 -0
  24. marathon/extra/__init__.py +0 -0
  25. marathon/extra/edge_to_edge/README.md +35 -0
  26. marathon/extra/edge_to_edge/__init__.py +4 -0
  27. marathon/extra/edge_to_edge/batching.py +100 -0
  28. marathon/extra/edge_to_edge/neighborlist.py +396 -0
  29. marathon/extra/hermes/__init__.py +10 -0
  30. marathon/extra/hermes/data_source.py +4 -0
  31. marathon/extra/hermes/pain.py +22 -0
  32. marathon/grain/README.md +44 -0
  33. marathon/grain/__init__.py +83 -0
  34. marathon/grain/data_source/README.md +9 -0
  35. marathon/grain/data_source/__init__.py +4 -0
  36. marathon/grain/data_source/data_source.py +107 -0
  37. marathon/grain/data_source/flatten_atoms.py +123 -0
  38. marathon/grain/data_source/prepare.py +87 -0
  39. marathon/grain/data_source/properties.py +232 -0
  40. marathon/grain/transforms/README.md +3 -0
  41. marathon/grain/transforms/__init__.py +25 -0
  42. marathon/grain/transforms/transforms.py +352 -0
  43. marathon/io/README.md +13 -0
  44. marathon/io/__init__.py +12 -0
  45. marathon/io/dicts.py +82 -0
  46. marathon/io/msgpack.py +33 -0
  47. marathon/io/yaml.py +43 -0
  48. marathon/utils.py +217 -0
  49. marathon_train-0.2.0.dist-info/METADATA +37 -0
  50. marathon_train-0.2.0.dist-info/RECORD +52 -0
  51. marathon_train-0.2.0.dist-info/WHEEL +4 -0
  52. marathon_train-0.2.0.dist-info/licenses/LICENSE +21 -0
marathon/README.md ADDED
@@ -0,0 +1,209 @@
1
+ # Technical readme
2
+
3
+ Welcome to the technical `marathon` readme! This is where we keep high-level conventions and so on. While the idea is that the subpackages are pretty independent, there are some implicit contracts that bind them together which we explain here. Individual modules make more targeted assumptions where needed; those are documented in their own `README.md` files.
4
+
5
+ ## Data model
6
+
7
+ The most basic internal data object is a `Sample`, which is a `namedtuple` of `(structure, labels)`. The `structure` dict describes the atomic geometry and its neighbor graph:
8
+
9
+ ```python
10
+ structure = {
11
+ "positions": ..., # (num_atoms, 3)
12
+ "atomic_numbers": ..., # (num_atoms,) -- Z values
13
+ "cell": ..., # (3, 3)
14
+ "charges": ..., # (num_atoms,)
15
+ "centers": ..., # (num_pairs,) -- i indices of neighbor pairs
16
+ "others": ..., # (num_pairs,) -- j indices of neighbor pairs
17
+ "displacements": ..., # (num_pairs, 3) -- R_ij vectors
18
+ "cell_shifts": ..., # (num_pairs, 3) -- periodic image shifts
19
+ "pbc": ..., # (3,) -- periodic boundary conditions
20
+ "max_neighbors": ..., # int -- max neighbors per atom
21
+ }
22
+ ```
23
+
24
+ The `labels` dict contains target properties:
25
+
26
+ ```python
27
+ labels = {
28
+ "energy": ..., # scalar -- total energy (eV)
29
+ "forces": ..., # (num_atoms, 3) -- eV/Å
30
+ "stress": ..., # (3, 3) -- dU/dε (eV, see below)
31
+ "num_atoms": ..., # int
32
+ }
33
+ ```
34
+
35
+ The neighbor list is computed by [`vesin`](https://github.com/Luthaf/vesin) from positions and a cutoff radius. The `displacements` array contains the actual $R_{ij} = R_j - R_i + S \cdot \text{cell}$ vectors. In the typical case, these are the quantities that the model operates on and that we differentiate through to obtain forces (see `evaluate/README.md`).
36
+
37
+ The next step is a `Batch`, which is another `namedtuple` that collates one or more `Sample`s, padded and index-offset so that an MPNN can process them as one big disconnected graph:
38
+
39
+ ```python
40
+ Batch(
41
+ atomic_numbers, # (num_atoms,) -- Z_i
42
+ displacements, # (num_pairs, 3) -- R_ij
43
+ centers, # (num_pairs,) -- i indices, offset per structure
44
+ others, # (num_pairs,) -- j indices, offset per structure
45
+ atom_to_structure, # (num_atoms,) -- which structure each atom belongs to
46
+ pair_to_structure, # (num_pairs,) -- which structure each pair belongs to
47
+ structure_mask, # (num_structures,) -- False for padding
48
+ atom_mask, # (num_atoms,) -- False for padding
49
+ pair_mask, # (num_pairs,) -- False for padding
50
+ labels, # dict of batched labels + masks
51
+ )
52
+ ```
53
+
54
+ It is expected that models that require something less off-the-shelf implement their own `Batch` class and related infrastructure. We try, as much as possible, to be agnostic to the internals of the batch. Only parts of the code that *must* explicitly interact with it care about internals, for example some parts of `marathon.evaluate`.
55
+
56
+ ## Padding and masking
57
+
58
+ Padding adds a single extra "padding structure" at the end of the batch. Its atoms fill the remaining allocated slots; its pairs point both endpoints to a single padding atom (creating self-loops that don't participate in message passing). Displacements for padding pairs are zero.
59
+
60
+ **Downstream code must handle zero displacements** ($R_{ij} = 0$). Division by $|R_{ij}|$ will blow up if you don't mask.
61
+
62
+ Three boolean masks distinguish real from padding: `structure_mask`, `atom_mask`, `pair_mask`. Additionally, each label `key` in `batch.labels` has a corresponding `key + "_mask"` (e.g. `energy_mask`, `forces_mask`) that is `True` where the label is valid and `False` where it's missing or padding. This handles the case where some structures have, say, energy labels but not stress labels. NaN values in input labels are treated as missing data and get mask `False`.
63
+
64
+ Depending on the model architecture, it may be better to always have `Batch`es with the exact same shape (so they can be stacked and processed with `vmap`/`scan`), or it may be better to batch dynamically to varying shapes. The former avoids recompilation; the latter reduces wasted computation. `marathon.grain` provides both strategies (`ToFixedShapeBatch` and `ToFixedLengthBatch`).
65
+
66
+
67
+ ## Label conventions
68
+
69
+ All *per-structure* properties are stored as genuinely per-structure quantities -- not per atom and not per volume:
70
+
71
+ - **Energy** is total energy (eV), not energy/atom.
72
+ - **Stress** is the ASE stress multiplied by volume, i.e. $dU/d\varepsilon$ in energy units (eV), not pressure. If you need stress in force/area units, divide by volume.
73
+ - **Forces** are per-atom by nature (eV/Å).
74
+
75
+ The properties/normalisation system (see below) can be used to change display units and normalise by atom count for loss computation, but it implicitly relies on inputs following this convention. The pipelines process `ase.Atoms` as inputs, and expect the energy there to be total energy and the stress the standard `ase` stress definition; we handle the conversion.
76
+
77
+ Everything up to generating a `Batch` is done in pure `numpy` in configurable precision (default: float64/int64). It is up to downstream code to convert to `jax` arrays and potentially cast into other `dtypes`. Some of the infrastructure is therefore also compatible with `pytorch`.
78
+
79
+
80
+ ## Properties System
81
+
82
+ Marathon supports custom properties beyond the standard `energy`, `forces`, `stress`. The key idea is that users define **one** `PROPERTIES` dict and **one** `NORMALIZATION` dict, then pass them to all relevant infrastructure--each module extracts what it needs.
83
+
84
+ ### Property definition
85
+
86
+ A property has these fields (not all are required everywhere):
87
+
88
+ ```python
89
+ PROPERTIES = {
90
+ "energy": {
91
+ "shape": (1,), # for data loading/batching
92
+ "storage": "atoms.calc", # for DataSource: where in ase.Atoms
93
+ "report_unit": (1000, "meV"), # for emit: (scale, unit)
94
+ "symbol": "E", # for emit: short symbol
95
+ },
96
+ "forces": {
97
+ "shape": ("atom", 3),
98
+ "storage": "atoms.calc",
99
+ "report_unit": (1000, "meV/Å"),
100
+ "symbol": "F",
101
+ },
102
+ # ...
103
+ }
104
+ ```
105
+
106
+ - `shape`: tuple where `"atom"` placeholder gets replaced with num_atoms. **Only leading `"atom"` dimension is supported** (e.g., `("atom", 3)` works, but `(3, "atom")` does not).
107
+ - `storage`: where to read/write from ase.Atoms: `"atoms.calc"`, `"atoms.arrays"`, `"atoms.info"`
108
+ - `report_unit`: `(scale_factor, base_unit)` for converting internal units (eV) to display units (meV)
109
+ - `symbol`: short symbol for console/log output
110
+
111
+ **Note on storage:** `atoms.calc` is reserved for `energy`, `forces`, and `stress` (these are what ASE calculators provide). Custom properties should use `atoms.arrays` (per-atom data) or `atoms.info` (per-structure data). We don't guarantee that other properties will work correctly through calculators.
112
+
113
+ ### Normalization
114
+
115
+ The normalization dict specifies which properties are divided by atom count:
116
+
117
+ ```python
118
+ NORMALIZATION = {
119
+ "energy": "atom",
120
+ "stress": "atom",
121
+ }
122
+ ```
123
+
124
+ - `"atom"` means divide by num_atoms during loss/metrics and append `/atom` to units
125
+ - Properties not listed (like forces) are not modified. Setting normalization to a quantity that's of dimension `[atom,...]` will yield undefined behaviour or crash.
126
+
127
+ ### Where configs are used
128
+
129
+ | Module | Uses from PROPERTIES | Uses NORMALIZATION |
130
+ |--------|---------------------|-------------------|
131
+ | `data/` batching | `shape` | - |
132
+ | `grain/DataSource` | `shape`, `storage` | - |
133
+ | `evaluate/` loss | - | yes |
134
+ | `evaluate/` metrics | - | yes |
135
+ | `emit/` logging | `report_unit`, `symbol` | yes (for unit suffix) |
136
+ | `emit/` plotting | `report_unit` | yes (for unit suffix) |
137
+
138
+ ### Custom properties example
139
+
140
+ To add dipole moment as a custom property:
141
+
142
+ ```python
143
+ # One unified PROPERTIES dict
144
+ PROPERTIES = {
145
+ "energy": {
146
+ "shape": (1,), "storage": "atoms.calc",
147
+ "report_unit": (1000, "meV"), "symbol": "E",
148
+ },
149
+ "forces": {
150
+ "shape": ("atom", 3), "storage": "atoms.calc",
151
+ "report_unit": (1000, "meV/Å"), "symbol": "F",
152
+ },
153
+ "dipole": {
154
+ "shape": (3,), "storage": "atoms.info", # per-structure, not from calc
155
+ "report_unit": (1, "Debye"), "symbol": "μ",
156
+ },
157
+ }
158
+
159
+ # One unified NORMALIZATION dict
160
+ NORMALIZATION = {
161
+ "energy": "atom",
162
+ # dipole not listed -> no /atom suffix, no per-atom normalization
163
+ }
164
+
165
+ # Pass to all relevant places:
166
+ from marathon.emit import Txt
167
+ from marathon.emit.pretty import format_metrics
168
+ from marathon.evaluate import get_loss_fn
169
+
170
+ logger = Txt(keys=["energy", "dipole"], properties=PROPERTIES, normalization=NORMALIZATION)
171
+ loss_fn = get_loss_fn(predict_fn, weights={"energy": 1.0, "dipole": 1.0}, normalization=NORMALIZATION)
172
+ msg = format_metrics(metrics, properties=PROPERTIES, normalization=NORMALIZATION)
173
+ ```
174
+
175
+ The defaults are split across modules for convenience (`data/properties.py`, `evaluate/properties.py`, `emit/properties.py`), but users can combine them into a single dict.
176
+
177
+
178
+ ## Serialization conventions
179
+
180
+ Model architectures are stored as YAML "spec dicts" -- a single-entry dict mapping a fully-qualified class path to its `__init__` kwargs:
181
+
182
+ ```yaml
183
+ my_package.models.MACE:
184
+ num_features: 128
185
+ num_layers: 3
186
+ ```
187
+
188
+ `marathon.io.to_dict(module)` serialises any dataclass (including `flax.nn.Module`); `marathon.io.from_dict(spec)` reconstructs it by dynamically importing the class. `parent` and `name` fields are excluded (these are Flax-internal).
189
+
190
+ Numerical data (weights, optimizer state) uses `msgpack` via Flax's serialization. Checkpoints combine both: the architecture lives in `model.yaml`, the weights in `model.msgpack`, training state in `state.msgpack`, and metrics in `metrics.yaml`. See `emit/README.md` for details.
191
+
192
+
193
+ ## Code style
194
+
195
+ Formatting is handled by `ruff` with a line length of 92. The line length is enforced by the formatter only, not the linter — so lines that can't be shortened automatically are left alone. We suppress rules that get in the way of research code: short variable names (`E741`), lambdas (`E731`), non-top-level imports (`E402`), and line length warnings (`E501`).
196
+
197
+ Import ordering groups `numpy` and `jax` before other third-party packages (configured via `isort` sections in `pyproject.toml`). Lazy imports are used for optional dependencies (`grain`, `matplotlib`, `wandb`, `numba`) and occasionally to avoid circular imports or heavy startup costs.
198
+
199
+ The code is deliberately kept **copy-and-pasteable** — individual files should be easy to pull into a script or notebook for quick hacking. This is part of the reason for inline testing, avoiding deep relative import chains, and keeping modules self-contained where possible.
200
+
201
+ More specifically:
202
+
203
+ - **Descriptive names, minimal docstrings.** Docstrings only where behaviour isn't obvious from the signature and context. Comments explain *why*, not *what*.
204
+ - **Lambdas and comprehensions** are used freely. Short closures are preferred over named helper functions when the logic is local and throwaway.
205
+ - **`namedtuple`** for data objects (`Sample`, `Batch`). Plain dicts for labels and properties configs. Dataclasses (via `flax.nn.Module` or the `frozen` helper in `utils.py`) for model components.
206
+ - **Factory functions** follow the `get_*_fn` pattern (e.g. `get_loss_fn`, `get_predict_fn`, `get_metrics_fn`). They return closures that capture configuration, keeping the hot path clean.
207
+ - **Inline tests** at the bottom of many modules, separated from production code by a `# -- test --` comment. Always use exactly this marker. These tests run on import and serve as both documentation and a basic smoke test. They're not a substitute for `pytest` but catch regressions immediately.
208
+ - **`__all__`** in `__init__.py` files defines the public API of each subpackage.
209
+ - **Private functions** are prefixed with `_` (e.g. `_huber`). No `__double_underscore` mangling.
marathon/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from opsis import Comms
2
+
3
+ comms = Comms("marathon")
@@ -0,0 +1,27 @@
1
+ # `marathon.data`: Samples, batching, and data splits
2
+
3
+ Converts `ase.Atoms` objects into the internal `Sample` and `Batch` representations described in the top-level README. This module is pure numpy and does not depend on JAX (except `splits.py`, which uses `jax.random` for reproducible shuffling).
4
+
5
+ ## `sample`: `ase.Atoms` → `Sample`
6
+
7
+ `to_structure(atoms, cutoff)` computes the neighbor list (via `vesin`) and packs geometry into a structure dict. `to_labels(atoms, ...)` extracts target properties using the `storage` field from the properties config. `to_sample` combines both.
8
+
9
+ For standard properties (`energy`, `forces`, `stress`), extraction from the ASE calculator handles the stress convention (multiply by volume to get $dU/d\varepsilon$). Custom properties are read from `atoms.info` or `atoms.arrays` as configured.
10
+
11
+ ## `batching`: `[Sample]` → `Batch`
12
+
13
+ `batch_samples` collates a list of `Sample`s into a single `Batch` namedtuple with padding. Index arrays (`centers`, `others`) are offset per structure so the batch looks like one disconnected graph. Labels are batched separately by `batch_labels`, which also creates per-key masks (NaN → `False`).
14
+
15
+ ## `sizes`: Buffer allocation
16
+
17
+ `determine_max_sizes` scans samples to find worst-case atom and pair counts for a given batch size, then rounds up via `next_size` to a JIT-friendly number.
18
+
19
+ Typically not needed and superseded by more complex `grain` machinery -- but this is nice for very small all-GPU training runs.
20
+
21
+ ## `splits`: Train/valid/test splits
22
+
23
+ `get_splits` draws non-overlapping index sets using `jax.random` for reproducibility.
24
+
25
+ ## `properties`: Shape and storage specs
26
+
27
+ `DEFAULT_PROPERTIES` defines `shape` and `storage` for the standard properties. `deduce_shape` resolves the `"atom"` placeholder at batch time.
@@ -0,0 +1,27 @@
1
+ import os
2
+ from pathlib import Path
3
+
4
+ from .batching import Batch, batch_labels, batch_samples
5
+ from .sample import Sample, to_sample, to_structure
6
+ from .sizes import determine_max_sizes
7
+ from .splits import get_splits
8
+
9
+ dataset_folder = os.environ.get("DATASETS")
10
+
11
+ if dataset_folder is not None:
12
+ datasets = Path(dataset_folder)
13
+ else:
14
+ datasets = Path.cwd()
15
+
16
+
17
+ __all__ = [
18
+ "Sample",
19
+ "Batch",
20
+ "batch_samples",
21
+ "batch_labels",
22
+ "determine_max_sizes",
23
+ "to_sample",
24
+ "to_structure",
25
+ "get_splits",
26
+ "datasets",
27
+ ]
@@ -0,0 +1,236 @@
1
+ import numpy as np
2
+
3
+ from collections import namedtuple
4
+
5
+ from .properties import DEFAULT_PROPERTIES, deduce_shape, is_per_atom
6
+ from .sample import Sample
7
+
8
+ # Padded, index-offset collation of multiple Samples into one disconnected graph.
9
+ Batch = namedtuple(
10
+ "Batch",
11
+ (
12
+ "atomic_numbers", # Z_i
13
+ "displacements", # R_ij
14
+ "centers", # i
15
+ "others", # j
16
+ "atom_to_structure", # map atomic_numbers to original structures
17
+ "pair_to_structure", # map displacements to original structures
18
+ "structure_mask", # False for padding
19
+ "atom_mask", # False for padding
20
+ "pair_mask", # False for padding
21
+ "labels",
22
+ ),
23
+ )
24
+
25
+
26
+ def batch_samples(
27
+ samples,
28
+ num_atoms,
29
+ num_pairs,
30
+ keys,
31
+ num_structures=None,
32
+ float_dtype=None,
33
+ int_dtype=None,
34
+ properties=DEFAULT_PROPERTIES,
35
+ ):
36
+ """Collate samples into a Batch, padding to fixed num_atoms/num_pairs with masks.
37
+
38
+ num_atoms/num_pairs must exceed the real totals (padding needs at least one extra slot).
39
+ """
40
+ if float_dtype is None:
41
+ float_dtype = samples[0].structure["displacements"].dtype
42
+ if int_dtype is None:
43
+ int_dtype = samples[0].structure["centers"].dtype
44
+
45
+ if num_structures is None:
46
+ num_structures = len(samples) + 1
47
+ else:
48
+ num_input_structures = len(samples)
49
+ assert num_input_structures + 1 <= num_structures
50
+
51
+ atomic_numbers = np.zeros(num_atoms, dtype=int_dtype)
52
+ displacements = np.zeros((num_pairs, 3), dtype=float_dtype)
53
+ centers = np.zeros(num_pairs, dtype=int_dtype)
54
+ others = np.zeros(num_pairs, dtype=int_dtype)
55
+ atom_to_structure = np.zeros(num_atoms, dtype=int_dtype)
56
+ pair_to_structure = np.zeros(num_pairs, dtype=int_dtype)
57
+ structure_mask = np.zeros(num_structures, dtype=bool)
58
+ atom_mask = np.zeros(num_atoms, dtype=bool)
59
+ pair_mask = np.zeros(num_pairs, dtype=bool)
60
+
61
+ labels = batch_labels(
62
+ [sample.labels for sample in samples],
63
+ num_structures,
64
+ num_atoms,
65
+ keys,
66
+ float_dtype=float_dtype,
67
+ int_dtype=int_dtype,
68
+ properties=properties,
69
+ )
70
+
71
+ atom_offset = 0
72
+ pair_offset = 0
73
+ for i, sample in enumerate(samples):
74
+ s = sample.structure
75
+
76
+ _n_atoms = s["atomic_numbers"].shape[0]
77
+ _n_pairs = s["displacements"].shape[0]
78
+
79
+ atom_slice = slice(atom_offset, atom_offset + _n_atoms)
80
+ pair_slice = slice(pair_offset, pair_offset + _n_pairs)
81
+
82
+ atomic_numbers[atom_slice] = s["atomic_numbers"]
83
+ displacements[pair_slice] = s["displacements"]
84
+ centers[pair_slice] = s["centers"] + atom_offset
85
+ others[pair_slice] = s["others"] + atom_offset
86
+
87
+ atom_to_structure[atom_slice] = i
88
+ pair_to_structure[pair_slice] = i
89
+
90
+ structure_mask[i] = True
91
+ atom_mask[atom_slice] = True
92
+ pair_mask[pair_slice] = True
93
+
94
+ atom_offset += _n_atoms
95
+ pair_offset += _n_pairs
96
+
97
+ assert atom_offset < len(atom_mask), "no room for padding!"
98
+
99
+ # now we add the padding
100
+
101
+ # skip atomic_numbers -- there is no element 0
102
+ # skip displacements -- already zero
103
+ centers[pair_offset:] = atom_offset
104
+ others[pair_offset:] = atom_offset
105
+
106
+ atom_to_structure[atom_offset:] = num_structures - 1
107
+ pair_to_structure[pair_offset:] = num_structures - 1
108
+
109
+ # skip masks -- already False
110
+
111
+ # skip labels -- already zero
112
+
113
+ return Batch(
114
+ atomic_numbers,
115
+ displacements,
116
+ centers,
117
+ others,
118
+ atom_to_structure,
119
+ pair_to_structure,
120
+ structure_mask,
121
+ atom_mask,
122
+ pair_mask,
123
+ labels,
124
+ )
125
+
126
+
127
+ def batch_labels(
128
+ list_of_labels,
129
+ num_structures,
130
+ num_atoms,
131
+ keys,
132
+ float_dtype=np.float64,
133
+ int_dtype=np.int64,
134
+ properties=DEFAULT_PROPERTIES,
135
+ ):
136
+ """Stack label dicts into padded arrays with per-key NaN-aware masks."""
137
+ labels = {}
138
+
139
+ for key in keys:
140
+ if key not in properties:
141
+ raise KeyError(f"unknown key: {key}")
142
+
143
+ shape = deduce_shape(num_structures, num_atoms, properties[key]["shape"])
144
+ labels[key] = np.zeros(shape, dtype=float_dtype)
145
+ labels[key + "_mask"] = labels[key].astype(bool)
146
+
147
+ labels["num_atoms"] = np.ones(num_structures, dtype=int_dtype)
148
+
149
+ atom_offset = 0
150
+ for i, l in enumerate(list_of_labels):
151
+ num_atoms = l["num_atoms"]
152
+ atom_slice = slice(atom_offset, atom_offset + num_atoms)
153
+
154
+ labels["num_atoms"][i] = num_atoms
155
+
156
+ for key in keys:
157
+ per_atom = is_per_atom(properties[key]["shape"])
158
+ values = l[key]
159
+ if not np.isnan(values).any():
160
+ if per_atom:
161
+ labels[key][atom_slice] = values
162
+ labels[key + "_mask"][atom_slice] = True
163
+ else:
164
+ # scalar per-structure: squeeze to avoid deprecation warning
165
+ labels[key][i] = np.squeeze(values)
166
+ labels[key + "_mask"][i] = True
167
+ # else: stays zero, mask False
168
+
169
+ atom_offset += num_atoms
170
+
171
+ return labels
172
+
173
+
174
+ # -- test --
175
+
176
+ test_samples = [
177
+ Sample(
178
+ dict(
179
+ displacements=np.ones((6, 3)),
180
+ atomic_numbers=np.array([0, 0, 0]),
181
+ centers=np.array([0, 0, 1, 1, 2, 2]),
182
+ others=np.array([1, 2, 0, 2, 0, 1]),
183
+ ),
184
+ {
185
+ "energy": 0.1,
186
+ "forces": np.ones((3, 3)),
187
+ "num_atoms": 3,
188
+ },
189
+ ),
190
+ Sample(
191
+ dict(
192
+ displacements=np.ones((2, 3)),
193
+ atomic_numbers=np.array([0, 0]),
194
+ centers=np.array([0, 1]),
195
+ others=np.array([1, 0]),
196
+ ),
197
+ {
198
+ "energy": 0.2,
199
+ "forces": np.ones((2, 3)),
200
+ "num_atoms": 2,
201
+ },
202
+ ),
203
+ ]
204
+
205
+ num_pairs = 10
206
+ num_atoms = 8
207
+
208
+ test_batch = batch_samples(test_samples, num_atoms, num_pairs, ["energy", "forces"])
209
+
210
+
211
+ assert test_batch.displacements.shape[0] == num_pairs
212
+ assert test_batch.centers.shape[0] == num_pairs
213
+ assert test_batch.others.shape[0] == num_pairs
214
+ assert test_batch.atomic_numbers.shape[0] == num_atoms
215
+ np.testing.assert_equal(
216
+ test_batch.displacements, np.concatenate((np.ones((8, 3)), np.zeros((2, 3))))
217
+ )
218
+
219
+ np.testing.assert_equal(test_batch.centers, np.array([0, 0, 1, 1, 2, 2, 3, 4, 5, 5]))
220
+ np.testing.assert_equal(test_batch.others, np.array([1, 2, 0, 2, 0, 1, 4, 3, 5, 5]))
221
+ np.testing.assert_equal(test_batch.atomic_numbers, np.array([0, 0, 0, 0, 0, 0, 0, 0]))
222
+ np.testing.assert_equal(test_batch.atom_to_structure, np.array([0, 0, 0, 1, 1, 2, 2, 2]))
223
+ np.testing.assert_equal(
224
+ test_batch.pair_to_structure, np.array([0, 0, 0, 0, 0, 0, 1, 1, 2, 2])
225
+ )
226
+
227
+ np.testing.assert_equal(
228
+ test_batch.atom_mask, np.array([True, True, True, True, True, False, False, False])
229
+ )
230
+ np.testing.assert_equal(test_batch.structure_mask, np.array([True, True, False]))
231
+
232
+ np.testing.assert_equal(test_batch.labels["energy"], np.array([0.1, 0.2, 0.0]))
233
+ np.testing.assert_equal(test_batch.labels["energy_mask"], np.array([True, True, False]))
234
+ assert test_batch.labels["forces"].shape == (8, 3)
235
+
236
+ np.testing.assert_array_equal(test_batch.labels["num_atoms"], np.array([3, 2, 1]))
@@ -0,0 +1,54 @@
1
+ DEFAULT_PROPERTIES = {
2
+ "energy": {"shape": (1,), "storage": "atoms.calc"},
3
+ "forces": {"shape": ("atom", 3), "storage": "atoms.calc"},
4
+ "stress": {"shape": (3, 3), "storage": "atoms.calc"},
5
+ }
6
+
7
+
8
+ def deduce_shape(num_structures, num_atoms, shape):
9
+ # batch context: resolves shape spec, prepends num_structures
10
+ # for non-atom properties.
11
+ # see also: marathon.grain.data_source.properties.deduce_shape
12
+
13
+ # scalar per structure: no dummy dimension
14
+ if shape == (1,):
15
+ shape = ()
16
+
17
+ # replace "atom" -> num_atoms, keep (optional) trailing shape
18
+ if (len(shape) > 0) and (shape[0] == "atom"):
19
+ shape = (num_atoms,) + shape[1:]
20
+ else:
21
+ shape = (num_structures,) + shape
22
+
23
+ return shape
24
+
25
+
26
+ def is_per_atom(shape):
27
+ return (len(shape) > 0) and (shape[0] == "atom")
28
+
29
+
30
+ # -- test --
31
+
32
+
33
+ def test_properties():
34
+ # deduce_shape: scalar per structure -> shape (num_structures,)
35
+ # (1,) removes dummy dim, then prepends num_structures
36
+ assert deduce_shape(5, 10, (1,)) == (5,)
37
+
38
+ # deduce_shape: per-atom property
39
+ assert deduce_shape(5, 10, ("atom", 3)) == (10, 3)
40
+ assert deduce_shape(5, 10, ("atom",)) == (10,)
41
+
42
+ # deduce_shape: per-structure tensor property
43
+ assert deduce_shape(5, 10, (3, 3)) == (5, 3, 3)
44
+ assert deduce_shape(5, 10, (6,)) == (5, 6)
45
+
46
+ # is_per_atom
47
+ assert is_per_atom(("atom", 3)) is True
48
+ assert is_per_atom(("atom",)) is True
49
+ assert is_per_atom((3, 3)) is False
50
+ assert is_per_atom((1,)) is False
51
+ assert is_per_atom(()) is False
52
+
53
+
54
+ test_properties()