rootstock 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
rootstock/worker.py ADDED
@@ -0,0 +1,273 @@
1
+ #!/usr/bin/env python
2
+ """
3
+ Rootstock worker process.
4
+
5
+ This runs in an isolated subprocess and:
6
+ 1. Loads an MLIP (e.g., MACE)
7
+ 2. Connects to the server via Unix socket
8
+ 3. Receives positions, calculates forces, sends results back
9
+ 4. Persists across multiple calculations (no startup overhead per calculation)
10
+
11
+ The worker is spawned via a generated wrapper script that calls run_worker().
12
+ """
13
+
14
+ import json
15
+ from collections.abc import Callable
16
+ from typing import TYPE_CHECKING
17
+
18
+ import numpy as np
19
+
20
+ from .protocol import (
21
+ IPIProtocol,
22
+ SocketClosed,
23
+ connect_unix_socket,
24
+ create_unix_socket_path,
25
+ )
26
+
27
+ if TYPE_CHECKING:
28
+ from ase.calculators.calculator import Calculator
29
+
30
+
31
+ class MLIPWorker:
32
+ """
33
+ Worker that runs an MLIP calculator and communicates via i-PI protocol.
34
+
35
+ The worker acts as an i-PI client:
36
+ 1. Connect to server
37
+ 2. Report READY status
38
+ 3. Receive positions via POSDATA
39
+ 4. Calculate energy/forces
40
+ 5. Report HAVEDATA status
41
+ 6. Send results via FORCEREADY
42
+ 7. Loop back to step 2
43
+ """
44
+
45
+ def __init__(
46
+ self,
47
+ socket_name: str,
48
+ calculator: "Calculator",
49
+ log=None,
50
+ ):
51
+ """
52
+ Initialize the worker.
53
+
54
+ Args:
55
+ socket_name: Name of Unix socket to connect to
56
+ calculator: Pre-loaded ASE calculator
57
+ log: Optional file object for logging
58
+ """
59
+ self.socket_name = socket_name
60
+ self.socket_path = create_unix_socket_path(socket_name)
61
+ self.log = log
62
+
63
+ self._calculator = calculator
64
+ self._socket = None
65
+ self._protocol = None
66
+ self._atoms = None # Cache ASE Atoms object
67
+
68
+ # Atomic species info from INIT message
69
+ self._atomic_numbers: list[int] | None = None
70
+ self._pbc: list[bool] | None = None
71
+
72
+ def _log(self, msg):
73
+ if self.log:
74
+ print(f"[Worker] {msg}", file=self.log, flush=True)
75
+
76
+ def _connect(self):
77
+ """Connect to the server."""
78
+ self._log(f"Connecting to {self.socket_path}")
79
+ self._socket = connect_unix_socket(self.socket_path)
80
+ self._protocol = IPIProtocol(self._socket, log=self.log)
81
+ self._log("Connected")
82
+
83
+ def _create_atoms(self, positions: np.ndarray, cell: np.ndarray):
84
+ """
85
+ Create or update ASE Atoms object.
86
+
87
+ On first call, creates a new Atoms object.
88
+ On subsequent calls, updates positions and cell in place.
89
+ """
90
+ from ase import Atoms
91
+
92
+ if self._atoms is None or len(self._atoms) != len(positions):
93
+ # Need to create new Atoms object
94
+ if self._atomic_numbers is None:
95
+ raise RuntimeError(
96
+ "No atomic numbers received. Server must send INIT with species data."
97
+ )
98
+
99
+ self._atoms = Atoms(
100
+ numbers=self._atomic_numbers,
101
+ positions=positions,
102
+ cell=cell,
103
+ pbc=self._pbc if self._pbc is not None else [True, True, True],
104
+ )
105
+ self._atoms.calc = self._calculator
106
+ else:
107
+ # Update existing object (faster - reuses neighbor lists etc.)
108
+ self._atoms.positions = positions
109
+ self._atoms.cell = cell
110
+
111
+ return self._atoms
112
+
113
+ def _calculate(
114
+ self, positions: np.ndarray, cell: np.ndarray
115
+ ) -> tuple[float, np.ndarray, np.ndarray]:
116
+ """
117
+ Run MLIP calculation.
118
+
119
+ Returns:
120
+ energy: Potential energy in eV
121
+ forces: Nx3 forces in eV/Angstrom
122
+ virial: 3x3 virial tensor in eV
123
+ """
124
+ atoms = self._create_atoms(positions, cell)
125
+
126
+ energy = atoms.get_potential_energy()
127
+ forces = atoms.get_forces()
128
+
129
+ # Calculate virial from stress
130
+ # stress is in eV/ų, virial = -stress * volume
131
+ try:
132
+ stress = atoms.get_stress(voigt=False) # 3x3 tensor
133
+ volume = atoms.get_volume()
134
+ virial = -stress * volume
135
+ except Exception:
136
+ # Some calculators don't support stress
137
+ virial = np.zeros((3, 3))
138
+
139
+ return energy, forces, virial
140
+
141
+ def run(self):
142
+ """
143
+ Main loop - receive positions, calculate, send results.
144
+
145
+ This implements the i-PI client state machine:
146
+ - NEEDINIT -> receive INIT -> READY
147
+ - READY -> receive POSDATA -> calculate -> HAVEDATA
148
+ - HAVEDATA -> receive GETFORCE -> send FORCEREADY -> NEEDINIT
149
+ """
150
+ self._connect()
151
+
152
+ state = "NEEDINIT"
153
+ energy = None
154
+ forces = None
155
+ virial = None
156
+
157
+ self._log("Entering main loop")
158
+
159
+ try:
160
+ while True:
161
+ # Wait for message from server
162
+ try:
163
+ msg = self._protocol.recvmsg()
164
+ except SocketClosed:
165
+ self._log("Server closed connection")
166
+ break
167
+
168
+ if msg == "EXIT":
169
+ self._log("Received EXIT, shutting down")
170
+ break
171
+
172
+ elif msg == "STATUS":
173
+ # Report current state
174
+ if state == "NEEDINIT":
175
+ self._protocol.sendmsg("NEEDINIT")
176
+ elif state == "READY":
177
+ self._protocol.sendmsg("READY")
178
+ elif state == "HAVEDATA":
179
+ self._protocol.sendmsg("HAVEDATA")
180
+
181
+ elif msg == "INIT":
182
+ # Receive initialization with atomic species info
183
+ bead_index, init_bytes = self._protocol.recv_init()
184
+
185
+ # Parse JSON from init_bytes
186
+ if init_bytes and init_bytes != b"\x00":
187
+ try:
188
+ init_data = json.loads(init_bytes.decode("utf-8"))
189
+ self._atomic_numbers = init_data.get("numbers")
190
+ self._pbc = init_data.get("pbc", [True, True, True])
191
+ self._log(
192
+ f"Received INIT (bead={bead_index}, "
193
+ f"atoms={len(self._atomic_numbers) if self._atomic_numbers else 0})"
194
+ )
195
+ except (json.JSONDecodeError, UnicodeDecodeError) as e:
196
+ self._log(f"Warning: Failed to parse INIT data: {e}")
197
+ else:
198
+ self._log(f"Received INIT (bead={bead_index}, no species data)")
199
+
200
+ state = "READY"
201
+
202
+ elif msg == "POSDATA":
203
+ # Receive atomic positions
204
+ if state not in ("READY", "NEEDINIT"):
205
+ self._log(f"Warning: POSDATA in state {state}")
206
+
207
+ cell, positions = self._protocol.recv_posdata()
208
+ self._log(f"Received POSDATA: {len(positions)} atoms")
209
+
210
+ # Calculate energy and forces
211
+ energy, forces, virial = self._calculate(positions, cell)
212
+ self._log(f"Calculated: E={energy:.6f} eV")
213
+
214
+ state = "HAVEDATA"
215
+
216
+ elif msg == "GETFORCE":
217
+ # Send results
218
+ if state != "HAVEDATA":
219
+ raise RuntimeError(f"GETFORCE in state {state}")
220
+
221
+ self._protocol.send_forceready(energy, forces, virial)
222
+ self._log("Sent FORCEREADY")
223
+
224
+ state = "NEEDINIT"
225
+
226
+ else:
227
+ self._log(f"Unknown message: {msg}")
228
+
229
+ finally:
230
+ if self._socket:
231
+ self._socket.close()
232
+ self._log("Worker shutdown complete")
233
+
234
+
235
+ def run_worker(
236
+ setup_fn: Callable[[str, str], "Calculator"],
237
+ model: str,
238
+ device: str,
239
+ socket_path: str,
240
+ log=None,
241
+ ):
242
+ """
243
+ Run worker with a provided setup function.
244
+
245
+ This is the entry point used by generated wrapper scripts.
246
+ The setup function is called once to create the calculator, which
247
+ is then reused for all subsequent calculations.
248
+
249
+ Args:
250
+ setup_fn: Function that takes (model, device) and returns an ASE calculator
251
+ model: Model identifier to pass to setup_fn
252
+ device: Device string to pass to setup_fn
253
+ socket_path: Full Unix socket path to connect to
254
+ log: Optional logging file object
255
+ """
256
+ if log:
257
+ print(f"[Worker] Calling setup({model!r}, {device!r})", file=log, flush=True)
258
+
259
+ # Load calculator via the setup function
260
+ calculator = setup_fn(model, device)
261
+
262
+ if log:
263
+ print(f"[Worker] Calculator loaded: {type(calculator).__name__}", file=log, flush=True)
264
+
265
+ # Extract socket name from path (e.g., /tmp/ipi_rootstock_abc -> rootstock_abc)
266
+ socket_name = socket_path.replace("/tmp/ipi_", "")
267
+
268
+ worker = MLIPWorker(
269
+ socket_name=socket_name,
270
+ calculator=calculator,
271
+ log=log,
272
+ )
273
+ worker.run()
@@ -0,0 +1,210 @@
1
+ Metadata-Version: 2.4
2
+ Name: rootstock
3
+ Version: 0.5.0
4
+ Summary: MLIP calculators with isolated Python environments
5
+ License-File: LICENSE.md
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: ase>=3.22
8
+ Requires-Dist: numpy>=1.24
9
+ Requires-Dist: packaging>=21.0
10
+ Requires-Dist: tomli>=2.0; python_version < '3.11'
11
+ Provides-Extra: dev
12
+ Requires-Dist: pytest>=7.0; extra == 'dev'
13
+ Requires-Dist: ruff>=0.1; extra == 'dev'
14
+ Provides-Extra: mace
15
+ Requires-Dist: mace-torch>=0.3; extra == 'mace'
16
+ Requires-Dist: torch>=2.0; extra == 'mace'
17
+ Provides-Extra: modal
18
+ Requires-Dist: modal>=0.56; extra == 'modal'
19
+ Description-Content-Type: text/markdown
20
+
21
+ # Rootstock
22
+
23
+ Run MLIP (Machine Learning Interatomic Potential) calculators in isolated pre-built Python environments, communicating via the i-PI protocol over Unix sockets.
24
+
25
+ ## Quick Start
26
+
27
+ ```python
28
+ from ase.build import bulk
29
+ from rootstock import RootstockCalculator
30
+
31
+ atoms = bulk("Cu", "fcc", a=3.6) * (5, 5, 5)
32
+
33
+ # Using a known cluster
34
+ with RootstockCalculator(
35
+ cluster="modal", # or "della"
36
+ model="mace-medium", # or "chgnet", "mace-small", etc.
37
+ device="cuda",
38
+ ) as calc:
39
+ atoms.calc = calc
40
+ print(atoms.get_potential_energy())
41
+ print(atoms.get_forces())
42
+
43
+ # Or with an explicit root path
44
+ with RootstockCalculator(
45
+ root="/scratch/gpfs/SHARED/rootstock",
46
+ model="mace-medium",
47
+ device="cuda",
48
+ ) as calc:
49
+ atoms.calc = calc
50
+ print(atoms.get_potential_energy())
51
+ ```
52
+
53
+ **Note:** Environments must be pre-built before use. See [Administrator Setup](#administrator-setup).
54
+
55
+ ## Installation
56
+
57
+ ```bash
58
+ pip install rootstock
59
+ # or
60
+ uv pip install rootstock
61
+ ```
62
+
63
+ ## Model String Format
64
+
65
+ The `model` parameter encodes both the environment and model-specific argument:
66
+
67
+ | `model=` | Environment | Model Arg |
68
+ |---------------------|----------------|---------------------|
69
+ | `"mace-medium"` | mace_env | `"medium"` |
70
+ | `"mace-small"` | mace_env | `"small"` |
71
+ | `"mace-large"` | mace_env | `"large"` |
72
+ | `"chgnet"` | chgnet_env | `""` (default) |
73
+ | `"mace-/path/to/weights.pt"` | mace_env | `"/path/to/weights.pt"` |
74
+
75
+ ## Known Clusters
76
+
77
+ | Cluster | Root Path |
78
+ |---------|-----------|
79
+ | `modal` | `/vol/rootstock` |
80
+ | `della` | `/scratch/gpfs/SHARED/rootstock` |
81
+
82
+ For other clusters, use `root="/path/to/rootstock"` directly.
83
+
84
+ ## Administrator Setup
85
+
86
+ Environments must be pre-built before users can run calculations.
87
+
88
+ ### 1. Create Directory Structure
89
+
90
+ ```bash
91
+ mkdir -p /scratch/gpfs/SHARED/rootstock/{environments,envs,cache}
92
+ ```
93
+
94
+ ### 2. Create Environment Source Files
95
+
96
+ ```bash
97
+ # mace_env.py
98
+ cat > /scratch/gpfs/SHARED/rootstock/environments/mace_env.py << 'EOF'
99
+ # /// script
100
+ # requires-python = ">=3.10"
101
+ # dependencies = ["mace-torch>=0.3.0", "ase>=3.22", "torch>=2.0"]
102
+ # ///
103
+ """MACE environment for Rootstock."""
104
+
105
+ def setup(model: str, device: str = "cuda"):
106
+ from mace.calculators import mace_mp
107
+ return mace_mp(model=model, device=device, default_dtype="float32")
108
+ EOF
109
+ ```
110
+
111
+ ### 3. Build Environments
112
+
113
+ ```bash
114
+ # Build MACE environment with model pre-download
115
+ rootstock build mace_env --root /scratch/gpfs/SHARED/rootstock --models small,medium,large
116
+
117
+ # Build CHGNet environment
118
+ rootstock build chgnet_env --root /scratch/gpfs/SHARED/rootstock
119
+
120
+ # Verify
121
+ rootstock status --root /scratch/gpfs/SHARED/rootstock
122
+ ```
123
+
124
+ ## Architecture
125
+
126
+ ```
127
+ Main Process Worker Process (subprocess)
128
+ +-------------------------+ +-----------------------------+
129
+ | RootstockCalculator | | Pre-built venv Python |
130
+ | (ASE-compatible) | | (mace_env/bin/python) |
131
+ | | | |
132
+ | server.py (i-PI server) |<-------->| worker.py (i-PI client) |
133
+ | - sends positions | Unix | - receives positions |
134
+ | - receives forces | socket | - calculates forces |
135
+ +-------------------------+ +-----------------------------+
136
+ ```
137
+
138
+ The worker process uses a pre-built virtual environment, providing:
139
+ - **Fast startup**: No dependency installation at runtime
140
+ - **Filesystem compatibility**: Works on NFS, Lustre, GPFS, Modal volumes
141
+ - **Reproducibility**: Same environment every time
142
+
143
+ ## Directory Structure
144
+
145
+ ```
146
+ {root}/
147
+ ├── environments/ # Environment SOURCE files (*.py with PEP 723)
148
+ │ ├── mace_env.py
149
+ │ └── chgnet_env.py
150
+ ├── envs/ # Pre-built virtual environments
151
+ │ ├── mace_env/
152
+ │ │ ├── bin/python
153
+ │ │ ├── lib/python3.11/site-packages/
154
+ │ │ └── env_source.py # Copy of environment source
155
+ │ └── chgnet_env/
156
+ └── cache/ # XDG_CACHE_HOME for model weights
157
+ ├── mace/ # MACE models
158
+ └── huggingface/ # HuggingFace models
159
+ ```
160
+
161
+ ## CLI Commands
162
+
163
+ ```bash
164
+ # Build a pre-built environment
165
+ rootstock build <env_name> --root <path> [--models m1,m2] [--force]
166
+
167
+ # Show status
168
+ rootstock status --root <path>
169
+
170
+ # Register an environment source file
171
+ rootstock register <env_file> --root <path>
172
+
173
+ # List environments
174
+ rootstock list --root <path>
175
+ ```
176
+
177
+ ## Running on Modal
178
+
179
+ ```bash
180
+ # Initialize volume and build environments (takes ~10-15 min)
181
+ modal run modal_app.py::init_rootstock_volume
182
+
183
+ # Test pre-built environments
184
+ modal run modal_app.py::test_prebuilt
185
+
186
+ # Show status
187
+ modal run modal_app.py::inspect_status
188
+
189
+ # Run benchmarks
190
+ modal run modal_app.py::benchmark_v4
191
+ ```
192
+
193
+ ## Performance
194
+
195
+ IPC overhead is <5% for systems with 1000+ atoms compared to direct in-process execution.
196
+
197
+ | System Size | Atoms | Typical Overhead |
198
+ |-------------|-------|------------------|
199
+ | Small | 64 | ~10-15% |
200
+ | Medium | 256 | ~5-8% |
201
+ | Large | 1000 | <5% |
202
+
203
+ ## Local Development
204
+
205
+ ```bash
206
+ uv venv && source .venv/bin/activate
207
+ uv pip install -e ".[dev]"
208
+ ruff check rootstock/
209
+ ruff format rootstock/
210
+ ```
@@ -0,0 +1,14 @@
1
+ rootstock/__init__.py,sha256=Qu570ne-AeWn6IT4Us00iU43A0yoRlyYDygYvKHduVQ,956
2
+ rootstock/calculator.py,sha256=0KpW3coJ0ealEIZkxq9i4kZV5AxqCX1xZqDI1HkUPA4,6257
3
+ rootstock/cli.py,sha256=6YtUXs38Zc5BC0AjEaAygtmct6JAsfaGTX7Zn2K_imY,13815
4
+ rootstock/clusters.py,sha256=JQdPgBl_eyyP3nWxg_JOvD0Kh7YTVVXsNq038ZusVJg,1207
5
+ rootstock/environment.py,sha256=e7AAKd7cECwnX-KHCdfelHYVqm3xa1mIvc2MGaTJwBs,6583
6
+ rootstock/pep723.py,sha256=hgTcP7Tp8_z_DCrmX4VPAitijvujmbGmz2ng1VgNYks,4616
7
+ rootstock/protocol.py,sha256=CgfYNc0aKOkAQ-D8tvUrVO5q1mg0Xwcl2F02ZvYOJCI,10169
8
+ rootstock/server.py,sha256=H7kn1FHGGyrrPZtgRKdfagbAF-SxI89H3u85GSToYmY,9210
9
+ rootstock/worker.py,sha256=ty13OPDcKd_Zy95MAnTwa063Glz_RfUTBDja9Lxy4SM,8961
10
+ rootstock-0.5.0.dist-info/METADATA,sha256=AD3xqByFrIWK-j8LdWfAxJXFKvbsdgro7xX1x9PPp-Y,5993
11
+ rootstock-0.5.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
12
+ rootstock-0.5.0.dist-info/entry_points.txt,sha256=rPiVll-qj1wq7wZQTwSl2aF22GLnnpDz37hkPLvqlh0,49
13
+ rootstock-0.5.0.dist-info/licenses/LICENSE.md,sha256=ORJAYeKSWpOYZ89KWT8ETWFb2u6MvKK3AhrMReDMWrA,1072
14
+ rootstock-0.5.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ rootstock = rootstock.cli:main
@@ -0,0 +1,7 @@
1
+ Copyright 2026 The University of Chicago
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.