dadadaxi commited on
Commit
fecdc11
·
verified ·
1 Parent(s): 18fca96

Upload folder using huggingface_hub

Browse files
.gitattributes CHANGED
@@ -1,35 +1,47 @@
1
  *.7z filter=lfs diff=lfs merge=lfs -text
2
  *.arrow filter=lfs diff=lfs merge=lfs -text
3
  *.bin filter=lfs diff=lfs merge=lfs -text
 
4
  *.bz2 filter=lfs diff=lfs merge=lfs -text
5
- *.ckpt filter=lfs diff=lfs merge=lfs -text
6
  *.ftz filter=lfs diff=lfs merge=lfs -text
7
  *.gz filter=lfs diff=lfs merge=lfs -text
8
  *.h5 filter=lfs diff=lfs merge=lfs -text
9
  *.joblib filter=lfs diff=lfs merge=lfs -text
10
  *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
- *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
  *.model filter=lfs diff=lfs merge=lfs -text
13
  *.msgpack filter=lfs diff=lfs merge=lfs -text
14
- *.npy filter=lfs diff=lfs merge=lfs -text
15
- *.npz filter=lfs diff=lfs merge=lfs -text
16
  *.onnx filter=lfs diff=lfs merge=lfs -text
17
  *.ot filter=lfs diff=lfs merge=lfs -text
18
  *.parquet filter=lfs diff=lfs merge=lfs -text
19
  *.pb filter=lfs diff=lfs merge=lfs -text
20
- *.pickle filter=lfs diff=lfs merge=lfs -text
21
- *.pkl filter=lfs diff=lfs merge=lfs -text
22
  *.pt filter=lfs diff=lfs merge=lfs -text
23
  *.pth filter=lfs diff=lfs merge=lfs -text
24
  *.rar filter=lfs diff=lfs merge=lfs -text
25
- *.safetensors filter=lfs diff=lfs merge=lfs -text
26
  saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
  *.tar.* filter=lfs diff=lfs merge=lfs -text
28
- *.tar filter=lfs diff=lfs merge=lfs -text
29
  *.tflite filter=lfs diff=lfs merge=lfs -text
30
  *.tgz filter=lfs diff=lfs merge=lfs -text
31
- *.wasm filter=lfs diff=lfs merge=lfs -text
32
  *.xz filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
- *tfevents* filter=lfs diff=lfs merge=lfs -text
 
1
  *.7z filter=lfs diff=lfs merge=lfs -text
2
  *.arrow filter=lfs diff=lfs merge=lfs -text
3
  *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bin.* filter=lfs diff=lfs merge=lfs -text
5
  *.bz2 filter=lfs diff=lfs merge=lfs -text
 
6
  *.ftz filter=lfs diff=lfs merge=lfs -text
7
  *.gz filter=lfs diff=lfs merge=lfs -text
8
  *.h5 filter=lfs diff=lfs merge=lfs -text
9
  *.joblib filter=lfs diff=lfs merge=lfs -text
10
  *.lfs.* filter=lfs diff=lfs merge=lfs -text
 
11
  *.model filter=lfs diff=lfs merge=lfs -text
12
  *.msgpack filter=lfs diff=lfs merge=lfs -text
 
 
13
  *.onnx filter=lfs diff=lfs merge=lfs -text
14
  *.ot filter=lfs diff=lfs merge=lfs -text
15
  *.parquet filter=lfs diff=lfs merge=lfs -text
16
  *.pb filter=lfs diff=lfs merge=lfs -text
 
 
17
  *.pt filter=lfs diff=lfs merge=lfs -text
18
  *.pth filter=lfs diff=lfs merge=lfs -text
19
  *.rar filter=lfs diff=lfs merge=lfs -text
 
20
  saved_model/**/* filter=lfs diff=lfs merge=lfs -text
21
  *.tar.* filter=lfs diff=lfs merge=lfs -text
 
22
  *.tflite filter=lfs diff=lfs merge=lfs -text
23
  *.tgz filter=lfs diff=lfs merge=lfs -text
 
24
  *.xz filter=lfs diff=lfs merge=lfs -text
25
  *.zip filter=lfs diff=lfs merge=lfs -text
26
+ *.zstandard filter=lfs diff=lfs merge=lfs -text
27
+ *.tfevents* filter=lfs diff=lfs merge=lfs -text
28
+ *.db* filter=lfs diff=lfs merge=lfs -text
29
+ *.ark* filter=lfs diff=lfs merge=lfs -text
30
+ **/*ckpt*data* filter=lfs diff=lfs merge=lfs -text
31
+ **/*ckpt*.meta filter=lfs diff=lfs merge=lfs -text
32
+ **/*ckpt*.index filter=lfs diff=lfs merge=lfs -text
33
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
34
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
35
+ *.gguf* filter=lfs diff=lfs merge=lfs -text
36
+ *.ggml filter=lfs diff=lfs merge=lfs -text
37
+ *.llamafile* filter=lfs diff=lfs merge=lfs -text
38
+ *.pt2 filter=lfs diff=lfs merge=lfs -text
39
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
40
+ *.npy filter=lfs diff=lfs merge=lfs -text
41
+ *.npz filter=lfs diff=lfs merge=lfs -text
42
+ *.pickle filter=lfs diff=lfs merge=lfs -text
43
+ *.pkl filter=lfs diff=lfs merge=lfs -text
44
+ *.tar filter=lfs diff=lfs merge=lfs -text
45
+ *.wasm filter=lfs diff=lfs merge=lfs -text
46
  *.zst filter=lfs diff=lfs merge=lfs -text
47
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ outputs/
2
+ checkpoints/
3
+ datasets/
4
+ weight/esen_*.pt
5
+ weight/esen_*.pth
6
+ __pycache__/
7
+ demo/__pycache__/
8
+ *.py[cod]
9
+ *.log
10
+ *.traj
11
+ relaxed.cif
README.md ADDED
@@ -0,0 +1,234 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ datasets:
3
+ - OneScience-Group/oxides
4
+ frameworks:
5
+ - ""
6
+ language:
7
+ - en
8
+ license: mit
9
+ tags:
10
+ - OneScience
11
+ - eSEN
12
+ - materials-science
13
+ - machine-learning-potential
14
+ - equivariant-graph-neural-network
15
+ - structure-relaxation
16
+ - molecular-dynamics
17
+ - fine-tuning
18
+ tasks: []
19
+ ---
20
+
21
+ <p align="center">
22
+ <strong>
23
+ <span style="font-size: 30px;">eSEN</span>
24
+ </strong>
25
+ </p>
26
+
27
+ # Model Introduction
28
+
29
+ eSEN (equivariant Smooth Energy Network) is an equivariant graph neural network interatomic potential proposed by FAIR Chemistry. It predicts the energy, atomic forces, and stress of material structures. OneScience provides an ASE calculator and fine-tuning entry points for single-point calculations, structure relaxation, molecular dynamics, and fine-tuning on custom materials data.
30
+
31
+ Upstream implementation: [FAIR-Chem/fairchem](https://github.com/FAIR-Chem/fairchem)
32
+
33
+ # Model Description
34
+
35
+ eSEN takes periodic atomic structure graphs as input, learns a smooth potential energy surface through rotationally equivariant representations, and obtains conservative forces from energy gradients. Different pretrained checkpoints correspond to different materials domains. Select weights whose target elemental systems and DFT labeling settings are close to your use case.
36
+
37
+ This repository contains the eSEN model code, inference scripts, an oxide PBE fine-tuning example, and multi-device launch configurations. Restricted pretrained eSEN checkpoints are not distributed with this repository.
38
+
39
+ # Use Cases
40
+
41
+ | Use case | Description |
42
+ | :---: | :--- |
43
+ | Single-point calculation | Predict the total energy, atomic forces, and stress of a periodic structure |
44
+ | Structure relaxation | Optimize atomic positions and, optionally, the unit cell with ASE BFGS |
45
+ | Molecular dynamics | Run NVT trajectories with ASE Langevin dynamics |
46
+ | Oxide PBE fine-tuning | Fine-tune an MPTrj checkpoint using energy and force labels |
47
+ | Distributed fine-tuning | Support single-node multi-device and multi-node Slurm DDP |
48
+
49
+ This repository does not provide a complete pretraining workflow from random initialization. Its training entry point is intended for checkpoint fine-tuning.
50
+
51
+ # Usage
52
+
53
+ ## 1. Using OneCode
54
+
55
+ Try intelligent, one-click AI4S programming in the OneCode online environment:
56
+
57
+ [Try intelligent, one-click AI4S programming](https://web-2069360198568017922-iaaj.ksai.scnet.cn:58043/home)
58
+
59
+ ## 2. Manual Installation and Usage
60
+
61
+ **Hardware requirements**
62
+
63
+ - A GPU or DCU is recommended for inference and fine-tuning.
64
+ - A CPU can be used for import and configuration checks but is not recommended for production workloads.
65
+ - A DCU requires a DTK runtime matching the PyTorch build.
66
+
67
+ ### Download the Model Package
68
+
69
+ ```bash
70
+ hf download --model OneScience-Group/eSEN --local-dir ./eSEN
71
+ cd eSEN
72
+ ```
73
+
74
+ ### Install the Runtime Environment
75
+
76
+ **DCU environment**
77
+
78
+ ```bash
79
+ conda create -n onescience311 python=3.11 -y
80
+ conda activate onescience311
81
+ pip install onescience[matchem-dcu] \
82
+ -i http://mirrors.onescience.ai:3141/pypi/simple/ \
83
+ --trusted-host mirrors.onescience.ai
84
+ ```
85
+
86
+ **GPU environment**
87
+
88
+ ```bash
89
+ conda create -n onescience311 python=3.11 -y \
90
+ libstdcxx-ng=12 libgcc-ng=12 gcc_linux-64=12 gxx_linux-64=12
91
+ conda activate onescience311
92
+ pip install onescience[matchem-gpu] \
93
+ -i http://mirrors.onescience.ai:3141/pypi/simple/ \
94
+ --trusted-host mirrors.onescience.ai
95
+ ```
96
+
97
+ ### Pretrained Weights
98
+
99
+ Access to pretrained eSEN checkpoints must be requested from the official FAIR Chemistry model release page. This repository neither provides nor redistributes those weights. After obtaining access, place the required checkpoint at:
100
+
101
+ ```text
102
+ weight/
103
+ ├── Jd.pt # Rotation-basis file included in this repository
104
+ ├── esen_30m_mptrj.pt # Add after obtaining access
105
+ ├── esen_30m_omat.pt # Add after obtaining access
106
+ └── esen_30m_oam.pt # Add after obtaining access
107
+ ```
108
+
109
+ Checkpoint details:
110
+
111
+ | Checkpoint | Training domain | Recommended use |
112
+ | --- | --- | --- |
113
+ | `esen_30m_mptrj.pt` | MPTrj | PBE/PBE+U inference for inorganic crystals and fine-tuning in a similar labeling domain |
114
+ | `esen_30m_omat.pt` | OMat24 | A broader range of non-equilibrium inorganic structures |
115
+ | `esen_30m_oam.pt` | OAM | General-purpose materials pretraining starting point |
116
+
117
+ Access and download resources:
118
+
119
+ - FAIR Chemistry model page: https://huggingface.co/fairchem
120
+ - Official FAIR Chemistry repository: https://github.com/FAIR-Chem/fairchem
121
+
122
+ `Jd.pt` is included in the repository under `weight/`. The inference and fine-tuning scripts use it automatically, with no additional download or UMA dependency required.
123
+
124
+ ### Fine-Tuning Dataset
125
+
126
+ This repository does not include the training data. The oxide PBE example dataset is published separately on Hugging Face:
127
+
128
+ ```bash
129
+ hf download --dataset OneScience-Group/oxides \
130
+ --local-dir ./datasets/oxides
131
+ ```
132
+
133
+ The downloaded data has the following layout:
134
+
135
+ ```text
136
+ datasets/oxides/data/OXIDES/prepared/
137
+ ├── train.db
138
+ ├── val.db
139
+ └── test.db
140
+ ```
141
+
142
+ The data has already been converted to ASE DB format. The default configuration uses energy and force supervision and does not train on stress. Use `prepare_oxide_dataset.py` to regenerate the data from the official oxide JSON files.
143
+
144
+ ### Inference
145
+
146
+ Predict single-point energy, forces, and stress:
147
+
148
+ ```bash
149
+ python single_point.py --checkpoint weight/esen_30m_mptrj.pt
150
+ ```
151
+
152
+ Relax a structure:
153
+
154
+ ```bash
155
+ python relax.py \
156
+ --checkpoint weight/esen_30m_mptrj.pt \
157
+ --fmax 0.05 --steps 100 --output relaxed.cif
158
+ ```
159
+
160
+ Run NVT molecular dynamics:
161
+
162
+ ```bash
163
+ python md.py \
164
+ --checkpoint weight/esen_30m_mptrj.pt \
165
+ --steps 100 --temperature 300 --timestep 1.0 --output md.traj
166
+ ```
167
+
168
+ Use `--input` to read CIF, POSCAR, XYZ, or any other structure format supported by ASE.
169
+
170
+ ### Fine-Tuning
171
+
172
+ Fine-tune on oxide PBE data with one device:
173
+
174
+ ```bash
175
+ bash demo/run.sh --config configs/finetune_1dcu.yaml
176
+ ```
177
+
178
+ Main configuration fields:
179
+
180
+ | YAML field | Purpose |
181
+ | --- | --- |
182
+ | `checkpoint` | Path to the initialization checkpoint |
183
+ | `train`, `val` | Paths to ASE DB or ASE-LMDB data |
184
+ | `epochs`, `batch_size`, `workers` | Number of training epochs, batch size, and data-loading workers |
185
+ | `lr` | AdamW learning rate |
186
+ | `energy_weight`, `force_weight`, `stress_weight` | Loss weight for each supervised target |
187
+ | `fit_element_references` | Whether to refit elemental energy references on the training set |
188
+ | `launch.num_nodes`, `launch.num_gpus` | Number of nodes and devices per node |
189
+ | `launch.mode` | Run directly with `local` or submit to Slurm with `submit` |
190
+ | `slurm.*` | Slurm partition, time limit, and CPU resources |
191
+
192
+ Use the same entry point for multi-device and multi-node jobs by selecting another YAML file:
193
+
194
+ ```bash
195
+ bash demo/run.sh --config configs/finetune_2dcu.yaml
196
+ bash demo/run.sh --config configs/finetune_16dcu.yaml
197
+ ```
198
+
199
+ When the current node has fewer visible devices than requested, or when the configuration requests multiple nodes, `demo/run.sh` automatically submits the job through `sbatch`.
200
+
201
+ Evaluate a fine-tuned checkpoint:
202
+
203
+ ```bash
204
+ python evaluate.py \
205
+ --checkpoint outputs/<run>/checkpoints/esen_oxides_1dcu_finetuned.pt \
206
+ --data datasets/oxides/data/OXIDES/prepared/test.db
207
+ ```
208
+
209
+ # File Reference
210
+
211
+ | Path | Purpose |
212
+ | --- | --- |
213
+ | `model/` | Source code for the eSEN backbone, prediction heads, and graph interface |
214
+ | `single_point.py` | Single-point energy, force, and stress inference |
215
+ | `relax.py` | Periodic structure relaxation |
216
+ | `md.py` | NVT molecular dynamics |
217
+ | `finetune.py` | Low-level checkpoint fine-tuning entry point |
218
+ | `evaluate.py` | Error evaluation on an independent dataset |
219
+ | `prepare_oxide_dataset.py` | Convert oxide JSON data to ASE DB format |
220
+ | `demo/run.sh` | YAML-driven local and Slurm fine-tuning entry point |
221
+ | `demo/configs/` | Single-device, multi-device, and multi-node fine-tuning configurations |
222
+
223
+ # Official OneScience Resources
224
+
225
+ | Platform | OneScience Main Repository | Skills Repository |
226
+ | --- | --- | --- |
227
+ | Gitee | https://gitee.com/onescience-ai/onescience | https://gitee.com/onescience-ai/oneskills |
228
+ | GitHub | https://github.com/onescience-ai/OneScience | https://github.com/onescience-ai/oneskills |
229
+
230
+ # Citation and License
231
+
232
+ - The eSEN model code is adapted from FairChem Core and follows the upstream FairChem MIT License. The model code and the included `Jd.pt` file are used under that model license.
233
+ - eSEN checkpoints are not distributed with this repository. Follow the access conditions and licenses specified on the FAIR Chemistry model pages.
234
+ - When using OMat24, MPTrj, OAM, or oxide data, cite the datasets and corresponding model work actually used.
configuration.json ADDED
@@ -0,0 +1 @@
 
 
1
+ {"framework":"Pytorch","task":"other"}
demo/_parse_config.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Extract eSEN demo metadata and the native finetune configuration from YAML."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import sys
7
+
8
+ import yaml
9
+
10
+
11
+ META_KEYS = {"name", "description", "launch", "slurm", "nccl"}
12
+
13
+
14
+ def _config(path: str) -> dict:
15
+ with open(path, encoding="utf-8") as handle:
16
+ return yaml.safe_load(handle) or {}
17
+
18
+
19
+ def _launch(cfg: dict) -> dict:
20
+ return cfg.get("launch", {}) or {}
21
+
22
+
23
+ def _slurm(cfg: dict) -> dict:
24
+ return cfg.get("slurm", {}) or {}
25
+
26
+
27
+ def _print_launch(cfg: dict) -> None:
28
+ launch = _launch(cfg)
29
+ mode = launch.get("mode", "local")
30
+ if mode not in {"local", "submit"}:
31
+ raise ValueError("launch.mode must be 'local' or 'submit'")
32
+ print(f"RUN_MODE={mode}")
33
+ print(f"NODES={launch.get('num_nodes', 1)}")
34
+ print(f"GPUS_PER_NODE={launch.get('num_gpus', 1)}")
35
+ print(f"OMP_NUM_THREADS={launch.get('omp_num_threads', 1)}")
36
+
37
+
38
+ def _print_slurm(cfg: dict) -> None:
39
+ slurm = _slurm(cfg)
40
+ print(f"PARTITION={slurm.get('partition', 'hx1hdexclu12')}")
41
+ print(f"TIME={slurm.get('time', '24:00:00')}")
42
+ print(f"CPUS_PER_TASK={slurm.get('cpus_per_task', 16)}")
43
+
44
+
45
+ def _print_env(cfg: dict) -> None:
46
+ nccl = cfg.get("nccl", {}) or {}
47
+ if nccl.get("socket_ifname"):
48
+ print(f"export NCCL_SOCKET_IFNAME={nccl['socket_ifname']}")
49
+ if nccl.get("ib_hca"):
50
+ print(f"export NCCL_IB_HCA={nccl['ib_hca']}")
51
+ if nccl.get("proto"):
52
+ print(f"export NCCL_PROTO={nccl['proto']}")
53
+ print("export HSA_FORCE_FINE_GRAIN_PCIE=1")
54
+
55
+
56
+ def _print_finetune_config(cfg: dict) -> None:
57
+ finetune = {key: value for key, value in cfg.items() if key not in META_KEYS}
58
+ yaml.safe_dump(
59
+ finetune,
60
+ sys.stdout,
61
+ sort_keys=False,
62
+ default_flow_style=False,
63
+ allow_unicode=True,
64
+ )
65
+
66
+
67
+ def main() -> None:
68
+ if len(sys.argv) != 3:
69
+ raise SystemExit(
70
+ "usage: _parse_config.py <config.yaml> <name|launch|slurm|env|finetune-config>"
71
+ )
72
+ cfg = _config(sys.argv[1])
73
+ action = sys.argv[2]
74
+ actions = {
75
+ "name": lambda: print(cfg.get("name", "esen_finetune")),
76
+ "launch": lambda: _print_launch(cfg),
77
+ "slurm": lambda: _print_slurm(cfg),
78
+ "env": lambda: _print_env(cfg),
79
+ "finetune-config": lambda: _print_finetune_config(cfg),
80
+ }
81
+ try:
82
+ actions[action]()
83
+ except KeyError as error:
84
+ raise SystemExit(f"unknown action: {action}") from error
85
+
86
+
87
+ if __name__ == "__main__":
88
+ main()
demo/configs/finetune_16dcu.yaml ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # eSEN oxide PBE fine-tuning: 2 nodes x 8 DCUs.
2
+ # Submit from examples/matchem/esen:
3
+ # bash demo/run.sh --config configs/finetune_16dcu.yaml
4
+ name: esen_oxides_16dcu
5
+ description: "eSEN oxide PBE fine-tuning on 2 nodes x 8 DCUs"
6
+
7
+ launch:
8
+ mode: submit
9
+ num_nodes: 2
10
+ num_gpus: 8
11
+ omp_num_threads: 1
12
+
13
+ slurm:
14
+ partition: hx1hdexclu12
15
+ time: "24:00:00"
16
+ cpus_per_task: 128
17
+
18
+ # Set fabric-specific NCCL values here when required by the cluster.
19
+ nccl: {}
20
+
21
+ checkpoint: "${ESEN_REPO_DIR}/weight/esen_30m_mptrj.pt"
22
+ train: "${ESEN_DATASET_DIR}/data/OXIDES/prepared/train.db"
23
+ val: "${ESEN_DATASET_DIR}/data/OXIDES/prepared/val.db"
24
+ output: "checkpoints/esen_oxides_16dcu_finetuned.pt"
25
+
26
+ device: cuda
27
+ backend: nccl
28
+ seed: 0
29
+ fit_element_references: true
30
+ epochs: 10
31
+ batch_size: 2
32
+ workers: 0
33
+ lr: 1.0e-6
34
+ energy_weight: 1.0
35
+ force_weight: 10.0
36
+ stress_weight: 0.0
demo/configs/finetune_16dcu_smoke.yaml ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # End-to-end validation only: 2 nodes x 8 DCUs, one batch per rank.
2
+ # This is not a scientific fine-tuning recipe.
3
+ name: esen_oxides_16dcu_smoke
4
+ description: "eSEN two-node DDP smoke test"
5
+
6
+ launch:
7
+ mode: submit
8
+ num_nodes: 2
9
+ num_gpus: 8
10
+ omp_num_threads: 1
11
+
12
+ slurm:
13
+ partition: hx1hdexclu12
14
+ time: "00:20:00"
15
+ cpus_per_task: 128
16
+
17
+ nccl: {}
18
+
19
+ checkpoint: "${ESEN_REPO_DIR}/weight/esen_30m_mptrj.pt"
20
+ train: "${ESEN_DATASET_DIR}/data/OXIDES/prepared/train.db"
21
+ val: "${ESEN_DATASET_DIR}/data/OXIDES/prepared/val.db"
22
+ output: "checkpoints/esen_oxides_16dcu_smoke.pt"
23
+
24
+ device: cuda
25
+ backend: nccl
26
+ seed: 0
27
+ fit_element_references: true
28
+ epochs: 1
29
+ batch_size: 2
30
+ workers: 0
31
+ max_train_samples: 16
32
+ max_val_samples: 16
33
+ lr: 1.0e-6
34
+ energy_weight: 1.0
35
+ force_weight: 10.0
36
+ stress_weight: 0.0
demo/configs/finetune_1dcu.yaml ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # eSEN oxide PBE fine-tuning: 1 node x 1 DCU.
2
+ # Run from examples/matchem/esen:
3
+ # bash demo/run.sh --config configs/finetune_1dcu.yaml
4
+ name: esen_oxides_1dcu
5
+ description: "eSEN oxide PBE fine-tuning on one DCU"
6
+
7
+ launch:
8
+ mode: local
9
+ num_nodes: 1
10
+ num_gpus: 1
11
+ omp_num_threads: 1
12
+
13
+ slurm:
14
+ partition: hx1hdexclu12
15
+ time: "24:00:00"
16
+ cpus_per_task: 16
17
+
18
+ nccl: {}
19
+
20
+ checkpoint: "${ESEN_REPO_DIR}/weight/esen_30m_mptrj.pt"
21
+ train: "${ESEN_DATASET_DIR}/data/OXIDES/prepared/train.db"
22
+ val: "${ESEN_DATASET_DIR}/data/OXIDES/prepared/val.db"
23
+ output: "checkpoints/esen_oxides_1dcu_finetuned.pt"
24
+
25
+ device: cuda
26
+ backend: nccl
27
+ seed: 0
28
+ fit_element_references: true
29
+ epochs: 10
30
+ batch_size: 2
31
+ workers: 0
32
+ lr: 1.0e-6
33
+ energy_weight: 1.0
34
+ force_weight: 10.0
35
+ stress_weight: 0.0
demo/configs/finetune_2dcu.yaml ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # eSEN oxide PBE fine-tuning: 1 node x 2 DCUs.
2
+ # Run from examples/matchem/esen:
3
+ # bash demo/run.sh --config configs/finetune_2dcu.yaml
4
+ name: esen_oxides_2dcu
5
+ description: "eSEN oxide PBE fine-tuning on 1 node x 2 DCUs"
6
+
7
+ launch:
8
+ mode: local
9
+ num_nodes: 1
10
+ num_gpus: 2
11
+ omp_num_threads: 1
12
+
13
+ slurm:
14
+ partition: hx1hdexclu12
15
+ time: "24:00:00"
16
+ cpus_per_task: 32
17
+
18
+ nccl: {}
19
+
20
+ checkpoint: "${ESEN_REPO_DIR}/weight/esen_30m_mptrj.pt"
21
+ train: "${ESEN_DATASET_DIR}/data/OXIDES/prepared/train.db"
22
+ val: "${ESEN_DATASET_DIR}/data/OXIDES/prepared/val.db"
23
+ output: "checkpoints/esen_oxides_2dcu_finetuned.pt"
24
+
25
+ device: cuda
26
+ backend: nccl
27
+ seed: 0
28
+ fit_element_references: true
29
+ epochs: 10
30
+ batch_size: 2
31
+ workers: 0
32
+ lr: 1.0e-6
33
+ energy_weight: 1.0
34
+ force_weight: 10.0
35
+ stress_weight: 0.0
demo/run.sh ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ # Run native eSEN fine-tuning directly or submit it to Slurm.
3
+ set -euo pipefail
4
+
5
+ DEMO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6
+ ESEN_DIR="$(cd "$DEMO_DIR/.." && pwd)"
7
+ PARSER="$DEMO_DIR/_parse_config.py"
8
+ CONFIG=""
9
+ SUBMIT=false
10
+
11
+ while [[ $# -gt 0 ]]; do
12
+ case "$1" in
13
+ --config) CONFIG="$2"; shift 2 ;;
14
+ --config=*) CONFIG="${1#*=}"; shift ;;
15
+ --submit) SUBMIT=true; shift ;;
16
+ -h|--help)
17
+ echo "Usage: bash demo/run.sh --config configs/<name>.yaml"
18
+ echo "Set launch.mode: submit in YAML to submit a Slurm job."
19
+ exit 0
20
+ ;;
21
+ *) echo "Unknown argument: $1" >&2; exit 2 ;;
22
+ esac
23
+ done
24
+
25
+ [[ -n "$CONFIG" ]] || { echo "Please specify --config configs/<name>.yaml" >&2; exit 2; }
26
+ [[ "$CONFIG" = /* ]] || CONFIG="$DEMO_DIR/$CONFIG"
27
+ [[ -f "$CONFIG" ]] || { echo "Config not found: $CONFIG" >&2; exit 2; }
28
+
29
+ if [[ -z "${CONDA_PREFIX:-}" ]]; then
30
+ echo "Activate a OneScience MatChem conda environment before running this script." >&2
31
+ exit 2
32
+ fi
33
+
34
+ export ESEN_REPO_DIR="$ESEN_DIR"
35
+ export ESEN_DATASET_DIR="${ESEN_DATASET_DIR:-$ESEN_DIR/datasets/oxides}"
36
+ export ONESCIENCE_ESEN_JD_PATH="${ONESCIENCE_ESEN_JD_PATH:-$ESEN_DIR/weight/Jd.pt}"
37
+
38
+ NAME="$(python3 "$PARSER" "$CONFIG" name)"
39
+ eval "$(python3 "$PARSER" "$CONFIG" launch)"
40
+ eval "$(python3 "$PARSER" "$CONFIG" slurm)"
41
+ ENV_EXPORTS="$(python3 "$PARSER" "$CONFIG" env)"
42
+
43
+ if [[ "$RUN_MODE" == "submit" ]]; then
44
+ SUBMIT=true
45
+ fi
46
+
47
+ if ! $SUBMIT; then
48
+ AUTO_SUBMIT_REASON=""
49
+ if (( NODES > 1 )); then
50
+ AUTO_SUBMIT_REASON="the config requests $NODES nodes"
51
+ else
52
+ AVAILABLE_GPUS="$(python3 -c 'import torch; print(torch.cuda.device_count() if torch.cuda.is_available() else 0)' 2>/dev/null || echo 0)"
53
+ if (( AVAILABLE_GPUS < GPUS_PER_NODE )); then
54
+ AUTO_SUBMIT_REASON="the config requests $GPUS_PER_NODE DCUs but only $AVAILABLE_GPUS are visible"
55
+ fi
56
+ fi
57
+ if [[ -n "$AUTO_SUBMIT_REASON" ]]; then
58
+ if ! command -v sbatch >/dev/null 2>&1; then
59
+ echo "Cannot run locally: $AUTO_SUBMIT_REASON, and sbatch is unavailable." >&2
60
+ exit 2
61
+ fi
62
+ echo "Local resources are insufficient: $AUTO_SUBMIT_REASON. Submitting to Slurm."
63
+ SUBMIT=true
64
+ fi
65
+ fi
66
+
67
+ TIMESTAMP="$(date +%Y%m%d_%H%M%S)"
68
+ OUTPUT_ROOT="${ESEN_OUTPUT_ROOT:-$ESEN_DIR/outputs}"
69
+ OUTPUT_DIR="$OUTPUT_ROOT/${NAME}_${TIMESTAMP}"
70
+ mkdir -p "$OUTPUT_DIR/checkpoints"
71
+ cp "$CONFIG" "$OUTPUT_DIR/config.yaml"
72
+ FINETUNE_CONFIG="$OUTPUT_DIR/finetune.yaml"
73
+ python3 "$PARSER" "$CONFIG" finetune-config > "$FINETUNE_CONFIG"
74
+
75
+ if $SUBMIT; then
76
+ SLURM_SCRIPT="$OUTPUT_DIR/submit.sh"
77
+ cat > "$SLURM_SCRIPT" <<EOF
78
+ #!/bin/bash
79
+ #SBATCH --job-name=$NAME
80
+ #SBATCH --partition=$PARTITION
81
+ #SBATCH --nodes=$NODES
82
+ #SBATCH --ntasks-per-node=1
83
+ #SBATCH --cpus-per-task=$CPUS_PER_TASK
84
+ #SBATCH --gres=dcu:$GPUS_PER_NODE
85
+ #SBATCH --time=$TIME
86
+ #SBATCH --output=$OUTPUT_DIR/slurm_%j.out
87
+ #SBATCH --error=$OUTPUT_DIR/slurm_%j.err
88
+ EOF
89
+
90
+ cat >> "$SLURM_SCRIPT" <<EOF
91
+
92
+ set -euo pipefail
93
+ export ESEN_REPO_DIR="$ESEN_DIR"
94
+ export ESEN_DATASET_DIR="$ESEN_DATASET_DIR"
95
+ export ONESCIENCE_ESEN_JD_PATH="$ONESCIENCE_ESEN_JD_PATH"
96
+ export OMP_NUM_THREADS="$OMP_NUM_THREADS"
97
+ # A login/compute shell may carry a single-device selection into sbatch.
98
+ # Let Slurm expose all devices requested by this YAML.
99
+ if (( $GPUS_PER_NODE > 1 )); then
100
+ unset HIP_VISIBLE_DEVICES
101
+ fi
102
+ $ENV_EXPORTS
103
+ cd "$OUTPUT_DIR"
104
+ EOF
105
+
106
+ if (( NODES > 1 )); then
107
+ cat >> "$SLURM_SCRIPT" <<EOF
108
+
109
+ nodes=(\$(scontrol show hostnames "\$SLURM_JOB_NODELIST"))
110
+ export MASTER_ADDR="\${nodes[0]}"
111
+ export MASTER_PORT=\$((20000 + SLURM_JOB_ID % 20000))
112
+ echo "eSEN multi-node DDP: nodes=\$SLURM_NNODES, devices/node=$GPUS_PER_NODE"
113
+ echo "MASTER_ADDR=\$MASTER_ADDR MASTER_PORT=\$MASTER_PORT"
114
+
115
+ srun --nodes="\$SLURM_NNODES" --ntasks="\$SLURM_NNODES" --ntasks-per-node=1 \\
116
+ bash -c 'exec torchrun \\
117
+ --nnodes=$NODES \\
118
+ --node_rank="\${SLURM_NODEID}" \\
119
+ --nproc_per_node=$GPUS_PER_NODE \\
120
+ --rdzv_id="\${SLURM_JOB_ID}" \\
121
+ --rdzv_backend=c10d \\
122
+ --rdzv_endpoint="\${MASTER_ADDR}:\${MASTER_PORT}" \\
123
+ "$ESEN_DIR/finetune.py" --config "$FINETUNE_CONFIG"'
124
+ EOF
125
+ elif (( GPUS_PER_NODE > 1 )); then
126
+ cat >> "$SLURM_SCRIPT" <<EOF
127
+
128
+ torchrun --standalone --nproc_per_node=$GPUS_PER_NODE \\
129
+ "$ESEN_DIR/finetune.py" --config "$FINETUNE_CONFIG"
130
+ EOF
131
+ else
132
+ echo "python \"$ESEN_DIR/finetune.py\" --config \"$FINETUNE_CONFIG\"" >> "$SLURM_SCRIPT"
133
+ fi
134
+
135
+ chmod u+x "$SLURM_SCRIPT"
136
+ echo "Submitting eSEN job: $SLURM_SCRIPT"
137
+ sbatch "$SLURM_SCRIPT"
138
+ exit 0
139
+ fi
140
+
141
+ eval "$ENV_EXPORTS"
142
+ cd "$OUTPUT_DIR"
143
+ if (( GPUS_PER_NODE > 1 )); then
144
+ exec torchrun --standalone --nproc_per_node="$GPUS_PER_NODE" \
145
+ "$ESEN_DIR/finetune.py" --config "$FINETUNE_CONFIG"
146
+ fi
147
+ exec python "$ESEN_DIR/finetune.py" --config "$FINETUNE_CONFIG"
evaluate.py ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Evaluate an eSEN checkpoint in physical units on an independent ASE DB."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ from pathlib import Path
9
+
10
+ os.environ.setdefault(
11
+ "ONESCIENCE_ESEN_JD_PATH",
12
+ os.path.join(os.path.dirname(__file__), "weight", "Jd.pt"),
13
+ )
14
+
15
+ import torch
16
+
17
+ from onescience.utils.esen.checkpoint import ESENCheckpointTransforms
18
+ from onescience.utils.uma.common.utils import load_model_and_weights_from_checkpoint
19
+
20
+ from finetune import _loader
21
+
22
+
23
+ def _stats(error: torch.Tensor) -> dict[str, float]:
24
+ error = error.detach().reshape(-1).double()
25
+ return {
26
+ "mae": float(error.abs().mean()),
27
+ "rmse": float(error.square().mean().sqrt()),
28
+ }
29
+
30
+
31
+ def main() -> None:
32
+ parser = argparse.ArgumentParser(description=__doc__)
33
+ parser.add_argument("--checkpoint", required=True)
34
+ parser.add_argument("--data", required=True, help="Independent ASE DB/ASE-LMDB")
35
+ parser.add_argument("--device", default="cuda")
36
+ parser.add_argument("--batch-size", type=int, default=2)
37
+ parser.add_argument("--workers", type=int, default=0)
38
+ parser.add_argument("--max-samples", type=int)
39
+ parser.add_argument("--seed", type=int, default=0)
40
+ parser.add_argument("--output")
41
+ args = parser.parse_args()
42
+
43
+ device = torch.device(args.device)
44
+ if device.type == "cuda" and not torch.cuda.is_available():
45
+ raise RuntimeError("CUDA/DCU was requested but is unavailable")
46
+ import onescience.models.esen # noqa: F401
47
+
48
+ model = load_model_and_weights_from_checkpoint(args.checkpoint).to(device)
49
+ model.eval()
50
+ transforms = ESENCheckpointTransforms.from_checkpoint(args.checkpoint).to(device)
51
+ loader = _loader(
52
+ args.data,
53
+ args.batch_size,
54
+ args.workers,
55
+ max_samples=args.max_samples,
56
+ train=False,
57
+ seed=args.seed,
58
+ )
59
+
60
+ energy_errors = []
61
+ energy_per_atom_errors = []
62
+ force_errors = []
63
+ stress_errors = []
64
+ with torch.enable_grad():
65
+ for batch in loader:
66
+ batch = batch.to(device)
67
+ prediction = model(batch)
68
+ pred_energy = transforms.denormalize_prediction("energy", prediction["energy"], batch)
69
+ target_energy = batch.energy.reshape_as(pred_energy)
70
+ energy_errors.append((pred_energy - target_energy).detach().cpu())
71
+ natoms = batch.natoms.to(pred_energy).reshape((-1,) + (1,) * (pred_energy.ndim - 1))
72
+ energy_per_atom_errors.append(((pred_energy - target_energy) / natoms).detach().cpu())
73
+
74
+ pred_forces = transforms.denormalize_prediction("forces", prediction["forces"], batch)
75
+ target_forces = batch.forces.reshape_as(pred_forces)
76
+ force_errors.append((pred_forces - target_forces).detach().cpu())
77
+ if "stress" in prediction and hasattr(batch, "stress"):
78
+ pred_stress = transforms.denormalize_prediction("stress", prediction["stress"], batch)
79
+ target_stress = batch.stress.reshape_as(pred_stress)
80
+ stress_errors.append((pred_stress - target_stress).detach().cpu())
81
+
82
+ result = {
83
+ "checkpoint": str(Path(args.checkpoint).expanduser()),
84
+ "data": str(Path(args.data).expanduser()),
85
+ "samples": len(loader.dataset),
86
+ "energy_total_eV": _stats(torch.cat(energy_errors)),
87
+ "energy_per_atom_eV": _stats(torch.cat(energy_per_atom_errors)),
88
+ "forces_eV_per_A": _stats(torch.cat(force_errors)),
89
+ }
90
+ if stress_errors:
91
+ result["stress_eV_per_A3"] = _stats(torch.cat(stress_errors))
92
+ print(json.dumps(result, indent=2, sort_keys=True))
93
+ if args.output:
94
+ output = Path(args.output).expanduser()
95
+ output.parent.mkdir(parents=True, exist_ok=True)
96
+ output.write_text(json.dumps(result, indent=2, sort_keys=True) + "\n")
97
+
98
+
99
+ if __name__ == "__main__":
100
+ main()
finetune.py ADDED
@@ -0,0 +1,372 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Fine-tune an eSEN checkpoint on an ASE database.
2
+
3
+ The input database must contain ASE calculator results (energy, forces, and
4
+ optionally stress). The output checkpoint keeps the native OneScience model
5
+ configuration and can be loaded by ``eSENCalculator.from_checkpoint``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import copy
12
+ import json
13
+ import os
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+
17
+ os.environ.setdefault(
18
+ "ONESCIENCE_ESEN_JD_PATH",
19
+ os.path.join(os.path.dirname(__file__), "weight", "Jd.pt"),
20
+ )
21
+
22
+ import torch
23
+ import yaml
24
+ from torch.nn.parallel import DistributedDataParallel
25
+ from torch.utils.data import DataLoader, Subset
26
+ from torch.utils.data.distributed import DistributedSampler
27
+
28
+ from onescience.datapipes.materials.custom_stack import data_list_collater
29
+ from onescience.datapipes.materials.custom_stack.storage.ase_datasets import AseDBDataset
30
+ from onescience.utils.esen.checkpoint import ESENCheckpointTransforms
31
+ from onescience.utils.uma.normalization.element_references import (
32
+ fit_linear_references,
33
+ )
34
+ from onescience.utils.uma.common.utils import load_model_and_weights_from_checkpoint
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class DistributedContext:
39
+ """Runtime information for a normal Python process or a torchrun worker."""
40
+
41
+ rank: int = 0
42
+ world_size: int = 1
43
+ local_rank: int = 0
44
+
45
+ @property
46
+ def enabled(self) -> bool:
47
+ return self.world_size > 1
48
+
49
+ @property
50
+ def is_main(self) -> bool:
51
+ return self.rank == 0
52
+
53
+
54
+ def _init_distributed(device_name: str, backend: str) -> DistributedContext:
55
+ world_size = int(os.environ.get("WORLD_SIZE", "1"))
56
+ if world_size == 1:
57
+ if device_name.startswith("cuda") and torch.cuda.is_available():
58
+ torch.cuda.set_device(0)
59
+ return DistributedContext()
60
+
61
+ if not torch.distributed.is_available():
62
+ raise RuntimeError("torch.distributed is required for multi-device fine-tuning.")
63
+ rank = int(os.environ["RANK"])
64
+ local_rank = int(os.environ.get("LOCAL_RANK", rank))
65
+ if device_name.startswith("cuda"):
66
+ if not torch.cuda.is_available():
67
+ raise RuntimeError("torchrun requested multiple CUDA/DCU devices, but CUDA is unavailable.")
68
+ torch.cuda.set_device(local_rank)
69
+ torch.distributed.init_process_group(backend=backend, rank=rank, world_size=world_size)
70
+ return DistributedContext(rank=rank, world_size=world_size, local_rank=local_rank)
71
+
72
+
73
+ def _close_distributed(context: DistributedContext) -> None:
74
+ if context.enabled and torch.distributed.is_initialized():
75
+ torch.distributed.barrier()
76
+ torch.distributed.destroy_process_group()
77
+
78
+
79
+ def _loader(
80
+ path: str | list[str],
81
+ batch_size: int,
82
+ workers: int,
83
+ max_samples: int | None = None,
84
+ context: DistributedContext | None = None,
85
+ train: bool = False,
86
+ seed: int = 0,
87
+ ) -> DataLoader:
88
+ dataset = AseDBDataset(
89
+ {
90
+ "src": path,
91
+ "a2g_args": {
92
+ "r_edges": False,
93
+ "r_energy": True,
94
+ "r_forces": True,
95
+ "r_stress": True,
96
+ },
97
+ }
98
+ )
99
+ if max_samples is not None:
100
+ sample_count = min(max_samples, len(dataset))
101
+ generator = torch.Generator().manual_seed(seed)
102
+ indices = torch.randperm(len(dataset), generator=generator)[:sample_count].tolist()
103
+ dataset = Subset(dataset, indices)
104
+ context = context or DistributedContext()
105
+ sampler = None
106
+ if context.enabled:
107
+ sampler = DistributedSampler(
108
+ dataset,
109
+ num_replicas=context.world_size,
110
+ rank=context.rank,
111
+ shuffle=train,
112
+ drop_last=False,
113
+ )
114
+ return DataLoader(
115
+ dataset,
116
+ batch_size=batch_size,
117
+ shuffle=sampler is None and train,
118
+ sampler=sampler,
119
+ num_workers=workers,
120
+ collate_fn=lambda items: data_list_collater(items, otf_graph=True),
121
+ )
122
+
123
+
124
+ def _loss(
125
+ pred,
126
+ batch,
127
+ energy_weight: float,
128
+ force_weight: float,
129
+ stress_weight: float,
130
+ transforms: ESENCheckpointTransforms,
131
+ ):
132
+ losses = {}
133
+ if energy_weight:
134
+ energy_target = transforms.normalize_target(
135
+ "energy", batch.energy, pred["energy"], batch
136
+ )
137
+ energy_error = pred["energy"] - energy_target
138
+ natoms_shape = (-1,) + (1,) * (energy_error.ndim - 1)
139
+ natoms = batch.natoms.to(energy_error).reshape(natoms_shape)
140
+ losses["energy"] = (energy_error / natoms).square().mean()
141
+ if force_weight:
142
+ force_target = transforms.normalize_target(
143
+ "forces", batch.forces, pred["forces"], batch
144
+ )
145
+ losses["forces"] = (pred["forces"] - force_target).square().mean()
146
+ if stress_weight and hasattr(batch, "stress") and "stress" in pred:
147
+ stress_target = transforms.normalize_target(
148
+ "stress", batch.stress, pred["stress"], batch
149
+ )
150
+ losses["stress"] = (pred["stress"] - stress_target).square().mean()
151
+ total = energy_weight * losses.get("energy", 0.0)
152
+ total = total + force_weight * losses.get("forces", 0.0)
153
+ total = total + stress_weight * losses.get("stress", 0.0)
154
+ return total, {key: float(value.detach()) for key, value in losses.items()}
155
+
156
+
157
+ def _run_epoch(
158
+ model,
159
+ loader,
160
+ device,
161
+ optimizer,
162
+ weights,
163
+ transforms: ESENCheckpointTransforms,
164
+ context: DistributedContext,
165
+ ):
166
+ training = optimizer is not None
167
+ model.train(training)
168
+ total = 0.0
169
+ batches = 0
170
+ metric_names = tuple(
171
+ name
172
+ for name, weight in zip(("energy", "forces", "stress"), weights)
173
+ if weight
174
+ )
175
+ metrics = {name: 0.0 for name in metric_names}
176
+ for batch in loader:
177
+ batch = batch.to(device)
178
+ if training:
179
+ optimizer.zero_grad(set_to_none=True)
180
+ prediction = model(batch)
181
+ loss, batch_metrics = _loss(prediction, batch, *weights, transforms)
182
+ if training:
183
+ loss.backward()
184
+ optimizer.step()
185
+ total += float(loss.detach())
186
+ batches += 1
187
+ for key, value in batch_metrics.items():
188
+ metrics[key] = metrics.get(key, 0.0) + value
189
+ if batches == 0:
190
+ raise RuntimeError("The dataset contains no samples.")
191
+ values = torch.tensor([total, *metrics.values(), float(batches)], dtype=torch.float64, device=device)
192
+ if context.enabled:
193
+ torch.distributed.all_reduce(values, op=torch.distributed.ReduceOp.SUM)
194
+ global_batches = values[-1].item()
195
+ return {
196
+ "loss": values[0].item() / global_batches,
197
+ **{
198
+ key: values[index].item() / global_batches
199
+ for index, key in enumerate(metrics, start=1)
200
+ },
201
+ }
202
+
203
+
204
+ def main() -> None:
205
+ parser = argparse.ArgumentParser(description=__doc__)
206
+ parser.add_argument("--config", help="YAML configuration path")
207
+ parser.add_argument("--checkpoint")
208
+ parser.add_argument("--train", help="ASE DB or ASE-LMDB training path")
209
+ parser.add_argument("--val", help="ASE DB or ASE-LMDB validation path")
210
+ parser.add_argument("--output")
211
+ parser.add_argument("--device")
212
+ parser.add_argument("--epochs", type=int)
213
+ parser.add_argument("--batch-size", type=int)
214
+ parser.add_argument("--workers", type=int)
215
+ parser.add_argument("--lr", type=float)
216
+ parser.add_argument("--energy-weight", type=float)
217
+ parser.add_argument("--force-weight", type=float)
218
+ parser.add_argument("--stress-weight", type=float)
219
+ parser.add_argument("--max-train-samples", type=int)
220
+ parser.add_argument("--max-val-samples", type=int)
221
+ parser.add_argument("--backend", help="torch.distributed backend for torchrun")
222
+ parser.add_argument("--seed", type=int)
223
+ parser.add_argument(
224
+ "--fit-element-references",
225
+ action=argparse.BooleanOptionalAction,
226
+ default=None,
227
+ help="fit energy element references on the training data",
228
+ )
229
+ args = parser.parse_args()
230
+
231
+ if not args.config:
232
+ parser.error("--config is required; use a YAML file from demo/configs")
233
+ config_path = args.config
234
+ with Path(config_path).expanduser().open() as handle:
235
+ config = yaml.safe_load(handle) or {}
236
+ for key, value in config.items():
237
+ if getattr(args, key.replace("-", "_"), None) is None:
238
+ setattr(args, key.replace("-", "_"), value)
239
+ for key in ("checkpoint", "train", "val", "output"):
240
+ value = getattr(args, key)
241
+ if value is not None:
242
+ if isinstance(value, list):
243
+ value = [
244
+ os.path.expandvars(os.path.expanduser(str(item)))
245
+ for item in value
246
+ ]
247
+ else:
248
+ value = os.path.expandvars(os.path.expanduser(str(value)))
249
+ setattr(args, key, value)
250
+ required = ("checkpoint", "train", "val", "output")
251
+ missing = [key for key in required if not getattr(args, key)]
252
+ if missing:
253
+ parser.error("missing required config fields: " + ", ".join(missing))
254
+ args.backend = args.backend or "nccl"
255
+ args.seed = 0 if args.seed is None else args.seed
256
+ args.fit_element_references = bool(args.fit_element_references)
257
+ if not any((args.energy_weight, args.force_weight, args.stress_weight)):
258
+ parser.error("at least one of energy_weight, force_weight, or stress_weight must be nonzero")
259
+
260
+ if args.device.startswith("cuda") and not torch.cuda.is_available():
261
+ raise RuntimeError("CUDA/DCU was requested but torch.cuda.is_available() is false.")
262
+ context = _init_distributed(args.device, args.backend)
263
+ try:
264
+ if args.device.startswith("cuda"):
265
+ device = torch.device(f"cuda:{context.local_rank}")
266
+ else:
267
+ device = torch.device(args.device)
268
+ torch.manual_seed(args.seed + context.rank)
269
+
270
+ # Import registrations before the generic native checkpoint loader.
271
+ import onescience.models.esen # noqa: F401
272
+
273
+ model = load_model_and_weights_from_checkpoint(args.checkpoint).to(device)
274
+ transforms = ESENCheckpointTransforms.from_checkpoint(args.checkpoint)
275
+ if args.fit_element_references:
276
+ reference_dataset = _loader(
277
+ args.train, args.batch_size, args.workers
278
+ ).dataset
279
+ fitted_references = fit_linear_references(
280
+ targets=["energy"],
281
+ dataset=reference_dataset,
282
+ batch_size=args.batch_size,
283
+ num_workers=args.workers,
284
+ log_metrics=False,
285
+ shuffle=False,
286
+ )
287
+ transforms.elementrefs["energy"] = fitted_references["energy"]
288
+ if context.is_main:
289
+ print("fitted energy element references from training data", flush=True)
290
+ transforms = transforms.to(device)
291
+ if context.enabled:
292
+ model = DistributedDataParallel(
293
+ model,
294
+ device_ids=[context.local_rank] if device.type == "cuda" else None,
295
+ output_device=context.local_rank if device.type == "cuda" else None,
296
+ )
297
+ optimizer = torch.optim.AdamW(model.parameters(), lr=args.lr)
298
+ train_loader = _loader(
299
+ args.train,
300
+ args.batch_size,
301
+ args.workers,
302
+ args.max_train_samples,
303
+ context=context,
304
+ train=True,
305
+ seed=args.seed,
306
+ )
307
+ val_loader = _loader(
308
+ args.val,
309
+ args.batch_size,
310
+ args.workers,
311
+ args.max_val_samples,
312
+ context=context,
313
+ train=False,
314
+ seed=args.seed + 1,
315
+ )
316
+ weights = (args.energy_weight, args.force_weight, args.stress_weight)
317
+
318
+ history = []
319
+ for epoch in range(args.epochs):
320
+ if isinstance(train_loader.sampler, DistributedSampler):
321
+ train_loader.sampler.set_epoch(epoch)
322
+ train_metrics = _run_epoch(
323
+ model, train_loader, device, optimizer, weights, transforms, context
324
+ )
325
+ # Force/stress outputs are gradients of the energy, so validation also
326
+ # needs autograd even though model parameters are not updated.
327
+ val_metrics = _run_epoch(
328
+ model, val_loader, device, None, weights, transforms, context
329
+ )
330
+ record = {"epoch": epoch, "train": train_metrics, "val": val_metrics}
331
+ if context.is_main:
332
+ history.append(record)
333
+ print(json.dumps(record, sort_keys=True), flush=True)
334
+
335
+ if context.is_main:
336
+ output = Path(args.output)
337
+ output.parent.mkdir(parents=True, exist_ok=True)
338
+ source = torch.load(args.checkpoint, map_location="cpu", weights_only=False)
339
+ checkpoint = copy.deepcopy(source)
340
+ base_model = model.module if context.enabled else model
341
+ checkpoint["state_dict"] = {
342
+ key: value.detach().cpu() for key, value in base_model.state_dict().items()
343
+ }
344
+ checkpoint["elementrefs"] = {
345
+ name: {
346
+ key: value.detach().cpu()
347
+ for key, value in elementref.state_dict().items()
348
+ }
349
+ for name, elementref in transforms.elementrefs.items()
350
+ }
351
+ checkpoint.setdefault("metadata", {})
352
+ checkpoint["metadata"].update(
353
+ {
354
+ "onescience_esen_history": history,
355
+ "source_checkpoint": args.checkpoint,
356
+ "world_size": context.world_size,
357
+ "loss_space": "checkpoint_normalized",
358
+ "element_references": (
359
+ "fitted_from_training_data"
360
+ if args.fit_element_references
361
+ else "source_checkpoint"
362
+ ),
363
+ }
364
+ )
365
+ torch.save(checkpoint, output)
366
+ print(f"saved checkpoint: {output}", flush=True)
367
+ finally:
368
+ _close_distributed(context)
369
+
370
+
371
+ if __name__ == "__main__":
372
+ main()
md.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Run a short NVT molecular-dynamics trajectory with eSEN."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+
8
+ os.environ.setdefault(
9
+ "ONESCIENCE_ESEN_JD_PATH",
10
+ os.path.join(os.path.dirname(__file__), "weight", "Jd.pt"),
11
+ )
12
+
13
+ import numpy as np
14
+ from ase import units
15
+ from ase.build import bulk
16
+ from ase.io import read
17
+ from ase.io.trajectory import Trajectory
18
+ from ase.md.langevin import Langevin
19
+ from ase.md.velocitydistribution import MaxwellBoltzmannDistribution, Stationary
20
+
21
+ from onescience.utils.esen import eSENCalculator
22
+
23
+
24
+ def default_checkpoint() -> str:
25
+ return os.path.join(os.path.dirname(__file__), "weight", "esen_30m_mptrj.pt")
26
+
27
+
28
+ def load_structure(path: str | None, repeat: tuple[int, int, int] | None):
29
+ if path:
30
+ atoms = read(path)
31
+ else:
32
+ atoms = bulk("Si", "diamond", a=5.43).repeat((2, 2, 2))
33
+ return atoms.repeat(repeat) if repeat else atoms
34
+
35
+
36
+ def main() -> None:
37
+ parser = argparse.ArgumentParser(description=__doc__)
38
+ parser.add_argument("--checkpoint", default=default_checkpoint())
39
+ parser.add_argument("--input", help="CIF, POSCAR, XYZ, or another ASE-readable structure")
40
+ parser.add_argument(
41
+ "--repeat",
42
+ type=int,
43
+ nargs=3,
44
+ metavar=("NX", "NY", "NZ"),
45
+ help="repeat the input structure along its three cell vectors",
46
+ )
47
+ parser.add_argument("--steps", type=int, default=10)
48
+ parser.add_argument("--temperature", type=float, default=300.0)
49
+ parser.add_argument("--timestep", type=float, default=1.0, help="time step in fs")
50
+ parser.add_argument(
51
+ "--friction", type=float, default=0.01, help="Langevin friction in 1/fs"
52
+ )
53
+ parser.add_argument("--seed", type=int, default=0)
54
+ parser.add_argument("--output", default="md.traj")
55
+ parser.add_argument("--device", default="cuda")
56
+ args = parser.parse_args()
57
+
58
+ repeat = tuple(args.repeat) if args.repeat else None
59
+ atoms = load_structure(args.input, repeat)
60
+ atoms.calc = eSENCalculator.from_checkpoint(args.checkpoint, device=args.device)
61
+ rng = np.random.default_rng(args.seed)
62
+ MaxwellBoltzmannDistribution(atoms, temperature_K=args.temperature, rng=rng)
63
+ Stationary(atoms)
64
+ dynamics = Langevin(
65
+ atoms,
66
+ timestep=args.timestep * units.fs,
67
+ temperature_K=args.temperature,
68
+ friction=args.friction / units.fs,
69
+ )
70
+ trajectory = Trajectory(args.output, "w", atoms)
71
+ dynamics.attach(trajectory.write, interval=1)
72
+ dynamics.run(args.steps)
73
+ trajectory.close()
74
+ print("formula:", atoms.get_chemical_formula())
75
+ print("atoms:", len(atoms))
76
+ print("steps:", dynamics.nsteps)
77
+ print("energy (eV):", atoms.get_potential_energy())
78
+
79
+
80
+ if __name__ == "__main__":
81
+ main()
model/__init__.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """eSEN atomistic potentials adapted to OneScience's UMA runtime."""
2
+
3
+ from .esen import eSEN_Backbone, Linear_Force_Head, MLP_EFS_Head, MLP_Energy_Head
4
+ from .esen_dens import (
5
+ Linear_Force_Head_DeNS,
6
+ MLP_EFS_Head as MLP_EFS_Head_DeNS,
7
+ eSEN_DeNS_Backbone,
8
+ )
9
+
10
+ __all__ = [
11
+ "eSEN_Backbone",
12
+ "eSEN_DeNS_Backbone",
13
+ "MLP_EFS_Head",
14
+ "MLP_EFS_Head_DeNS",
15
+ "MLP_Energy_Head",
16
+ "Linear_Force_Head",
17
+ "Linear_Force_Head_DeNS",
18
+ ]
model/esen.py ADDED
@@ -0,0 +1,514 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Copyright (c) Meta, Inc. and its affiliates.
3
+
4
+ This source code is licensed under the MIT license found in the
5
+ LICENSE file in the root directory of this source tree.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+
12
+ import torch
13
+ import torch.nn as nn
14
+
15
+ from onescience.models.UMA.base import BackboneInterface, HeadInterface
16
+ from onescience.models.esen.graph import GraphModelMixin
17
+ from onescience.utils.uma.common.registry import registry
18
+ from onescience.utils.uma.common.utils import conditional_grad
19
+
20
+ from onescience.modules.layer.esen.common.rotation import (
21
+ init_edge_rot_mat,
22
+ rotation_to_wigner,
23
+ )
24
+ from onescience.modules.layer.esen.common.so3 import (
25
+ CoefficientMapping,
26
+ SO3_Grid,
27
+ )
28
+ from onescience.modules.layer.esen.esen_block import eSEN_Block
29
+ from onescience.modules.layer.esen.nn.embedding import EdgeDegreeEmbedding
30
+ from onescience.modules.layer.esen.nn.layer_norm import (
31
+ EquivariantLayerNormArray,
32
+ EquivariantLayerNormArraySphericalHarmonics,
33
+ EquivariantRMSNormArraySphericalHarmonicsV2,
34
+ get_normalization_layer,
35
+ )
36
+ from onescience.modules.layer.esen.nn.radial import EnvelopedBesselBasis, GaussianSmearing
37
+ from onescience.modules.layer.esen.nn.so3_layers import SO3_Linear
38
+ from onescience.modules.func_utils.esen_path_utils import resolve_esen_jd_path
39
+
40
+
41
+ @registry.register_model("esen_backbone")
42
+ class eSEN_Backbone(nn.Module, GraphModelMixin):
43
+ def __init__(
44
+ self,
45
+ max_num_elements: int = 100,
46
+ sphere_channels: int = 128,
47
+ lmax: int = 2,
48
+ mmax: int = 2,
49
+ grid_resolution: int | None = None,
50
+ otf_graph: bool = False,
51
+ max_neighbors: int = 300,
52
+ use_pbc: bool = True,
53
+ use_pbc_single: bool = False,
54
+ cutoff: float = 5.0,
55
+ edge_channels: int = 128,
56
+ distance_function: str = "gaussian",
57
+ num_distance_basis: int = 512,
58
+ direct_forces: bool = True,
59
+ regress_forces: bool = True,
60
+ regress_stress: bool = False,
61
+ # escnmd specific
62
+ num_layers: int = 2,
63
+ hidden_channels: int = 128,
64
+ norm_type: str = "rms_norm_sh",
65
+ act_type: str = "s2",
66
+ mlp_type: str = "grid",
67
+ use_envelope: bool = False,
68
+ activation_checkpointing: bool = False,
69
+ jd_path: str | None = None,
70
+ ):
71
+ super().__init__()
72
+
73
+ self.max_num_elements = max_num_elements
74
+ self.lmax = lmax
75
+ self.mmax = mmax
76
+ self.sphere_channels = sphere_channels
77
+ self.grid_resolution = grid_resolution
78
+
79
+ self.regress_forces = regress_forces
80
+ self.direct_forces = direct_forces
81
+ self.regress_stress = regress_stress
82
+
83
+ self.otf_graph = otf_graph
84
+ self.max_neighbors = max_neighbors
85
+ self.use_pbc = use_pbc
86
+ self.use_pbc_single = use_pbc_single
87
+ self.enforce_max_neighbors_strictly = False
88
+ self.activation_checkpointing = activation_checkpointing
89
+
90
+ self.mlp_type = mlp_type
91
+ self.use_envelope = use_envelope
92
+
93
+ # rotation utils
94
+ Jd_list = torch.load(resolve_esen_jd_path(jd_path))
95
+ for l in range(self.lmax + 1):
96
+ self.register_buffer(f"Jd_{l}", Jd_list[l])
97
+ self.sph_feature_size = int((self.lmax + 1) ** 2)
98
+ self.mappingReduced = CoefficientMapping(self.lmax, self.mmax)
99
+
100
+ # lmax_lmax for node, lmax_mmax for edge
101
+ self.SO3_grid = nn.ModuleDict()
102
+ self.SO3_grid["lmax_lmax"] = SO3_Grid(
103
+ self.lmax, self.lmax, resolution=grid_resolution, rescale=True
104
+ )
105
+ self.SO3_grid["lmax_mmax"] = SO3_Grid(
106
+ self.lmax, self.mmax, resolution=grid_resolution, rescale=True
107
+ )
108
+
109
+ # atom embedding
110
+ self.sphere_embedding = nn.Embedding(
111
+ self.max_num_elements, self.sphere_channels
112
+ )
113
+
114
+ # edge distance embedding
115
+ self.cutoff = cutoff
116
+ self.edge_channels = edge_channels
117
+ self.distance_function = distance_function
118
+ self.num_distance_basis = num_distance_basis
119
+
120
+ if self.distance_function == "gaussian":
121
+ self.distance_expansion = GaussianSmearing(
122
+ 0.0,
123
+ self.cutoff,
124
+ self.num_distance_basis,
125
+ 2.0,
126
+ )
127
+ elif self.distance_function == "bessel":
128
+ self.distance_expansion = EnvelopedBesselBasis(
129
+ num_radial=self.num_distance_basis,
130
+ cutoff=cutoff,
131
+ )
132
+ self.distance_expansion.offset = [self.cutoff]
133
+ self.distance_expansion.num_output = self.num_distance_basis
134
+ else:
135
+ raise ValueError("Unknown distance function")
136
+
137
+ # equivariant initial embedding
138
+ self.source_embedding = nn.Embedding(self.max_num_elements, self.edge_channels)
139
+ self.target_embedding = nn.Embedding(self.max_num_elements, self.edge_channels)
140
+ nn.init.uniform_(self.source_embedding.weight.data, -0.001, 0.001)
141
+ nn.init.uniform_(self.target_embedding.weight.data, -0.001, 0.001)
142
+
143
+ self.edge_channels_list = [
144
+ self.num_distance_basis + 2 * self.edge_channels,
145
+ self.edge_channels,
146
+ self.edge_channels,
147
+ ]
148
+
149
+ self.edge_degree_embedding = EdgeDegreeEmbedding(
150
+ sphere_channels=self.sphere_channels,
151
+ lmax=self.lmax,
152
+ mmax=self.mmax,
153
+ max_num_elements=self.max_num_elements,
154
+ edge_channels_list=self.edge_channels_list,
155
+ rescale_factor=5.0,
156
+ cutoff=self.cutoff,
157
+ mappingReduced=self.mappingReduced,
158
+ out_mask=self.SO3_grid["lmax_lmax"].mapping.coefficient_idx(
159
+ self.lmax, self.mmax
160
+ ),
161
+ use_envelope=use_envelope,
162
+ )
163
+
164
+ self.num_layers = num_layers
165
+ self.hidden_channels = hidden_channels
166
+ self.norm_type = norm_type
167
+ self.act_type = act_type
168
+
169
+ # Initialize the blocks for each layer
170
+ self.blocks = nn.ModuleList()
171
+ for _ in range(self.num_layers):
172
+ block = eSEN_Block(
173
+ self.sphere_channels,
174
+ self.hidden_channels,
175
+ self.lmax,
176
+ self.mmax,
177
+ self.mappingReduced,
178
+ self.SO3_grid,
179
+ self.edge_channels_list,
180
+ self.cutoff,
181
+ self.norm_type,
182
+ self.act_type,
183
+ self.mlp_type,
184
+ self.use_envelope,
185
+ )
186
+ self.blocks.append(block)
187
+
188
+ self.norm = get_normalization_layer(
189
+ self.norm_type,
190
+ lmax=self.lmax,
191
+ num_channels=self.sphere_channels,
192
+ )
193
+
194
+ def get_rotmat_and_wigner(self, edge_distance_vecs):
195
+ edge_rot_mat = init_edge_rot_mat(
196
+ edge_distance_vecs, rot_clip=(not self.direct_forces)
197
+ )
198
+
199
+ Jd_buffers = [
200
+ getattr(self, f"Jd_{l}").type(edge_rot_mat.dtype)
201
+ for l in range(self.lmax + 1)
202
+ ]
203
+
204
+ wigner = rotation_to_wigner(
205
+ edge_rot_mat,
206
+ 0,
207
+ self.lmax,
208
+ Jd_buffers,
209
+ rot_clip=(not self.direct_forces),
210
+ )
211
+ wigner_inv = torch.transpose(wigner, 1, 2).contiguous()
212
+
213
+ return edge_rot_mat, wigner, wigner_inv
214
+
215
+ def generate_graph(self, *args, **kwargs):
216
+ graph = super().generate_graph(*args, **kwargs)
217
+ return {
218
+ "edge_index": graph.edge_index,
219
+ "edge_distance": graph.edge_distance,
220
+ "edge_distance_vec": graph.edge_distance_vec,
221
+ "cell_offsets": graph.cell_offsets,
222
+ "offset_distances": None,
223
+ "neighbors": None,
224
+ "node_offset": 0,
225
+ "batch_full": graph.batch_full,
226
+ "atomic_numbers_full": graph.atomic_numbers_full,
227
+ }
228
+
229
+ @conditional_grad(torch.enable_grad())
230
+ def forward(self, data_dict) -> dict[str, torch.Tensor]:
231
+ ###############################################################
232
+ # gradient-based forces/stress
233
+ ###############################################################
234
+ data_dict["atomic_numbers"] = data_dict["atomic_numbers"].long()
235
+
236
+ displacement = None
237
+ orig_cell = None
238
+ if self.regress_stress and not self.direct_forces:
239
+ displacement = torch.zeros(
240
+ (3, 3),
241
+ dtype=data_dict["pos"].dtype,
242
+ device=data_dict["pos"].device,
243
+ )
244
+ # num_batch = data_dict["num_graphs"]
245
+ num_batch = data_dict.get("num_graphs", len(data_dict["natoms"]))
246
+ displacement = displacement.view(-1, 3, 3).expand(num_batch, 3, 3)
247
+ displacement.requires_grad_(True)
248
+ symmetric_displacement = 0.5 * (
249
+ displacement + displacement.transpose(-1, -2)
250
+ )
251
+
252
+ data_dict["pos"].requires_grad_(True)
253
+ data_dict["pos"] = data_dict["pos"] + torch.bmm(
254
+ data_dict["pos"].unsqueeze(-2),
255
+ torch.index_select(symmetric_displacement, 0, data_dict["batch"]),
256
+ ).squeeze(-2)
257
+
258
+ orig_cell = data_dict["cell"]
259
+ data_dict["cell"] = data_dict["cell"] + torch.bmm(
260
+ data_dict["cell"], symmetric_displacement
261
+ )
262
+
263
+ if not self.regress_stress and self.regress_forces and not self.direct_forces:
264
+ data_dict["pos"].requires_grad_(True)
265
+
266
+ if self.otf_graph:
267
+ graph_dict = self.generate_graph(data_dict)
268
+ else:
269
+ cell_per_edge = data_dict["cell"].repeat_interleave(
270
+ data_dict["nedges"], dim=0
271
+ )
272
+ shifts = torch.einsum(
273
+ "ij,ijk->ik",
274
+ data_dict["cell_offsets"].to(cell_per_edge.dtype),
275
+ cell_per_edge,
276
+ )
277
+ edge_distance_vec = (
278
+ data_dict["pos"][data_dict["edge_index"][0]]
279
+ - data_dict["pos"][data_dict["edge_index"][1]]
280
+ + shifts
281
+ )
282
+ # pylint: disable=E1102
283
+ edge_distance = torch.linalg.norm(edge_distance_vec, dim=-1, keepdim=False)
284
+ graph_dict = {
285
+ "atomic_numbers_full": data_dict["atomic_numbers_full"],
286
+ "batch_full": data_dict["batch_full"],
287
+ "edge_index": data_dict["edge_index"],
288
+ "edge_distance": edge_distance,
289
+ "edge_distance_vec": edge_distance_vec,
290
+ "node_offset": 0,
291
+ }
292
+
293
+ _, wigner, wigner_inv = self.get_rotmat_and_wigner(
294
+ graph_dict["edge_distance_vec"]
295
+ )
296
+
297
+ ###############################################################
298
+ # Initialize node embeddings
299
+ ###############################################################
300
+
301
+ x_message = torch.zeros(
302
+ data_dict["pos"].shape[0],
303
+ self.sph_feature_size,
304
+ self.sphere_channels,
305
+ device=data_dict["pos"].device,
306
+ dtype=data_dict["pos"].dtype,
307
+ )
308
+ x_message[:, 0, :] = self.sphere_embedding(data_dict["atomic_numbers"])
309
+
310
+ # edge degree embedding
311
+ edge_distance_embedding = self.distance_expansion(graph_dict["edge_distance"])
312
+ source_embedding = self.source_embedding(
313
+ data_dict["atomic_numbers"][graph_dict["edge_index"][0]]
314
+ )
315
+ target_embedding = self.target_embedding(
316
+ data_dict["atomic_numbers"][graph_dict["edge_index"][1]]
317
+ )
318
+ x_edge = torch.cat(
319
+ (edge_distance_embedding, source_embedding, target_embedding), dim=1
320
+ )
321
+ x_message = self.edge_degree_embedding(
322
+ x_message,
323
+ x_edge,
324
+ graph_dict["edge_distance"],
325
+ graph_dict["edge_index"],
326
+ wigner_inv,
327
+ )
328
+
329
+ ###############################################################
330
+ # Update spherical node embeddings
331
+ ###############################################################
332
+ if graph_dict["edge_index"].shape[1] != 0:
333
+ for i in range(self.num_layers):
334
+ if self.activation_checkpointing:
335
+ x_message = torch.utils.checkpoint.checkpoint(
336
+ self.blocks[i],
337
+ x_message,
338
+ x_edge,
339
+ graph_dict["edge_distance"],
340
+ graph_dict["edge_index"],
341
+ wigner,
342
+ wigner_inv,
343
+ graph_dict["node_offset"],
344
+ use_reentrant=False,
345
+ )
346
+ else:
347
+ x_message = self.blocks[i](
348
+ x_message,
349
+ x_edge,
350
+ graph_dict["edge_distance"],
351
+ graph_dict["edge_index"],
352
+ wigner,
353
+ wigner_inv,
354
+ node_offset=graph_dict["node_offset"],
355
+ )
356
+
357
+ # Final layer norm
358
+ x_message = self.norm(x_message)
359
+
360
+ out = {
361
+ "node_embedding": x_message,
362
+ "displacement": displacement,
363
+ "orig_cell": orig_cell,
364
+ }
365
+ out.update(graph_dict)
366
+
367
+ return out
368
+
369
+ @property
370
+ def num_params(self):
371
+ return sum(p.numel() for p in self.parameters())
372
+
373
+ @torch.jit.ignore
374
+ def no_weight_decay(self) -> set:
375
+ no_wd_list = []
376
+ named_parameters_list = [name for name, _ in self.named_parameters()]
377
+ for module_name, module in self.named_modules():
378
+ if isinstance(
379
+ module,
380
+ (
381
+ torch.nn.Linear,
382
+ SO3_Linear,
383
+ torch.nn.LayerNorm,
384
+ EquivariantLayerNormArray,
385
+ EquivariantLayerNormArraySphericalHarmonics,
386
+ EquivariantRMSNormArraySphericalHarmonicsV2,
387
+ ),
388
+ ):
389
+ for parameter_name, _ in module.named_parameters():
390
+ if (
391
+ isinstance(module, (torch.nn.Linear, SO3_Linear))
392
+ and "weight" in parameter_name
393
+ ):
394
+ continue
395
+ global_parameter_name = module_name + "." + parameter_name
396
+ assert global_parameter_name in named_parameters_list
397
+ no_wd_list.append(global_parameter_name)
398
+
399
+ return set(no_wd_list)
400
+
401
+
402
+ @registry.register_model("esen_mlp_efs_head")
403
+ class MLP_EFS_Head(nn.Module, HeadInterface):
404
+ def __init__(self, backbone):
405
+ super().__init__()
406
+ backbone.energy_block = None
407
+ backbone.force_block = None
408
+ self.regress_stress = backbone.regress_stress
409
+ self.regress_forces = backbone.regress_forces
410
+
411
+ self.sphere_channels = backbone.sphere_channels
412
+ self.hidden_channels = backbone.hidden_channels
413
+ self.energy_block = nn.Sequential(
414
+ nn.Linear(self.sphere_channels, self.hidden_channels, bias=True),
415
+ nn.SiLU(),
416
+ nn.Linear(self.hidden_channels, self.hidden_channels, bias=True),
417
+ nn.SiLU(),
418
+ nn.Linear(self.hidden_channels, 1, bias=True),
419
+ )
420
+
421
+ backbone.direct_forces = False
422
+
423
+ @conditional_grad(torch.enable_grad())
424
+ def forward(self, data, emb: dict[str, torch.Tensor]) -> dict[str, torch.Tensor]:
425
+ energy_key = "energy"
426
+ forces_key = "forces"
427
+ stress_key = "stress"
428
+
429
+ outputs = {}
430
+
431
+ node_energy = self.energy_block(
432
+ emb["node_embedding"].narrow(1, 0, 1).squeeze()
433
+ ).view(-1, 1, 1)
434
+
435
+ energy = torch.zeros(
436
+ len(data["natoms"]), device=data["pos"].device, dtype=node_energy.dtype
437
+ )
438
+ energy.index_add_(0, data["batch"], node_energy.view(-1))
439
+ outputs[energy_key] = energy
440
+
441
+ if self.regress_stress:
442
+ grads = torch.autograd.grad(
443
+ [energy.sum()],
444
+ [data["pos"], emb["displacement"]],
445
+ create_graph=self.training,
446
+ )
447
+ forces = torch.neg(grads[0])
448
+ virial = grads[1].view(-1, 3, 3)
449
+ volume = torch.det(data["cell"]).abs().unsqueeze(-1)
450
+ stress = virial / volume.view(-1, 1, 1)
451
+ virial = torch.neg(virial)
452
+ outputs[forces_key] = forces
453
+ outputs[stress_key] = stress.view(-1, 9)
454
+ data["cell"] = emb["orig_cell"]
455
+ elif self.regress_forces:
456
+ forces = (
457
+ -1
458
+ * torch.autograd.grad(
459
+ energy.sum(), data["pos"], create_graph=self.training
460
+ )[0]
461
+ )
462
+ outputs[forces_key] = forces
463
+ return outputs
464
+
465
+
466
+ @registry.register_model("esen_mlp_energy_head")
467
+ class MLP_Energy_Head(nn.Module, HeadInterface):
468
+ def __init__(self, backbone, reduce: str = "sum"):
469
+ super().__init__()
470
+ self.reduce = reduce
471
+
472
+ self.sphere_channels = backbone.sphere_channels
473
+ self.hidden_channels = backbone.hidden_channels
474
+ self.energy_block = nn.Sequential(
475
+ nn.Linear(self.sphere_channels, self.hidden_channels, bias=True),
476
+ nn.SiLU(),
477
+ nn.Linear(self.hidden_channels, self.hidden_channels, bias=True),
478
+ nn.SiLU(),
479
+ nn.Linear(self.hidden_channels, 1, bias=True),
480
+ )
481
+
482
+ def forward(self, data_dict, emb: dict[str, torch.Tensor]):
483
+ node_energy = self.energy_block(
484
+ emb["node_embedding"].narrow(1, 0, 1).squeeze()
485
+ ).view(-1, 1, 1)
486
+
487
+ energy = torch.zeros(
488
+ len(data_dict["natoms"]),
489
+ device=node_energy.device,
490
+ dtype=node_energy.dtype,
491
+ )
492
+
493
+ energy.index_add_(0, data_dict["batch"], node_energy.view(-1))
494
+ if self.reduce == "sum":
495
+ return {"energy": energy}
496
+ elif self.reduce == "mean":
497
+ return {"energy": energy / data_dict["natoms"]}
498
+ else:
499
+ raise ValueError(
500
+ f"reduce can only be sum or mean, user provided: {self.reduce}"
501
+ )
502
+
503
+
504
+ @registry.register_model("esen_linear_force_head")
505
+ class Linear_Force_Head(nn.Module, HeadInterface):
506
+ def __init__(self, backbone):
507
+ super().__init__()
508
+ self.linear = SO3_Linear(backbone.sphere_channels, 1, lmax=1)
509
+
510
+ def forward(self, data_dict, emb: dict[str, torch.Tensor]):
511
+ forces = self.linear(emb["node_embedding"].narrow(1, 0, 4))
512
+ forces = forces.narrow(1, 1, 3)
513
+ forces = forces.view(-1, 3).contiguous()
514
+ return {"forces": forces}
model/esen_dens.py ADDED
@@ -0,0 +1,582 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Copyright (c) Meta, Inc. and its affiliates.
3
+
4
+ This source code is licensed under the MIT license found in the
5
+ LICENSE file in the root directory of this source tree.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ import os
12
+
13
+ import torch
14
+ import torch.nn as nn
15
+ from e3nn import o3
16
+
17
+ from onescience.models.UMA.base import BackboneInterface, HeadInterface
18
+ from onescience.models.esen.graph import GraphModelMixin
19
+ from onescience.utils.uma.common.registry import registry
20
+ from onescience.utils.uma.common.utils import conditional_grad
21
+
22
+ from onescience.modules.layer.esen.common.rotation import (
23
+ init_edge_rot_mat,
24
+ rotation_to_wigner,
25
+ )
26
+ from onescience.modules.layer.esen.common.so3 import (
27
+ CoefficientMapping,
28
+ SO3_Grid,
29
+ )
30
+ from onescience.modules.layer.esen.esen_block import eSEN_Block
31
+ from onescience.modules.layer.esen.nn.embedding import EdgeDegreeEmbedding
32
+ from onescience.modules.layer.esen.nn.layer_norm import (
33
+ EquivariantLayerNormArray,
34
+ EquivariantLayerNormArraySphericalHarmonics,
35
+ EquivariantRMSNormArraySphericalHarmonicsV2,
36
+ get_normalization_layer,
37
+ )
38
+ from onescience.modules.layer.esen.nn.radial import EnvelopedBesselBasis, GaussianSmearing
39
+ from onescience.modules.layer.esen.nn.so3_layers import SO3_Linear
40
+ from onescience.modules.func_utils.esen_path_utils import resolve_esen_jd_path
41
+
42
+
43
+ @registry.register_model("esen_backbone_dens")
44
+ class eSEN_DeNS_Backbone(nn.Module, GraphModelMixin):
45
+ def __init__(
46
+ self,
47
+ max_num_elements: int = 100,
48
+ sphere_channels: int = 128,
49
+ lmax: int = 2,
50
+ mmax: int = 2,
51
+ grid_resolution: int | None = None,
52
+ otf_graph: bool = False,
53
+ max_neighbors: int = 300,
54
+ use_pbc: bool = True,
55
+ use_pbc_single: bool = False,
56
+ cutoff: float = 5.0,
57
+ edge_channels: int = 128,
58
+ distance_function: str = "gaussian",
59
+ num_distance_basis: int = 512,
60
+ direct_forces: bool = True,
61
+ regress_forces: bool = True,
62
+ regress_stress: bool = False,
63
+ # escnmd specific
64
+ num_layers: int = 2,
65
+ hidden_channels: int = 128,
66
+ norm_type: str = "rms_norm_sh",
67
+ act_type: str = "s2",
68
+ mlp_type: str = "grid",
69
+ use_envelope: bool = False,
70
+ activation_checkpointing: bool = False,
71
+ jd_path: str | None = None,
72
+ ):
73
+ super().__init__()
74
+
75
+ self.max_num_elements = max_num_elements
76
+ self.lmax = lmax
77
+ self.mmax = mmax
78
+ self.sphere_channels = sphere_channels
79
+ self.grid_resolution = grid_resolution
80
+
81
+ self.regress_forces = regress_forces
82
+ self.direct_forces = direct_forces
83
+ self.regress_stress = regress_stress
84
+
85
+ self.otf_graph = otf_graph
86
+ self.max_neighbors = max_neighbors
87
+ self.use_pbc = use_pbc
88
+ self.use_pbc_single = use_pbc_single
89
+ self.enforce_max_neighbors_strictly = False
90
+ self.activation_checkpointing = activation_checkpointing
91
+
92
+ self.mlp_type = mlp_type
93
+ self.use_envelope = use_envelope
94
+
95
+ # rotation utils
96
+ Jd_list = torch.load(resolve_esen_jd_path(jd_path))
97
+ for l in range(self.lmax + 1):
98
+ self.register_buffer(f"Jd_{l}", Jd_list[l])
99
+ self.sph_feature_size = int((self.lmax + 1) ** 2)
100
+ self.mappingReduced = CoefficientMapping(self.lmax, self.mmax)
101
+
102
+ # lmax_lmax for node, lmax_mmax for edge
103
+ self.SO3_grid = nn.ModuleDict()
104
+ self.SO3_grid["lmax_lmax"] = SO3_Grid(
105
+ self.lmax, self.lmax, resolution=grid_resolution, rescale=True
106
+ )
107
+ self.SO3_grid["lmax_mmax"] = SO3_Grid(
108
+ self.lmax, self.mmax, resolution=grid_resolution, rescale=True
109
+ )
110
+
111
+ # atom embedding
112
+ self.sphere_embedding = nn.Embedding(
113
+ self.max_num_elements, self.sphere_channels
114
+ )
115
+
116
+ # edge distance embedding
117
+ self.cutoff = cutoff
118
+ self.edge_channels = edge_channels
119
+ self.distance_function = distance_function
120
+ self.num_distance_basis = num_distance_basis
121
+
122
+ if self.distance_function == "gaussian":
123
+ self.distance_expansion = GaussianSmearing(
124
+ 0.0,
125
+ self.cutoff,
126
+ self.num_distance_basis,
127
+ 2.0,
128
+ )
129
+ elif self.distance_function == "bessel":
130
+ self.distance_expansion = EnvelopedBesselBasis(
131
+ num_radial=self.num_distance_basis,
132
+ cutoff=cutoff,
133
+ )
134
+ self.distance_expansion.offset = [self.cutoff]
135
+ self.distance_expansion.num_output = self.num_distance_basis
136
+ else:
137
+ raise ValueError("Unknown distance function")
138
+
139
+ # equivariant initial embedding
140
+ self.source_embedding = nn.Embedding(self.max_num_elements, self.edge_channels)
141
+ self.target_embedding = nn.Embedding(self.max_num_elements, self.edge_channels)
142
+ nn.init.uniform_(self.source_embedding.weight.data, -0.001, 0.001)
143
+ nn.init.uniform_(self.target_embedding.weight.data, -0.001, 0.001)
144
+
145
+ self.edge_channels_list = [
146
+ self.num_distance_basis + 2 * self.edge_channels,
147
+ self.edge_channels,
148
+ self.edge_channels,
149
+ ]
150
+
151
+ self.edge_degree_embedding = EdgeDegreeEmbedding(
152
+ sphere_channels=self.sphere_channels,
153
+ lmax=self.lmax,
154
+ mmax=self.mmax,
155
+ max_num_elements=self.max_num_elements,
156
+ edge_channels_list=self.edge_channels_list,
157
+ rescale_factor=5.0,
158
+ cutoff=self.cutoff,
159
+ mappingReduced=self.mappingReduced,
160
+ out_mask=self.SO3_grid["lmax_lmax"].mapping.coefficient_idx(
161
+ self.lmax, self.mmax
162
+ ),
163
+ use_envelope=use_envelope,
164
+ )
165
+
166
+ self.num_layers = num_layers
167
+ self.hidden_channels = hidden_channels
168
+ self.norm_type = norm_type
169
+ self.act_type = act_type
170
+
171
+ # Initialize the blocks for each layer
172
+ self.blocks = nn.ModuleList()
173
+ for _ in range(self.num_layers):
174
+ block = eSEN_Block(
175
+ self.sphere_channels,
176
+ self.hidden_channels,
177
+ self.lmax,
178
+ self.mmax,
179
+ self.mappingReduced,
180
+ self.SO3_grid,
181
+ self.edge_channels_list,
182
+ self.cutoff,
183
+ self.norm_type,
184
+ self.act_type,
185
+ self.mlp_type,
186
+ self.use_envelope,
187
+ )
188
+ self.blocks.append(block)
189
+
190
+ self.norm = get_normalization_layer(
191
+ self.norm_type,
192
+ lmax=self.lmax,
193
+ num_channels=self.sphere_channels,
194
+ )
195
+
196
+ self.irreps_sh = o3.Irreps.spherical_harmonics(lmax=self.lmax, p=1)
197
+ self.force_embedding = SO3_Linear(
198
+ in_features=1, out_features=self.sphere_channels, lmax=self.lmax
199
+ )
200
+
201
+ def get_rotmat_and_wigner(self, edge_distance_vecs):
202
+ edge_rot_mat = init_edge_rot_mat(
203
+ edge_distance_vecs, rot_clip=(not self.direct_forces)
204
+ )
205
+
206
+ Jd_buffers = [
207
+ getattr(self, f"Jd_{l}").type(edge_rot_mat.dtype)
208
+ for l in range(self.lmax + 1)
209
+ ]
210
+
211
+ wigner = rotation_to_wigner(
212
+ edge_rot_mat,
213
+ 0,
214
+ self.lmax,
215
+ Jd_buffers,
216
+ rot_clip=(not self.direct_forces),
217
+ )
218
+ wigner_inv = torch.transpose(wigner, 1, 2).contiguous()
219
+
220
+ return edge_rot_mat, wigner, wigner_inv
221
+
222
+ def generate_graph(self, *args, **kwargs):
223
+ graph = super().generate_graph(*args, **kwargs)
224
+ return {
225
+ "edge_index": graph.edge_index,
226
+ "edge_distance": graph.edge_distance,
227
+ "edge_distance_vec": graph.edge_distance_vec,
228
+ "cell_offsets": graph.cell_offsets,
229
+ "offset_distances": None,
230
+ "neighbors": None,
231
+ "node_offset": 0,
232
+ "batch_full": graph.batch_full,
233
+ "atomic_numbers_full": graph.atomic_numbers_full,
234
+ }
235
+
236
+ @conditional_grad(torch.enable_grad())
237
+ def forward(self, data_dict) -> dict[str, torch.Tensor]:
238
+ ###############################################################
239
+ # gradient-based forces/stress
240
+ ###############################################################
241
+ data_dict["atomic_numbers"] = data_dict["atomic_numbers"].long()
242
+
243
+ displacement = None
244
+ orig_cell = None
245
+ if self.regress_stress and not self.direct_forces:
246
+ displacement = torch.zeros(
247
+ (3, 3),
248
+ dtype=data_dict["pos"].dtype,
249
+ device=data_dict["pos"].device,
250
+ )
251
+ # num_batch = data_dict["num_graphs"]
252
+ num_batch = data_dict.get("num_graphs", len(data_dict["natoms"]))
253
+ displacement = displacement.view(-1, 3, 3).expand(num_batch, 3, 3)
254
+ displacement.requires_grad_(True)
255
+ symmetric_displacement = 0.5 * (
256
+ displacement + displacement.transpose(-1, -2)
257
+ )
258
+
259
+ data_dict["pos"].requires_grad_(True)
260
+ data_dict["pos"] = data_dict["pos"] + torch.bmm(
261
+ data_dict["pos"].unsqueeze(-2),
262
+ torch.index_select(symmetric_displacement, 0, data_dict["batch"]),
263
+ ).squeeze(-2)
264
+
265
+ orig_cell = data_dict["cell"]
266
+ data_dict["cell"] = data_dict["cell"] + torch.bmm(
267
+ data_dict["cell"], symmetric_displacement
268
+ )
269
+
270
+ if not self.regress_stress and self.regress_forces and not self.direct_forces:
271
+ data_dict["pos"].requires_grad_(True)
272
+
273
+ if self.otf_graph:
274
+ graph_dict = self.generate_graph(data_dict)
275
+ else:
276
+ cell_per_edge = data_dict["cell"].repeat_interleave(
277
+ data_dict["nedges"], dim=0
278
+ )
279
+ shifts = torch.einsum(
280
+ "ij,ijk->ik",
281
+ data_dict["cell_offsets"].to(cell_per_edge.dtype),
282
+ cell_per_edge,
283
+ )
284
+ edge_distance_vec = (
285
+ data_dict["pos"][data_dict["edge_index"][0]]
286
+ - data_dict["pos"][data_dict["edge_index"][1]]
287
+ + shifts
288
+ )
289
+ # pylint: disable=E1102
290
+ edge_distance = torch.linalg.norm(edge_distance_vec, dim=-1, keepdim=False)
291
+ graph_dict = {
292
+ "atomic_numbers_full": data_dict["atomic_numbers_full"],
293
+ "batch_full": data_dict["batch_full"],
294
+ "edge_index": data_dict["edge_index"],
295
+ "edge_distance": edge_distance,
296
+ "edge_distance_vec": edge_distance_vec,
297
+ "node_offset": 0,
298
+ }
299
+
300
+ _, wigner, wigner_inv = self.get_rotmat_and_wigner(
301
+ graph_dict["edge_distance_vec"]
302
+ )
303
+
304
+ ###############################################################
305
+ # Initialize node embeddings
306
+ ###############################################################
307
+
308
+ x_message = torch.zeros(
309
+ data_dict["pos"].shape[0],
310
+ self.sph_feature_size,
311
+ self.sphere_channels,
312
+ device=data_dict["pos"].device,
313
+ dtype=data_dict["pos"].dtype,
314
+ )
315
+ x_message[:, 0, :] = self.sphere_embedding(data_dict["atomic_numbers"])
316
+
317
+ ##################
318
+ ### DeNS Start ###
319
+ ##################
320
+
321
+ num_atoms = len(data_dict.atomic_numbers)
322
+ # Node-wise force encoding during denoising positions
323
+ if (
324
+ hasattr(data_dict, "denoising_pos_forward")
325
+ and data_dict.denoising_pos_forward
326
+ ):
327
+ assert hasattr(data_dict, "forces")
328
+ force_data = data_dict.forces
329
+ force_sh = o3.spherical_harmonics(
330
+ l=self.irreps_sh,
331
+ x=force_data,
332
+ normalize=True,
333
+ normalization="component",
334
+ )
335
+ force_sh = force_sh.view(num_atoms, (self.lmax + 1) ** 2, 1)
336
+ force_norm = force_data.norm(dim=-1, keepdim=True)
337
+ if hasattr(data_dict, "noise_mask"):
338
+ noise_mask_tensor = data_dict.noise_mask.view(-1, 1, 1)
339
+ force_sh = force_sh * noise_mask_tensor
340
+ else:
341
+ force_sh = torch.zeros(
342
+ (num_atoms, (self.lmax + 1) ** 2, 1),
343
+ dtype=data_dict.pos.dtype,
344
+ device=data_dict.pos.device,
345
+ )
346
+ force_norm = torch.zeros(
347
+ (num_atoms, 1), dtype=data_dict.pos.dtype, device=data_dict.pos.device
348
+ )
349
+
350
+ force_norm = force_norm.view(-1, 1, 1)
351
+ force_norm = force_norm / math.sqrt(
352
+ 3.0
353
+ ) # since we use `component` normalization
354
+ force_embedding = force_sh * force_norm
355
+
356
+ force_embedding = self.force_embedding(force_embedding)
357
+ x_message = x_message + force_embedding
358
+
359
+ ##################
360
+ ### DeNS End ###
361
+ ##################
362
+
363
+ # edge degree embedding
364
+ edge_distance_embedding = self.distance_expansion(graph_dict["edge_distance"])
365
+ source_embedding = self.source_embedding(
366
+ data_dict["atomic_numbers"][graph_dict["edge_index"][0]]
367
+ )
368
+ target_embedding = self.target_embedding(
369
+ data_dict["atomic_numbers"][graph_dict["edge_index"][1]]
370
+ )
371
+ x_edge = torch.cat(
372
+ (edge_distance_embedding, source_embedding, target_embedding), dim=1
373
+ )
374
+ x_message = self.edge_degree_embedding(
375
+ x_message,
376
+ x_edge,
377
+ graph_dict["edge_distance"],
378
+ graph_dict["edge_index"],
379
+ wigner_inv,
380
+ )
381
+
382
+ ###############################################################
383
+ # Update spherical node embeddings
384
+ ###############################################################
385
+ if graph_dict["edge_index"].shape[1] != 0:
386
+ for i in range(self.num_layers):
387
+ if self.activation_checkpointing:
388
+ x_message = torch.utils.checkpoint.checkpoint(
389
+ self.blocks[i],
390
+ x_message,
391
+ x_edge,
392
+ graph_dict["edge_distance"],
393
+ graph_dict["edge_index"],
394
+ wigner,
395
+ wigner_inv,
396
+ graph_dict["node_offset"],
397
+ use_reentrant=False,
398
+ )
399
+ else:
400
+ x_message = self.blocks[i](
401
+ x_message,
402
+ x_edge,
403
+ graph_dict["edge_distance"],
404
+ graph_dict["edge_index"],
405
+ wigner,
406
+ wigner_inv,
407
+ node_offset=graph_dict["node_offset"],
408
+ )
409
+
410
+ # Final layer norm
411
+ x_message = self.norm(x_message)
412
+
413
+ out = {
414
+ "node_embedding": x_message,
415
+ "displacement": displacement,
416
+ "orig_cell": orig_cell,
417
+ }
418
+ out.update(graph_dict)
419
+
420
+ return out
421
+
422
+ @property
423
+ def num_params(self):
424
+ return sum(p.numel() for p in self.parameters())
425
+
426
+ @torch.jit.ignore
427
+ def no_weight_decay(self) -> set:
428
+ no_wd_list = []
429
+ named_parameters_list = [name for name, _ in self.named_parameters()]
430
+ for module_name, module in self.named_modules():
431
+ if isinstance(
432
+ module,
433
+ (
434
+ torch.nn.Linear,
435
+ SO3_Linear,
436
+ torch.nn.LayerNorm,
437
+ EquivariantLayerNormArray,
438
+ EquivariantLayerNormArraySphericalHarmonics,
439
+ EquivariantRMSNormArraySphericalHarmonicsV2,
440
+ ),
441
+ ):
442
+ for parameter_name, _ in module.named_parameters():
443
+ if (
444
+ isinstance(module, (torch.nn.Linear, SO3_Linear))
445
+ and "weight" in parameter_name
446
+ ):
447
+ continue
448
+ global_parameter_name = module_name + "." + parameter_name
449
+ assert global_parameter_name in named_parameters_list
450
+ no_wd_list.append(global_parameter_name)
451
+
452
+ return set(no_wd_list)
453
+
454
+
455
+ @registry.register_model("esen_mlp_efs_head_dens")
456
+ class MLP_EFS_Head(nn.Module, HeadInterface):
457
+ def __init__(self, backbone):
458
+ super().__init__()
459
+ backbone.energy_block = None
460
+ backbone.force_block = None
461
+ self.regress_stress = backbone.regress_stress
462
+ self.regress_forces = backbone.regress_forces
463
+ self.denoising_pos = True
464
+
465
+ self.sphere_channels = backbone.sphere_channels
466
+ self.hidden_channels = backbone.hidden_channels
467
+ self.energy_block = nn.Sequential(
468
+ nn.Linear(self.sphere_channels, self.hidden_channels, bias=True),
469
+ nn.SiLU(),
470
+ nn.Linear(self.hidden_channels, self.hidden_channels, bias=True),
471
+ nn.SiLU(),
472
+ nn.Linear(self.hidden_channels, 1, bias=True),
473
+ )
474
+
475
+ self.denoising_linear = SO3_Linear(backbone.sphere_channels, 1, lmax=1)
476
+
477
+ backbone.direct_forces = False
478
+
479
+ def delete_denoising_head(self):
480
+ self.denoising_linear = None
481
+ self.denoising_pos = False
482
+
483
+ @conditional_grad(torch.enable_grad())
484
+ def forward(self, data, emb: dict[str, torch.Tensor]) -> dict[str, torch.Tensor]:
485
+ energy_key = "energy"
486
+ forces_key = "forces"
487
+ stress_key = "stress"
488
+
489
+ outputs = {}
490
+
491
+ node_energy = self.energy_block(
492
+ emb["node_embedding"].narrow(1, 0, 1).squeeze()
493
+ ).view(-1, 1, 1)
494
+
495
+ energy = torch.zeros(
496
+ len(data["natoms"]), device=data["pos"].device, dtype=node_energy.dtype
497
+ )
498
+ energy.index_add_(0, data["batch"], node_energy.view(-1))
499
+
500
+ # no train on noisy energy for conserved
501
+ if hasattr(data, "denoising_pos_forward") and data.denoising_pos_forward:
502
+ energy = energy * 0.0
503
+
504
+ outputs[energy_key] = energy
505
+
506
+ if self.regress_stress:
507
+ grads = torch.autograd.grad(
508
+ [energy.sum()],
509
+ [data["pos"], emb["displacement"]],
510
+ create_graph=self.training,
511
+ )
512
+ forces = torch.neg(grads[0])
513
+ virial = grads[1].view(-1, 3, 3)
514
+ volume = torch.det(data["cell"]).abs().unsqueeze(-1)
515
+ stress = virial / volume.view(-1, 1, 1)
516
+ virial = torch.neg(virial)
517
+ outputs[forces_key] = forces
518
+ outputs[stress_key] = stress.view(
519
+ -1, 9
520
+ ) # NOTE to work better with current Multi-task trainer
521
+ data["cell"] = emb["orig_cell"]
522
+ elif self.regress_forces:
523
+ forces = (
524
+ -1
525
+ * torch.autograd.grad(
526
+ energy.sum(), data["pos"], create_graph=self.training
527
+ )[0]
528
+ )
529
+
530
+ if self.denoising_pos:
531
+ denoising_pos_vec = self.denoising_linear(
532
+ emb["node_embedding"].narrow(1, 0, 4)
533
+ )
534
+ denoising_pos_vec = denoising_pos_vec.narrow(1, 1, 3)
535
+ denoising_pos_vec = denoising_pos_vec.view(-1, 3).contiguous()
536
+
537
+ if hasattr(data, "denoising_pos_forward") and data.denoising_pos_forward:
538
+ if hasattr(data, "noise_mask"):
539
+ noise_mask_tensor = data.noise_mask.view(-1, 1)
540
+ forces = denoising_pos_vec * noise_mask_tensor + forces * (
541
+ ~noise_mask_tensor
542
+ )
543
+ else:
544
+ forces = denoising_pos_vec + 0 * forces
545
+ else:
546
+ forces = 0 * denoising_pos_vec + forces
547
+
548
+ outputs[forces_key] = forces
549
+
550
+ return outputs
551
+
552
+
553
+ @registry.register_model("esen_linear_force_head_dens")
554
+ class Linear_Force_Head_DeNS(nn.Module, HeadInterface):
555
+ def __init__(self, backbone):
556
+ super().__init__()
557
+ self.linear = SO3_Linear(backbone.sphere_channels, 1, lmax=1)
558
+ self.denoising_linear = SO3_Linear(backbone.sphere_channels, 1, lmax=1)
559
+
560
+ def forward(self, data_dict, emb: dict[str, torch.Tensor]):
561
+ forces = self.linear(emb["node_embedding"].narrow(1, 0, 4))
562
+ denoising_pos_vec = self.denoising_linear(emb["node_embedding"].narrow(1, 0, 4))
563
+ forces = forces.narrow(1, 1, 3)
564
+ forces = forces.view(-1, 3).contiguous()
565
+ denoising_pos_vec = denoising_pos_vec.narrow(1, 1, 3)
566
+ denoising_pos_vec = denoising_pos_vec.view(-1, 3).contiguous()
567
+
568
+ if (
569
+ hasattr(data_dict, "denoising_pos_forward")
570
+ and data_dict.denoising_pos_forward
571
+ ):
572
+ if hasattr(data_dict, "noise_mask"):
573
+ noise_mask_tensor = data_dict.noise_mask.view(-1, 1)
574
+ forces = denoising_pos_vec * noise_mask_tensor + forces * (
575
+ ~noise_mask_tensor
576
+ )
577
+ else:
578
+ forces = denoising_pos_vec + 0 * forces
579
+ else:
580
+ forces = 0 * denoising_pos_vec + forces
581
+
582
+ return {"forces": forces}
model/graph.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Graph helpers shared by the OneScience eSEN backbones."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from types import SimpleNamespace
6
+
7
+ import torch
8
+
9
+ from onescience.modules.func_utils.uma_graph.compute import generate_graph
10
+
11
+
12
+ class GraphModelMixin:
13
+ """Provide FairChem-v1 compatible graph output using OneScience graph code."""
14
+
15
+ def generate_graph(self, data, cutoff=None, max_neighbors=None, **kwargs):
16
+ cutoff = cutoff or self.cutoff
17
+ max_neighbors = max_neighbors or self.max_neighbors
18
+ pbc = kwargs.pop("pbc", None)
19
+ if pbc is None:
20
+ pbc = getattr(data, "pbc", None)
21
+ if pbc is None:
22
+ pbc = torch.ones(
23
+ (data.natoms.numel(), 3), dtype=torch.bool, device=data.pos.device
24
+ )
25
+ elif pbc.ndim == 1:
26
+ pbc = pbc.view(1, 3).expand(data.natoms.numel(), -1)
27
+
28
+ graph = generate_graph(
29
+ data,
30
+ cutoff=cutoff,
31
+ max_neighbors=max_neighbors,
32
+ enforce_max_neighbors_strictly=getattr(
33
+ self, "enforce_max_neighbors_strictly", False
34
+ ),
35
+ radius_pbc_version=getattr(self, "radius_pbc_version", 1),
36
+ pbc=pbc,
37
+ )
38
+ graph.update(
39
+ batch_full=data.batch,
40
+ atomic_numbers_full=data.atomic_numbers,
41
+ node_offset=0,
42
+ )
43
+ return SimpleNamespace(**graph)
prepare_oxide_dataset.py ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Build reproducible ASE DB splits from FairChem's oxide tutorial data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import random
8
+ from collections import Counter
9
+ from pathlib import Path
10
+
11
+ import numpy as np
12
+ from ase import Atoms, units
13
+ from ase.calculators.singlepoint import SinglePointCalculator
14
+ from ase.db import connect
15
+
16
+
17
+ def load_records(path: Path):
18
+ with path.open() as handle:
19
+ source = json.load(handle)
20
+ records = []
21
+ for oxide, polymorphs in source.items():
22
+ for polymorph, calculations in polymorphs.items():
23
+ for calculation in calculations.get("PBE", {}).get("EOS", {}).get(
24
+ "calculations", []
25
+ ):
26
+ records.append((oxide, polymorph, calculation))
27
+ return records
28
+
29
+
30
+ def split_groups(records, seed: int):
31
+ groups = sorted({(oxide, polymorph) for oxide, polymorph, _ in records})
32
+ random.Random(seed).shuffle(groups)
33
+ n_train = int(0.8 * len(groups))
34
+ n_val = int(0.1 * len(groups))
35
+ split_for = {group: "train" for group in groups[:n_train]}
36
+ split_for.update({group: "val" for group in groups[n_train : n_train + n_val]})
37
+ split_for.update({group: "test" for group in groups[n_train + n_val :]})
38
+ return split_for
39
+
40
+
41
+ def to_atoms(oxide: str, polymorph: str, calculation: dict) -> Atoms:
42
+ structure = calculation["atoms"]
43
+ results = calculation["data"]
44
+ atoms = Atoms(
45
+ symbols=structure["symbols"],
46
+ positions=structure["positions"],
47
+ cell=structure["cell"],
48
+ pbc=structure["pbc"],
49
+ )
50
+ atoms.set_tags(np.ones(len(atoms), dtype=int))
51
+ # ASE stress uses eV/Angstrom^3. The source JSON documents stress in GPa.
52
+ stress = np.asarray(results["stress"], dtype=float) * units.GPa
53
+ atoms.calc = SinglePointCalculator(
54
+ atoms,
55
+ energy=float(results["total_energy"]),
56
+ forces=np.asarray(results["forces"], dtype=float),
57
+ stress=stress,
58
+ )
59
+ atoms.info.update({"oxide": oxide, "polymorph": polymorph, "xc": "PBE"})
60
+ return atoms
61
+
62
+
63
+ def main() -> None:
64
+ parser = argparse.ArgumentParser(description=__doc__)
65
+ parser.add_argument("--input", required=True, type=Path)
66
+ parser.add_argument("--output", required=True, type=Path)
67
+ parser.add_argument("--seed", type=int, default=42)
68
+ parser.add_argument(
69
+ "--overwrite", action="store_true", help="Replace existing split databases"
70
+ )
71
+ args = parser.parse_args()
72
+
73
+ records = load_records(args.input)
74
+ if not records:
75
+ raise ValueError(f"No PBE EOS structures found in {args.input}")
76
+ args.output.mkdir(parents=True, exist_ok=True)
77
+ paths = {split: args.output / f"{split}.db" for split in ("train", "val", "test")}
78
+ existing = [str(path) for path in paths.values() if path.exists()]
79
+ if existing and not args.overwrite:
80
+ raise FileExistsError("Output exists; pass --overwrite to replace: " + ", ".join(existing))
81
+ for path in paths.values():
82
+ if path.exists():
83
+ path.unlink()
84
+
85
+ split_for = split_groups(records, args.seed)
86
+ counts = Counter()
87
+ databases = {split: connect(path) for split, path in paths.items()}
88
+ for oxide, polymorph, calculation in records:
89
+ split = split_for[(oxide, polymorph)]
90
+ databases[split].write(
91
+ to_atoms(oxide, polymorph, calculation),
92
+ oxide=oxide,
93
+ polymorph=polymorph,
94
+ xc="PBE",
95
+ )
96
+ counts[split] += 1
97
+
98
+ manifest = {
99
+ "source": str(args.input.resolve()),
100
+ "seed": args.seed,
101
+ "split_strategy": "oxide-polymorph grouped 80/10/10",
102
+ "stress_source_unit": "GPa",
103
+ "stress_output_unit": "eV/Angstrom^3",
104
+ "counts": dict(counts),
105
+ "groups": {f"{oxide}/{polymorph}": split for (oxide, polymorph), split in sorted(split_for.items())},
106
+ }
107
+ with (args.output / "manifest.json").open("w") as handle:
108
+ json.dump(manifest, handle, indent=2, sort_keys=True)
109
+ print(json.dumps(manifest["counts"], sort_keys=True))
110
+ print(f"saved dataset: {args.output.resolve()}")
111
+
112
+
113
+ if __name__ == "__main__":
114
+ main()
relax.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Relax a periodic structure with an eSEN ASE calculator."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+
8
+ os.environ.setdefault(
9
+ "ONESCIENCE_ESEN_JD_PATH",
10
+ os.path.join(os.path.dirname(__file__), "weight", "Jd.pt"),
11
+ )
12
+
13
+ from ase.build import bulk
14
+ from ase.filters import FrechetCellFilter
15
+ from ase.io import read, write
16
+ from ase.optimize import BFGS
17
+
18
+ from onescience.utils.esen import eSENCalculator
19
+
20
+
21
+ def default_checkpoint() -> str:
22
+ return os.path.join(os.path.dirname(__file__), "weight", "esen_30m_mptrj.pt")
23
+
24
+
25
+ def load_structure(path: str | None):
26
+ if path:
27
+ return read(path)
28
+ return bulk("Si", "diamond", a=5.50)
29
+
30
+
31
+ def main() -> None:
32
+ parser = argparse.ArgumentParser(description=__doc__)
33
+ parser.add_argument("--checkpoint", default=default_checkpoint())
34
+ parser.add_argument("--input", help="CIF, POSCAR, XYZ, or another ASE-readable structure")
35
+ parser.add_argument("--fmax", type=float, default=0.05)
36
+ parser.add_argument("--steps", type=int, default=100)
37
+ parser.add_argument("--output", default="relaxed.cif")
38
+ parser.add_argument(
39
+ "--fixed-cell",
40
+ action="store_true",
41
+ help="relax atomic positions only; by default the cell is relaxed too",
42
+ )
43
+ parser.add_argument("--device", default="cuda")
44
+ args = parser.parse_args()
45
+
46
+ atoms = load_structure(args.input)
47
+ if not args.fixed_cell and not atoms.pbc.all():
48
+ parser.error("cell relaxation requires periodic boundaries; use --fixed-cell")
49
+ atoms.calc = eSENCalculator.from_checkpoint(args.checkpoint, device=args.device)
50
+ target = atoms if args.fixed_cell else FrechetCellFilter(atoms)
51
+ optimizer = BFGS(target, logfile="relax.log", trajectory="relax.traj")
52
+ optimizer.run(fmax=args.fmax, steps=args.steps)
53
+ write(args.output, atoms)
54
+ print("formula:", atoms.get_chemical_formula())
55
+ print("atoms:", len(atoms))
56
+ print("steps:", optimizer.nsteps)
57
+ print("energy (eV):", atoms.get_potential_energy())
58
+ print("maximum force (eV/Angstrom):", max((atoms.get_forces() ** 2).sum(1) ** 0.5))
59
+
60
+
61
+ if __name__ == "__main__":
62
+ main()
single_point.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Run one eSEN single-point calculation through ASE."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+
8
+ os.environ.setdefault(
9
+ "ONESCIENCE_ESEN_JD_PATH",
10
+ os.path.join(os.path.dirname(__file__), "weight", "Jd.pt"),
11
+ )
12
+
13
+ from ase.build import bulk
14
+ from ase.io import read
15
+
16
+ from onescience.utils.esen import eSENCalculator
17
+
18
+
19
+ def default_checkpoint() -> str:
20
+ return os.path.join(os.path.dirname(__file__), "weight", "esen_30m_mptrj.pt")
21
+
22
+
23
+ def load_structure(path: str | None):
24
+ if path:
25
+ return read(path)
26
+ return bulk("Si", "diamond", a=5.43)
27
+
28
+
29
+ def main() -> None:
30
+ parser = argparse.ArgumentParser(description=__doc__)
31
+ parser.add_argument("--checkpoint", default=default_checkpoint())
32
+ parser.add_argument("--input", help="CIF, POSCAR, XYZ, or another ASE-readable structure")
33
+ parser.add_argument("--device", default="cuda")
34
+ args = parser.parse_args()
35
+
36
+ atoms = load_structure(args.input)
37
+ atoms.calc = eSENCalculator.from_checkpoint(args.checkpoint, device=args.device)
38
+
39
+ print("formula:", atoms.get_chemical_formula())
40
+ print("atoms:", len(atoms))
41
+ print("energy (eV):", atoms.get_potential_energy())
42
+ print("forces (eV/Angstrom):\n", atoms.get_forces())
43
+ print("stress (eV/Angstrom^3):", atoms.get_stress())
44
+
45
+
46
+ if __name__ == "__main__":
47
+ main()
weight/Jd.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b4059c45be246dcb6c49c545670b65c56550eb0c2e7a9c92b4b50a92d370dbe2
3
+ size 21697