lorem-jax 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.
lorem/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ from opsis.comms import Comms
2
+
3
+ from ._version import __version__
4
+ from .batching import to_batch, to_sample
5
+ from .calculator import Calculator as LOREMCalculator
6
+ from .models.bec import LoremBEC
7
+ from .models.mlip import Lorem
8
+ from .transforms import ToBatch, ToSample
9
+
10
+ comms = Comms("lorem")
11
+
12
+ __all__ = [
13
+ "__version__",
14
+ "Lorem",
15
+ "LoremBEC",
16
+ "LOREMCalculator",
17
+ "to_sample",
18
+ "to_batch",
19
+ "ToSample",
20
+ "ToBatch",
21
+ ]
@@ -0,0 +1,31 @@
1
+ """Helper to install the LOREM driver into i-PI."""
2
+
3
+ import shutil
4
+ from importlib.util import find_spec
5
+ from pathlib import Path
6
+
7
+
8
+ def install_ipi_driver():
9
+ """Copy the installed `lorem.py` into the i-PI `pes` directory."""
10
+
11
+ # Locate i-PI installation
12
+ ipi_spec = find_spec("ipi")
13
+ if ipi_spec is None or not ipi_spec.submodule_search_locations:
14
+ raise RuntimeError(
15
+ "i-PI installation not found. Install i-PI and rerun "
16
+ "`lorem-install-ipi-driver`.",
17
+ )
18
+ pes_dir = Path(ipi_spec.submodule_search_locations[0]) / "pes"
19
+
20
+ # Locate source driver module
21
+ source_spec = find_spec("lorem.ipi")
22
+ if source_spec is None or source_spec.origin is None:
23
+ raise FileNotFoundError("Could not locate lorem.ipi module")
24
+ source_path = Path(source_spec.origin)
25
+
26
+ target_path = pes_dir / "lorem.py"
27
+ shutil.copy(source_path, target_path)
28
+
29
+
30
+ if __name__ == "__main__":
31
+ install_ipi_driver()
lorem/_version.py ADDED
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.0'
22
+ __version_tuple__ = version_tuple = (0, 1, 0)
23
+
24
+ __commit_id__ = commit_id = None
lorem/batching.py ADDED
@@ -0,0 +1,113 @@
1
+ import numpy as np
2
+
3
+ from collections import namedtuple
4
+
5
+ from jaxpme.batched_mixed.batching import get_batch as jaxpme_batcher
6
+ from jaxpme.batched_mixed.batching import prepare as jaxpme_prepare
7
+ from marathon.data.batching import batch_labels
8
+ from marathon.data.properties import DEFAULT_PROPERTIES
9
+ from marathon.data.sample import to_labels
10
+ from marathon.utils import next_size
11
+
12
+ Batch = namedtuple(
13
+ "Batch",
14
+ (
15
+ "atomic_numbers",
16
+ "sr",
17
+ "nopbc",
18
+ "pbc",
19
+ "labels",
20
+ ),
21
+ )
22
+
23
+
24
+ Sample = namedtuple(
25
+ "Sample",
26
+ (
27
+ "structure",
28
+ "labels",
29
+ ),
30
+ )
31
+
32
+
33
+ def to_batch(
34
+ samples,
35
+ keys,
36
+ batch_size=None,
37
+ strategies={"default": "powers_of_2"},
38
+ shapes=None,
39
+ properties=DEFAULT_PROPERTIES,
40
+ ):
41
+ if batch_size is not None:
42
+ assert batch_size > len(samples)
43
+ else:
44
+ batch_size = next_size(len(samples) + 1, strategy="powers_of_2")
45
+
46
+ labels, structures = [], []
47
+
48
+ for sample in samples:
49
+ labels.append(sample.labels)
50
+ structures.append(sample.structure)
51
+
52
+ if shapes is None:
53
+ default = strategies.pop("default", "powers_of_2")
54
+ _, sr, nopbc, pbc = jaxpme_batcher(
55
+ structures,
56
+ strategy=default,
57
+ num_structures_pbc=strategies.get("fine", default),
58
+ num_pairs_nonpbc=strategies.get("coarse", default),
59
+ num_pairs=strategies.get("coarse", default),
60
+ num_structures=batch_size,
61
+ )
62
+ else:
63
+ kwargs = {
64
+ "num_structures": batch_size,
65
+ "num_structures_pbc": shapes["pbc"],
66
+ "num_atoms": shapes["atoms"],
67
+ "num_atoms_pbc": shapes["atoms_pbc"],
68
+ "num_pairs": shapes["pairs"],
69
+ "num_pairs_nonpbc": shapes["pairs_nonpbc"],
70
+ "num_k": shapes["k"],
71
+ "strategy": "multiples",
72
+ }
73
+ _, sr, nopbc, pbc = jaxpme_batcher(
74
+ structures,
75
+ **kwargs,
76
+ )
77
+
78
+ num_structures = sr.cell.shape[0]
79
+ num_atoms = sr.positions.shape[0]
80
+
81
+ atomic_numbers = np.zeros(num_atoms, dtype=int)
82
+ Z = np.concatenate([sample.structure["atomic_numbers"] for sample in samples])
83
+ atomic_numbers[: len(Z)] = Z
84
+
85
+ labels = batch_labels(labels, num_structures, num_atoms, keys, properties=properties)
86
+
87
+ return Batch(atomic_numbers, sr, nopbc, pbc, labels)
88
+
89
+
90
+ def to_sample(
91
+ atoms,
92
+ cutoff,
93
+ keys=None,
94
+ energy=True,
95
+ forces=True,
96
+ stress=False,
97
+ lr_wavelength=None,
98
+ smearing=None,
99
+ properties=DEFAULT_PROPERTIES,
100
+ ):
101
+ structure = jaxpme_prepare(
102
+ atoms, cutoff, lr_wavelength=lr_wavelength, smearing=smearing, dtype=np.float32
103
+ )
104
+ labels = to_labels(
105
+ atoms,
106
+ keys=keys,
107
+ energy=energy,
108
+ forces=forces,
109
+ stress=stress,
110
+ properties=properties,
111
+ )
112
+
113
+ return Sample(structure, labels)
lorem/calculator.py ADDED
@@ -0,0 +1,245 @@
1
+ import numpy as np
2
+ import jax
3
+ import jax.numpy as jnp
4
+
5
+ jax.config.update("jax_default_matmul_precision", "float32")
6
+
7
+ from ase.calculators.calculator import (
8
+ BaseCalculator,
9
+ PropertyNotImplementedError,
10
+ )
11
+ from marathon.emit.checkpoint import read_msgpack
12
+ from marathon.io import from_dict, read_yaml
13
+
14
+ from lorem.neighborlist import NeighborListCache
15
+
16
+
17
+ class Calculator(BaseCalculator):
18
+ name = "lorem"
19
+ parameters = {}
20
+
21
+ def todict(self):
22
+ return self.parameters
23
+
24
+ # default; rebuilt per-instance in __init__ from what the model predicts
25
+ implemented_properties = ["energy", "forces"]
26
+
27
+ def __init__(
28
+ self,
29
+ pred_fn,
30
+ species_weights,
31
+ params,
32
+ cutoff,
33
+ atoms=None,
34
+ stress=False,
35
+ bec=False,
36
+ add_offset=True,
37
+ double_precision=False,
38
+ skin=0.25,
39
+ ):
40
+ self.params = params
41
+ self.cutoff = cutoff
42
+ self.skin = skin
43
+ self.add_offset = add_offset
44
+ self.double_precision = double_precision
45
+
46
+ self._nl_cache = NeighborListCache(skin=skin)
47
+
48
+ # only advertise what the model actually produces
49
+ self.implemented_properties = ["energy", "forces"]
50
+ if stress:
51
+ self.implemented_properties.append("stress")
52
+ if bec:
53
+ self.implemented_properties.append("born_effective_charges")
54
+
55
+ predict_fn = lambda params, batch: pred_fn(params, batch, stress=stress)
56
+
57
+ self.predict_fn = jax.jit(predict_fn)
58
+ self.species_weights = species_weights
59
+
60
+ self.atoms = None
61
+ self.batch = None
62
+ self.results = {}
63
+ if atoms is not None:
64
+ self.setup(atoms)
65
+
66
+ @classmethod
67
+ def from_model(cls, model, params=None, species_weights=None, **kwargs):
68
+ """Create a Calculator from a Lorem model instance."""
69
+ if params is None:
70
+ params = model.init(jax.random.key(0), *model.dummy_inputs())
71
+ if species_weights is None:
72
+ species_weights = {}
73
+ kwargs.setdefault("add_offset", False)
74
+ kwargs.setdefault("bec", _model_predicts_bec(model))
75
+ return cls(model.predict, species_weights, params, model.cutoff, **kwargs)
76
+
77
+ @classmethod
78
+ def from_checkpoint(
79
+ cls,
80
+ folder,
81
+ **kwargs,
82
+ ):
83
+ from pathlib import Path
84
+
85
+ folder = Path(folder)
86
+
87
+ model = from_dict(read_yaml(folder / "model/model.yaml"))
88
+
89
+ _ = model.init(jax.random.key(1), *model.dummy_inputs())
90
+
91
+ baseline = read_yaml(folder / "model/baseline.yaml")
92
+ species_to_weight = baseline["elemental"]
93
+
94
+ params = read_msgpack(folder / "model/model.msgpack")
95
+
96
+ kwargs.setdefault("bec", _model_predicts_bec(model))
97
+ return cls(model.predict, species_to_weight, params, model.cutoff, **kwargs)
98
+
99
+ def update(self, atoms):
100
+ if self._nl_cache.needs_update(atoms):
101
+ # Structural change or combined displacement beyond skin
102
+ self.results = {}
103
+ self.atoms = atoms.copy()
104
+ self.setup(atoms)
105
+ elif self.atoms is None or not self._geometry_unchanged(atoms):
106
+ # Positions and/or cell changed but within skin budget
107
+ self.results = {}
108
+ self.atoms = atoms.copy()
109
+ self._update_geometry(atoms)
110
+
111
+ def _geometry_unchanged(self, atoms):
112
+ return np.array_equal(
113
+ atoms.get_positions(), self.atoms.get_positions()
114
+ ) and np.array_equal(atoms.get_cell()[:], self.atoms.get_cell()[:])
115
+
116
+ def setup(self, atoms):
117
+ from lorem.batching import to_batch, to_sample
118
+
119
+ nl_cutoff = self.cutoff + self.skin
120
+
121
+ # Derive Ewald parameters from physical cutoff so the long-range
122
+ # decomposition is unchanged when using the extended cutoff.
123
+ lr_wavelength = self.cutoff / 8.0
124
+ smearing = lr_wavelength * 2.0
125
+
126
+ sample = to_sample(
127
+ atoms,
128
+ nl_cutoff,
129
+ lr_wavelength=lr_wavelength,
130
+ smearing=smearing,
131
+ energy=False,
132
+ forces=False,
133
+ stress=False,
134
+ )
135
+ batch = to_batch([sample], [])
136
+ self.batch = jax.tree.map(lambda x: jnp.array(x), batch)
137
+
138
+ max_cell_shift = int(np.abs(np.array(self.batch.sr.cell_shifts)).max())
139
+ self._nl_cache.save_reference(atoms, max_cell_shift=max_cell_shift)
140
+
141
+ def _update_geometry(self, atoms):
142
+ """Update positions and cell in cached batch without rebuilding
143
+ the neighbor list. The model recomputes R_ij from the updated
144
+ sr.positions and sr.cell, and the Ewald calculator recomputes
145
+ k-vectors from sr.cell (pbc.k_grid stores only integer frequency
146
+ indices). So forces, energy, and stress remain correct."""
147
+ sr = self.batch.sr
148
+ n_atoms = len(atoms)
149
+
150
+ positions = np.zeros(np.array(sr.positions).shape, dtype=np.float32)
151
+ positions[:n_atoms] = atoms.get_positions()
152
+
153
+ cell = np.array(sr.cell)
154
+ new_cell = atoms.get_cell()[:].astype(np.float32)
155
+ if atoms.get_pbc().sum() == 2:
156
+ from jaxpme.batched_mixed.batching import shrink_2d_cell
157
+
158
+ new_cell = shrink_2d_cell(new_cell, atoms.get_pbc(), positions[:n_atoms])
159
+ cell[0] = new_cell
160
+
161
+ new_sr = sr._replace(
162
+ positions=jnp.array(positions),
163
+ cell=jnp.array(cell),
164
+ )
165
+ self.batch = self.batch._replace(sr=new_sr)
166
+
167
+ def calculate(
168
+ self,
169
+ atoms=None,
170
+ properties=None,
171
+ system_changes=None,
172
+ **kwargs,
173
+ ):
174
+ self.update(atoms)
175
+
176
+ results = self.predict_fn(self.params, self.batch)
177
+
178
+ actual_results = {}
179
+ for key in self.implemented_properties:
180
+ if key == "energy":
181
+ actual_results[key] = float(
182
+ results[key][self.batch.sr.structure_mask].squeeze()
183
+ )
184
+ elif key == "forces":
185
+ actual_results[key] = np.array(
186
+ results[key][self.batch.sr.atom_mask].reshape(-1, 3),
187
+ dtype=np.float32,
188
+ )
189
+ elif key == "stress":
190
+ virial = np.array(
191
+ results[key][self.batch.sr.structure_mask].reshape(3, 3),
192
+ dtype=np.float32,
193
+ )
194
+ volume = atoms.get_volume()
195
+ from ase.stress import full_3x3_to_voigt_6_stress
196
+
197
+ actual_results[key] = full_3x3_to_voigt_6_stress(virial / volume)
198
+
199
+ # BEC passthrough: when model outputs "apt" (e.g. LoremBEC), expose as
200
+ # "born_effective_charges" in (natoms, 3, 3) layout for ase compatibility
201
+ if "apt" in results:
202
+ actual_results["born_effective_charges"] = np.array(
203
+ results["apt"][self.batch.sr.atom_mask].reshape(-1, 3, 3),
204
+ dtype=np.float32,
205
+ )
206
+
207
+ if self.add_offset:
208
+ energy_offset = np.sum(
209
+ [self.species_weights[Z] for Z in atoms.get_atomic_numbers()]
210
+ )
211
+ actual_results["energy"] += energy_offset
212
+
213
+ self.results = actual_results
214
+ return actual_results
215
+
216
+ def get_property(self, name, atoms=None, allow_calculation=True):
217
+ if name not in self.implemented_properties:
218
+ raise PropertyNotImplementedError(f"{name} property not implemented")
219
+
220
+ self.update(atoms)
221
+
222
+ if name not in self.results:
223
+ if not allow_calculation:
224
+ return None
225
+ self.calculate(atoms=atoms)
226
+
227
+ if name not in self.results:
228
+ # For some reason the calculator was not able to do what we want,
229
+ # and that is OK.
230
+ raise PropertyNotImplementedError(f"{name} property not present in results!")
231
+
232
+ result = self.results[name]
233
+ if isinstance(result, np.ndarray):
234
+ result = result.copy()
235
+ return result
236
+
237
+ def get_potential_energy(self, atoms=None, force_consistent=True):
238
+ # force_consistent is ignored; we are always consistent
239
+ return self.get_property(name="energy", atoms=atoms)
240
+
241
+
242
+ def _model_predicts_bec(model):
243
+ from lorem.models.bec import LoremBEC
244
+
245
+ return isinstance(model, LoremBEC)
lorem/ipi.py ADDED
@@ -0,0 +1,42 @@
1
+ """LOREM driver for i-PI."""
2
+
3
+ import numpy as np
4
+
5
+ import json
6
+
7
+ from ipi.pes._ase import ASEDriver
8
+
9
+ from lorem.calculator import Calculator
10
+
11
+ __DRIVER_NAME__ = "lorem"
12
+ __DRIVER_CLASS__ = "LOREM_driver"
13
+
14
+
15
+ class LOREM_driver(ASEDriver):
16
+ def __init__(self, template, model_path, *args, skin=0.25, **kwargs):
17
+ self.model_path = model_path
18
+ self.skin = skin
19
+ super().__init__(template, *args, **kwargs)
20
+ # only advertise BEC if the model predicts it
21
+ if "born_effective_charges" in self.ase_calculator.implemented_properties:
22
+ self.capabilities.append("born_effective_charges")
23
+
24
+ def check_parameters(self):
25
+ super().check_parameters()
26
+ has_stress = "stress" in self.capabilities
27
+ self.ase_calculator = Calculator.from_checkpoint(
28
+ self.model_path, stress=has_stress, skin=self.skin
29
+ )
30
+
31
+ def compute_structure(self, cell, pos):
32
+ pot_ipi, force_ipi, vir_ipi, extras = super().compute_structure(cell, pos)
33
+
34
+ # BEC passthrough: when model outputs "born_effective_charges" (e.g. LoremBEC),
35
+ # expose as "BEC" in (3*natoms, 3) layout for i-PI compatibility
36
+ if extras and "born_effective_charges" in extras:
37
+ extras = json.loads(extras)
38
+ BEC = np.reshape(extras.pop("born_effective_charges"), (-1, 3))
39
+ extras["BEC"] = BEC.tolist()
40
+ extras = json.dumps(extras)
41
+
42
+ return pot_ipi, force_ipi, vir_ipi, extras
@@ -0,0 +1,4 @@
1
+ from .bec import LoremBEC
2
+ from .mlip import Lorem
3
+
4
+ __all__ = ["Lorem", "LoremBEC"]