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/__init__.py +34 -0
- rootstock/calculator.py +194 -0
- rootstock/cli.py +426 -0
- rootstock/clusters.py +41 -0
- rootstock/environment.py +238 -0
- rootstock/pep723.py +172 -0
- rootstock/protocol.py +309 -0
- rootstock/server.py +287 -0
- rootstock/worker.py +273 -0
- rootstock-0.5.0.dist-info/METADATA +210 -0
- rootstock-0.5.0.dist-info/RECORD +14 -0
- rootstock-0.5.0.dist-info/WHEEL +4 -0
- rootstock-0.5.0.dist-info/entry_points.txt +2 -0
- rootstock-0.5.0.dist-info/licenses/LICENSE.md +7 -0
rootstock/__init__.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Rootstock: MLIP calculators with isolated Python environments.
|
|
3
|
+
|
|
4
|
+
This package provides ASE-compatible calculators that run MLIPs in isolated
|
|
5
|
+
subprocess environments, communicating via the i-PI protocol.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .calculator import RootstockCalculator
|
|
9
|
+
from .clusters import CLUSTER_REGISTRY, KNOWN_ENVIRONMENTS, get_root_for_cluster
|
|
10
|
+
from .environment import (
|
|
11
|
+
EnvironmentManager,
|
|
12
|
+
get_model_cache_env,
|
|
13
|
+
list_built_environments,
|
|
14
|
+
list_environments,
|
|
15
|
+
)
|
|
16
|
+
from .pep723 import parse_pep723_metadata, validate_environment_file
|
|
17
|
+
from .server import RootstockServer
|
|
18
|
+
from .worker import run_worker
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"RootstockCalculator",
|
|
22
|
+
"RootstockServer",
|
|
23
|
+
"EnvironmentManager",
|
|
24
|
+
"list_environments",
|
|
25
|
+
"list_built_environments",
|
|
26
|
+
"get_model_cache_env",
|
|
27
|
+
"parse_pep723_metadata",
|
|
28
|
+
"validate_environment_file",
|
|
29
|
+
"run_worker",
|
|
30
|
+
"CLUSTER_REGISTRY",
|
|
31
|
+
"KNOWN_ENVIRONMENTS",
|
|
32
|
+
"get_root_for_cluster",
|
|
33
|
+
]
|
|
34
|
+
__version__ = "0.5.0"
|
rootstock/calculator.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ASE-compatible calculator that delegates to an MLIP worker process.
|
|
3
|
+
|
|
4
|
+
This is the main user-facing interface for Rootstock.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import uuid
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
from ase.calculators.calculator import Calculator, all_changes
|
|
14
|
+
from ase.stress import full_3x3_to_voigt_6_stress
|
|
15
|
+
|
|
16
|
+
from .clusters import get_root_for_cluster
|
|
17
|
+
from .server import RootstockServer
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RootstockCalculator(Calculator):
|
|
21
|
+
"""
|
|
22
|
+
ASE calculator that runs MLIPs in a pre-built isolated environment.
|
|
23
|
+
|
|
24
|
+
This calculator:
|
|
25
|
+
1. Spawns a worker process using a pre-built virtual environment
|
|
26
|
+
2. Communicates via i-PI protocol over Unix sockets
|
|
27
|
+
3. Keeps the worker alive across calculations (no startup overhead)
|
|
28
|
+
|
|
29
|
+
Example:
|
|
30
|
+
from ase.build import bulk
|
|
31
|
+
from rootstock import RootstockCalculator
|
|
32
|
+
|
|
33
|
+
atoms = bulk("Cu", "fcc", a=3.6) * (5, 5, 5)
|
|
34
|
+
|
|
35
|
+
with RootstockCalculator(
|
|
36
|
+
cluster="della",
|
|
37
|
+
model="mace",
|
|
38
|
+
checkpoint="medium",
|
|
39
|
+
device="cuda",
|
|
40
|
+
) as calc:
|
|
41
|
+
atoms.calc = calc
|
|
42
|
+
print(atoms.get_potential_energy())
|
|
43
|
+
|
|
44
|
+
# Checkpoint defaults to environment's default if omitted
|
|
45
|
+
with RootstockCalculator(
|
|
46
|
+
cluster="della",
|
|
47
|
+
model="uma",
|
|
48
|
+
device="cuda",
|
|
49
|
+
) as calc:
|
|
50
|
+
atoms.calc = calc
|
|
51
|
+
print(atoms.get_potential_energy())
|
|
52
|
+
|
|
53
|
+
Note:
|
|
54
|
+
Environments must be pre-built using `rootstock build` before use.
|
|
55
|
+
Run: rootstock build mace_env --root /path/to/rootstock
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
implemented_properties = ["energy", "free_energy", "forces", "stress"]
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
model: str,
|
|
63
|
+
checkpoint: str | None = None,
|
|
64
|
+
cluster: str | None = None,
|
|
65
|
+
root: str | Path | None = None,
|
|
66
|
+
device: str = "cuda",
|
|
67
|
+
log=None,
|
|
68
|
+
**kwargs,
|
|
69
|
+
):
|
|
70
|
+
"""
|
|
71
|
+
Initialize the Rootstock calculator.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
model: Environment family name (e.g. "mace", "uma", "tensornet").
|
|
75
|
+
Maps to {model}_env environment.
|
|
76
|
+
checkpoint: Specific checkpoint/weights to load. Passed to the
|
|
77
|
+
environment's setup() as the model argument. If omitted,
|
|
78
|
+
the environment's default is used.
|
|
79
|
+
cluster: Known cluster name ("modal", "della"). Mutually exclusive with root.
|
|
80
|
+
root: Path to rootstock directory. Mutually exclusive with cluster.
|
|
81
|
+
device: PyTorch device ("cuda", "cuda:0", "cpu")
|
|
82
|
+
log: Optional file object for logging
|
|
83
|
+
**kwargs: Additional arguments passed to ASE Calculator
|
|
84
|
+
"""
|
|
85
|
+
super().__init__(**kwargs)
|
|
86
|
+
|
|
87
|
+
self.device = device
|
|
88
|
+
self.log = log
|
|
89
|
+
|
|
90
|
+
# Resolve root directory
|
|
91
|
+
if cluster is not None and root is not None:
|
|
92
|
+
raise ValueError("Cannot specify both 'cluster' and 'root'")
|
|
93
|
+
|
|
94
|
+
if cluster is not None:
|
|
95
|
+
self.root = get_root_for_cluster(cluster)
|
|
96
|
+
elif root is not None:
|
|
97
|
+
self.root = Path(root)
|
|
98
|
+
else:
|
|
99
|
+
raise ValueError("Must specify either 'cluster' or 'root'")
|
|
100
|
+
|
|
101
|
+
self.env_name = f"{model}_env"
|
|
102
|
+
self.model_arg = checkpoint or ""
|
|
103
|
+
|
|
104
|
+
# Verify environment is built
|
|
105
|
+
env_python = self.root / "envs" / self.env_name / "bin" / "python"
|
|
106
|
+
if not env_python.exists():
|
|
107
|
+
envs_dir = self.root / "envs"
|
|
108
|
+
if envs_dir.exists():
|
|
109
|
+
available = [p.name for p in envs_dir.iterdir() if p.is_dir()]
|
|
110
|
+
else:
|
|
111
|
+
available = []
|
|
112
|
+
raise RuntimeError(
|
|
113
|
+
f"Environment '{self.env_name}' not built at {self.root}/envs/{self.env_name}/\n"
|
|
114
|
+
f"Run: rootstock build {self.env_name} --root {self.root}\n"
|
|
115
|
+
f"Available environments: {available}"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
# Generate unique socket name to avoid conflicts
|
|
119
|
+
self._socket_name = f"rootstock_{uuid.uuid4().hex[:8]}"
|
|
120
|
+
self._server: RootstockServer | None = None
|
|
121
|
+
|
|
122
|
+
def _ensure_server(self):
|
|
123
|
+
"""Start server if not already running."""
|
|
124
|
+
if self._server is None:
|
|
125
|
+
self._server = RootstockServer(
|
|
126
|
+
env_name=self.env_name,
|
|
127
|
+
model=self.model_arg,
|
|
128
|
+
device=self.device,
|
|
129
|
+
socket_name=self._socket_name,
|
|
130
|
+
root=self.root,
|
|
131
|
+
log=self.log,
|
|
132
|
+
)
|
|
133
|
+
self._server.start()
|
|
134
|
+
|
|
135
|
+
def calculate(
|
|
136
|
+
self,
|
|
137
|
+
atoms=None,
|
|
138
|
+
properties=None,
|
|
139
|
+
system_changes=all_changes,
|
|
140
|
+
):
|
|
141
|
+
"""
|
|
142
|
+
Calculate properties for the given atoms.
|
|
143
|
+
|
|
144
|
+
This is called by ASE when properties are requested.
|
|
145
|
+
"""
|
|
146
|
+
if properties is None:
|
|
147
|
+
properties = self.implemented_properties
|
|
148
|
+
|
|
149
|
+
# Call parent to set self.atoms
|
|
150
|
+
Calculator.calculate(self, atoms, properties, system_changes)
|
|
151
|
+
|
|
152
|
+
# Ensure server is running
|
|
153
|
+
self._ensure_server()
|
|
154
|
+
|
|
155
|
+
# Get results from worker
|
|
156
|
+
energy, forces, virial = self._server.calculate(
|
|
157
|
+
positions=self.atoms.positions,
|
|
158
|
+
cell=np.array(self.atoms.cell),
|
|
159
|
+
atomic_numbers=self.atoms.numbers,
|
|
160
|
+
pbc=list(self.atoms.pbc),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
# Store results
|
|
164
|
+
self.results["energy"] = energy
|
|
165
|
+
self.results["free_energy"] = energy # No entropy contribution
|
|
166
|
+
self.results["forces"] = forces
|
|
167
|
+
|
|
168
|
+
# Convert virial to stress if cell is 3D
|
|
169
|
+
if self.atoms.cell.rank == 3 and any(self.atoms.pbc):
|
|
170
|
+
volume = self.atoms.get_volume()
|
|
171
|
+
stress_tensor = -virial / volume
|
|
172
|
+
self.results["stress"] = full_3x3_to_voigt_6_stress(stress_tensor)
|
|
173
|
+
else:
|
|
174
|
+
self.results["stress"] = np.zeros(6)
|
|
175
|
+
|
|
176
|
+
def close(self):
|
|
177
|
+
"""Stop the worker process and clean up."""
|
|
178
|
+
if self._server is not None:
|
|
179
|
+
self._server.stop()
|
|
180
|
+
self._server = None
|
|
181
|
+
|
|
182
|
+
def __enter__(self):
|
|
183
|
+
return self
|
|
184
|
+
|
|
185
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
186
|
+
self.close()
|
|
187
|
+
return False
|
|
188
|
+
|
|
189
|
+
def __del__(self):
|
|
190
|
+
# Best-effort cleanup
|
|
191
|
+
try:
|
|
192
|
+
self.close()
|
|
193
|
+
except Exception:
|
|
194
|
+
pass
|
rootstock/cli.py
ADDED
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Rootstock CLI.
|
|
3
|
+
|
|
4
|
+
Commands:
|
|
5
|
+
rootstock build <env_name> --root <path> [--models m1,m2] [--force]
|
|
6
|
+
rootstock build --all --root <path>
|
|
7
|
+
rootstock status --root <path>
|
|
8
|
+
rootstock register <env_file> --root <path>
|
|
9
|
+
rootstock list --root <path>
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import os
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import tempfile
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def extract_minimum_python_version(requires_python: str) -> str:
|
|
22
|
+
"""
|
|
23
|
+
Extract minimum Python version from a requires-python specifier.
|
|
24
|
+
|
|
25
|
+
Handles PEP 440 version specifiers like:
|
|
26
|
+
">=3.10" -> "3.10"
|
|
27
|
+
">=3.10,<3.13" -> "3.10"
|
|
28
|
+
"~=3.10" -> "3.10"
|
|
29
|
+
">=3.10.0" -> "3.10" (normalized for uv)
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
requires_python: PEP 440 version specifier string
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
Minimum version string suitable for `uv venv --python X.Y`
|
|
36
|
+
|
|
37
|
+
Raises:
|
|
38
|
+
ValueError: If no minimum version can be determined
|
|
39
|
+
"""
|
|
40
|
+
from packaging.specifiers import SpecifierSet
|
|
41
|
+
from packaging.version import Version
|
|
42
|
+
|
|
43
|
+
spec_set = SpecifierSet(requires_python)
|
|
44
|
+
|
|
45
|
+
min_version = None
|
|
46
|
+
|
|
47
|
+
for spec in spec_set:
|
|
48
|
+
# Operators that establish a lower bound
|
|
49
|
+
if spec.operator in (">=", "~=", "=="):
|
|
50
|
+
version = Version(spec.version)
|
|
51
|
+
if min_version is None or version < min_version:
|
|
52
|
+
min_version = version
|
|
53
|
+
elif spec.operator == ">":
|
|
54
|
+
# Strict greater-than: we can't determine exact minimum
|
|
55
|
+
# but the version given is a reasonable approximation for uv
|
|
56
|
+
version = Version(spec.version)
|
|
57
|
+
if min_version is None or version < min_version:
|
|
58
|
+
min_version = version
|
|
59
|
+
|
|
60
|
+
if min_version is None:
|
|
61
|
+
raise ValueError(
|
|
62
|
+
f"Cannot determine minimum Python version from '{requires_python}'. "
|
|
63
|
+
"Specifier must include >=, ~=, ==, or > constraint."
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# Return major.minor only (uv expects "3.10" not "3.10.0")
|
|
67
|
+
return f"{min_version.major}.{min_version.minor}"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def cmd_build(args) -> int:
|
|
71
|
+
"""
|
|
72
|
+
Build a pre-built virtual environment from an environment source file.
|
|
73
|
+
|
|
74
|
+
Exit codes:
|
|
75
|
+
0: Success
|
|
76
|
+
1: Build failed
|
|
77
|
+
"""
|
|
78
|
+
from .environment import check_uv_available, get_model_cache_env
|
|
79
|
+
from .pep723 import parse_pep723_metadata
|
|
80
|
+
|
|
81
|
+
root = Path(args.root)
|
|
82
|
+
env_name = args.env_name
|
|
83
|
+
|
|
84
|
+
# Check uv is available
|
|
85
|
+
if not check_uv_available():
|
|
86
|
+
print(
|
|
87
|
+
"Error: uv not found in PATH. Install uv: "
|
|
88
|
+
"https://docs.astral.sh/uv/getting-started/installation/",
|
|
89
|
+
file=sys.stderr,
|
|
90
|
+
)
|
|
91
|
+
return 1
|
|
92
|
+
|
|
93
|
+
# Find environment source file
|
|
94
|
+
env_source = root / "environments" / f"{env_name}.py"
|
|
95
|
+
if not env_source.exists():
|
|
96
|
+
print(f"Error: Environment source not found: {env_source}", file=sys.stderr)
|
|
97
|
+
available = (
|
|
98
|
+
list((root / "environments").glob("*.py")) if (root / "environments").exists() else []
|
|
99
|
+
)
|
|
100
|
+
if available:
|
|
101
|
+
print(f"Available: {[p.stem for p in available]}", file=sys.stderr)
|
|
102
|
+
return 1
|
|
103
|
+
|
|
104
|
+
env_target = root / "envs" / env_name
|
|
105
|
+
|
|
106
|
+
# Check if already exists
|
|
107
|
+
if env_target.exists():
|
|
108
|
+
if args.force:
|
|
109
|
+
print(f"Removing existing environment: {env_target}")
|
|
110
|
+
shutil.rmtree(env_target)
|
|
111
|
+
else:
|
|
112
|
+
print(f"Error: Environment already exists: {env_target}", file=sys.stderr)
|
|
113
|
+
print("Use --force to rebuild", file=sys.stderr)
|
|
114
|
+
return 1
|
|
115
|
+
|
|
116
|
+
print(f"Building environment: {env_name}")
|
|
117
|
+
print(f" Source: {env_source}")
|
|
118
|
+
print(f" Target: {env_target}")
|
|
119
|
+
|
|
120
|
+
# Parse PEP 723 metadata
|
|
121
|
+
content = env_source.read_text()
|
|
122
|
+
metadata = parse_pep723_metadata(content)
|
|
123
|
+
if metadata is None:
|
|
124
|
+
print(f"Error: No PEP 723 metadata in {env_source}", file=sys.stderr)
|
|
125
|
+
return 1
|
|
126
|
+
|
|
127
|
+
dependencies = metadata.get("dependencies", [])
|
|
128
|
+
requires_python = metadata.get("requires-python", ">=3.10")
|
|
129
|
+
|
|
130
|
+
# Extract uv-specific config (generic, works for any environment)
|
|
131
|
+
uv_config = metadata.get("tool", {}).get("uv", {})
|
|
132
|
+
find_links = uv_config.get("find-links", [])
|
|
133
|
+
|
|
134
|
+
# Extract minimum version properly
|
|
135
|
+
try:
|
|
136
|
+
python_version = extract_minimum_python_version(requires_python)
|
|
137
|
+
except ValueError as e:
|
|
138
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
139
|
+
return 1
|
|
140
|
+
|
|
141
|
+
print(f" Python: {requires_python} -> {python_version}")
|
|
142
|
+
print(f" Dependencies: {dependencies}")
|
|
143
|
+
if find_links:
|
|
144
|
+
print(f" Find-links: {find_links}")
|
|
145
|
+
|
|
146
|
+
# Ensure home directory exists for model downloads
|
|
147
|
+
home_dir = root / "home"
|
|
148
|
+
home_dir.mkdir(parents=True, exist_ok=True)
|
|
149
|
+
|
|
150
|
+
# Set up environment for uv commands.
|
|
151
|
+
# UV_PYTHON_INSTALL_DIR ensures Python interpreters are stored in the rootstock
|
|
152
|
+
# root directory, making the entire installation portable across machines/containers.
|
|
153
|
+
python_install_dir = root / ".python"
|
|
154
|
+
python_install_dir.mkdir(parents=True, exist_ok=True)
|
|
155
|
+
|
|
156
|
+
# Create virtual environment
|
|
157
|
+
print("\n1. Creating virtual environment...")
|
|
158
|
+
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
159
|
+
tmp_python_dir = Path(tmp_dir) / ".python"
|
|
160
|
+
|
|
161
|
+
# Download Python to local temp directory
|
|
162
|
+
download_env = os.environ.copy()
|
|
163
|
+
download_env["UV_PYTHON_INSTALL_DIR"] = str(tmp_python_dir)
|
|
164
|
+
|
|
165
|
+
result = subprocess.run(
|
|
166
|
+
["uv", "python", "install", python_version],
|
|
167
|
+
capture_output=True,
|
|
168
|
+
text=True,
|
|
169
|
+
env=download_env,
|
|
170
|
+
)
|
|
171
|
+
if result.returncode != 0:
|
|
172
|
+
print(f"Error downloading Python: {result.stderr}", file=sys.stderr)
|
|
173
|
+
return 1
|
|
174
|
+
|
|
175
|
+
# Copy downloaded Python to root directory (if not already there)
|
|
176
|
+
if tmp_python_dir.exists():
|
|
177
|
+
for item in tmp_python_dir.iterdir():
|
|
178
|
+
dest = python_install_dir / item.name
|
|
179
|
+
if not dest.exists():
|
|
180
|
+
if item.is_dir():
|
|
181
|
+
print(f" Copying Python to {dest}")
|
|
182
|
+
shutil.copytree(item, dest)
|
|
183
|
+
else:
|
|
184
|
+
shutil.copy2(item, dest)
|
|
185
|
+
|
|
186
|
+
# Phase 2: Create venv using the Python we just installed
|
|
187
|
+
uv_env = os.environ.copy()
|
|
188
|
+
uv_env["UV_PYTHON_INSTALL_DIR"] = str(python_install_dir)
|
|
189
|
+
|
|
190
|
+
result = subprocess.run(
|
|
191
|
+
["uv", "venv", str(env_target), "--python", python_version],
|
|
192
|
+
capture_output=True,
|
|
193
|
+
text=True,
|
|
194
|
+
env=uv_env,
|
|
195
|
+
)
|
|
196
|
+
if result.returncode != 0:
|
|
197
|
+
print(f"Error creating venv: {result.stderr}", file=sys.stderr)
|
|
198
|
+
return 1
|
|
199
|
+
|
|
200
|
+
env_python = env_target / "bin" / "python"
|
|
201
|
+
|
|
202
|
+
# Install dependencies using uv pip with --python flag
|
|
203
|
+
print("2. Installing dependencies...")
|
|
204
|
+
|
|
205
|
+
if dependencies:
|
|
206
|
+
pip_cmd = ["uv", "pip", "install", "--python", str(env_python)]
|
|
207
|
+
for link in find_links:
|
|
208
|
+
pip_cmd.extend(["--find-links", link])
|
|
209
|
+
pip_cmd.extend(dependencies)
|
|
210
|
+
|
|
211
|
+
result = subprocess.run(
|
|
212
|
+
pip_cmd,
|
|
213
|
+
capture_output=not args.verbose,
|
|
214
|
+
text=True,
|
|
215
|
+
env=uv_env,
|
|
216
|
+
)
|
|
217
|
+
if result.returncode != 0:
|
|
218
|
+
print(
|
|
219
|
+
f"Error installing dependencies: {result.stderr if not args.verbose else ''}",
|
|
220
|
+
file=sys.stderr,
|
|
221
|
+
)
|
|
222
|
+
return 1
|
|
223
|
+
|
|
224
|
+
# Install rootstock
|
|
225
|
+
print("3. Installing rootstock...")
|
|
226
|
+
# Find rootstock package path
|
|
227
|
+
import rootstock
|
|
228
|
+
|
|
229
|
+
rootstock_path = Path(rootstock.__file__).parent.parent
|
|
230
|
+
|
|
231
|
+
result = subprocess.run(
|
|
232
|
+
["uv", "pip", "install", "--python", str(env_python), str(rootstock_path)],
|
|
233
|
+
capture_output=not args.verbose,
|
|
234
|
+
text=True,
|
|
235
|
+
env=uv_env,
|
|
236
|
+
)
|
|
237
|
+
if result.returncode != 0:
|
|
238
|
+
print(
|
|
239
|
+
f"Error installing rootstock: {result.stderr if not args.verbose else ''}",
|
|
240
|
+
file=sys.stderr,
|
|
241
|
+
)
|
|
242
|
+
return 1
|
|
243
|
+
|
|
244
|
+
# Copy environment source file
|
|
245
|
+
print("4. Copying environment source...")
|
|
246
|
+
shutil.copy(env_source, env_target / "env_source.py")
|
|
247
|
+
|
|
248
|
+
# Pre-download models if requested
|
|
249
|
+
if args.models:
|
|
250
|
+
models = [m.strip() for m in args.models.split(",")]
|
|
251
|
+
print(f"5. Pre-downloading models: {models}")
|
|
252
|
+
|
|
253
|
+
cache_env = get_model_cache_env(root)
|
|
254
|
+
env = {**os.environ, **cache_env}
|
|
255
|
+
|
|
256
|
+
for model in models:
|
|
257
|
+
print(f" Downloading: {model}")
|
|
258
|
+
script = f'''
|
|
259
|
+
import sys
|
|
260
|
+
sys.path.insert(0, "{env_target}")
|
|
261
|
+
from env_source import setup
|
|
262
|
+
calc = setup("{model}", "cpu")
|
|
263
|
+
print(f"Downloaded model: {model}")
|
|
264
|
+
'''
|
|
265
|
+
result = subprocess.run(
|
|
266
|
+
[str(env_python), "-c", script],
|
|
267
|
+
env=env,
|
|
268
|
+
capture_output=not args.verbose,
|
|
269
|
+
text=True,
|
|
270
|
+
)
|
|
271
|
+
if result.returncode != 0:
|
|
272
|
+
print(f" Warning: Failed to download {model}", file=sys.stderr)
|
|
273
|
+
if args.verbose:
|
|
274
|
+
print(result.stderr, file=sys.stderr)
|
|
275
|
+
|
|
276
|
+
print(f"\nBuilt environment: {env_target}")
|
|
277
|
+
return 0
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def cmd_status(args) -> int:
|
|
281
|
+
"""Show status of rootstock installation."""
|
|
282
|
+
from .environment import list_built_environments, list_environments
|
|
283
|
+
|
|
284
|
+
root = Path(args.root)
|
|
285
|
+
|
|
286
|
+
print(f"Rootstock root: {root}")
|
|
287
|
+
|
|
288
|
+
# List environment sources
|
|
289
|
+
print("\nEnvironment sources:")
|
|
290
|
+
sources = list_environments(root)
|
|
291
|
+
if not sources:
|
|
292
|
+
print(" (none)")
|
|
293
|
+
else:
|
|
294
|
+
for name, path in sources:
|
|
295
|
+
print(f" {name}")
|
|
296
|
+
|
|
297
|
+
# List built environments
|
|
298
|
+
print("\nBuilt environments:")
|
|
299
|
+
built = list_built_environments(root)
|
|
300
|
+
if not built:
|
|
301
|
+
print(" (none)")
|
|
302
|
+
else:
|
|
303
|
+
for name, path in built:
|
|
304
|
+
# Check if env_source.py exists
|
|
305
|
+
has_source = (path / "env_source.py").exists()
|
|
306
|
+
status = "ready" if has_source else "incomplete"
|
|
307
|
+
print(f" {name:<20} [{status}]")
|
|
308
|
+
|
|
309
|
+
# Show cache sizes
|
|
310
|
+
print("\nCache:")
|
|
311
|
+
cache_dir = root / "cache"
|
|
312
|
+
if cache_dir.exists():
|
|
313
|
+
for subdir in sorted(cache_dir.iterdir()):
|
|
314
|
+
if subdir.is_dir():
|
|
315
|
+
# Get size
|
|
316
|
+
total_size = sum(f.stat().st_size for f in subdir.rglob("*") if f.is_file())
|
|
317
|
+
size_mb = total_size / (1024 * 1024)
|
|
318
|
+
print(f" {subdir.name + '/':<20} {size_mb:.1f} MB")
|
|
319
|
+
else:
|
|
320
|
+
print(" (no cache directory)")
|
|
321
|
+
|
|
322
|
+
return 0
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def cmd_register(args) -> int:
|
|
326
|
+
"""Register an environment file to the shared directory."""
|
|
327
|
+
from .pep723 import validate_environment_file
|
|
328
|
+
|
|
329
|
+
env_path = Path(args.env_file)
|
|
330
|
+
root = Path(args.root)
|
|
331
|
+
|
|
332
|
+
# Validate the file
|
|
333
|
+
print(f"Validating {env_path}...")
|
|
334
|
+
is_valid, error = validate_environment_file(env_path)
|
|
335
|
+
if not is_valid:
|
|
336
|
+
print(f"Error: {error}", file=sys.stderr)
|
|
337
|
+
return 1
|
|
338
|
+
|
|
339
|
+
# Create environments directory
|
|
340
|
+
env_dir = root / "environments"
|
|
341
|
+
env_dir.mkdir(parents=True, exist_ok=True)
|
|
342
|
+
|
|
343
|
+
# Copy file
|
|
344
|
+
dest_path = env_dir / env_path.name
|
|
345
|
+
shutil.copy2(env_path, dest_path)
|
|
346
|
+
|
|
347
|
+
print(f"Registered: {env_path.stem} -> {dest_path}")
|
|
348
|
+
return 0
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def cmd_list(args) -> int:
|
|
352
|
+
"""List registered environments."""
|
|
353
|
+
from .environment import list_built_environments, list_environments
|
|
354
|
+
|
|
355
|
+
root = Path(args.root)
|
|
356
|
+
|
|
357
|
+
sources = list_environments(root)
|
|
358
|
+
built = list_built_environments(root)
|
|
359
|
+
built_names = {name for name, _ in built}
|
|
360
|
+
|
|
361
|
+
if not sources and not built:
|
|
362
|
+
print(f"No environments in {root}")
|
|
363
|
+
return 0
|
|
364
|
+
|
|
365
|
+
print(f"Environments in {root}:")
|
|
366
|
+
for name, path in sources:
|
|
367
|
+
status = "built" if name in built_names else "source only"
|
|
368
|
+
print(f" {name:<20} [{status}]")
|
|
369
|
+
|
|
370
|
+
return 0
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def main():
|
|
374
|
+
parser = argparse.ArgumentParser(
|
|
375
|
+
prog="rootstock",
|
|
376
|
+
description="Rootstock MLIP environment manager",
|
|
377
|
+
)
|
|
378
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
379
|
+
|
|
380
|
+
# build command
|
|
381
|
+
build_parser = subparsers.add_parser(
|
|
382
|
+
"build",
|
|
383
|
+
help="Build a pre-built environment",
|
|
384
|
+
description="Build a virtual environment from an environment source file.",
|
|
385
|
+
)
|
|
386
|
+
build_parser.add_argument("env_name", help="Name of environment to build (e.g., mace_env)")
|
|
387
|
+
build_parser.add_argument("--root", required=True, help="Root directory")
|
|
388
|
+
build_parser.add_argument("--models", help="Comma-separated list of models to pre-download")
|
|
389
|
+
build_parser.add_argument("--force", action="store_true", help="Rebuild if exists")
|
|
390
|
+
build_parser.add_argument("--verbose", "-v", action="store_true", help="Verbose output")
|
|
391
|
+
build_parser.set_defaults(func=cmd_build)
|
|
392
|
+
|
|
393
|
+
# status command
|
|
394
|
+
status_parser = subparsers.add_parser(
|
|
395
|
+
"status",
|
|
396
|
+
help="Show status of rootstock installation",
|
|
397
|
+
description="Show environment sources, built environments, and cache sizes.",
|
|
398
|
+
)
|
|
399
|
+
status_parser.add_argument("--root", required=True, help="Root directory")
|
|
400
|
+
status_parser.set_defaults(func=cmd_status)
|
|
401
|
+
|
|
402
|
+
# register command
|
|
403
|
+
reg_parser = subparsers.add_parser(
|
|
404
|
+
"register",
|
|
405
|
+
help="Register an environment file",
|
|
406
|
+
description="Copy a validated environment file to the shared environments directory.",
|
|
407
|
+
)
|
|
408
|
+
reg_parser.add_argument("env_file", help="Path to environment file")
|
|
409
|
+
reg_parser.add_argument("--root", required=True, help="Root directory")
|
|
410
|
+
reg_parser.set_defaults(func=cmd_register)
|
|
411
|
+
|
|
412
|
+
# list command
|
|
413
|
+
list_parser = subparsers.add_parser(
|
|
414
|
+
"list",
|
|
415
|
+
help="List registered environments",
|
|
416
|
+
description="List all environment files in the shared environments directory.",
|
|
417
|
+
)
|
|
418
|
+
list_parser.add_argument("--root", required=True, help="Root directory")
|
|
419
|
+
list_parser.set_defaults(func=cmd_list)
|
|
420
|
+
|
|
421
|
+
args = parser.parse_args()
|
|
422
|
+
sys.exit(args.func(args))
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
if __name__ == "__main__":
|
|
426
|
+
main()
|
rootstock/clusters.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cluster configuration for Rootstock.
|
|
3
|
+
|
|
4
|
+
This module provides mappings from cluster names to root directories
|
|
5
|
+
where Rootstock environments and caches are stored.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
# Registry of known clusters and their rootstock root directories
|
|
13
|
+
CLUSTER_REGISTRY: dict[str, str] = {
|
|
14
|
+
"modal": "/vol/rootstock",
|
|
15
|
+
"della": "/scratch/gpfs/SHARED/rootstock",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
# Known environment families (used for validation)
|
|
19
|
+
KNOWN_ENVIRONMENTS = ["mace", "chgnet", "orb", "alignn", "uma", "tensornet"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_root_for_cluster(cluster: str) -> Path:
|
|
23
|
+
"""
|
|
24
|
+
Get the rootstock root directory for a known cluster.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
cluster: Name of a known cluster (e.g., "modal", "della")
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
Path to the rootstock root directory for that cluster.
|
|
31
|
+
|
|
32
|
+
Raises:
|
|
33
|
+
ValueError: If the cluster is not in the registry.
|
|
34
|
+
"""
|
|
35
|
+
if cluster not in CLUSTER_REGISTRY:
|
|
36
|
+
available = ", ".join(CLUSTER_REGISTRY.keys())
|
|
37
|
+
raise ValueError(
|
|
38
|
+
f"Unknown cluster '{cluster}'. Known clusters: {available}. "
|
|
39
|
+
f"Use root='/path/to/rootstock' for custom locations."
|
|
40
|
+
)
|
|
41
|
+
return Path(CLUSTER_REGISTRY[cluster])
|