runops 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- runops/__init__.py +5 -0
- runops/_data/README.md +476 -0
- runops/adapters/__init__.py +29 -0
- runops/adapters/_utils/__init__.py +36 -0
- runops/adapters/_utils/toml_utils.py +81 -0
- runops/adapters/base.py +335 -0
- runops/adapters/contrib/__init__.py +5 -0
- runops/adapters/contrib/beach.py +837 -0
- runops/adapters/contrib/emses.py +1010 -0
- runops/adapters/generic.py +439 -0
- runops/adapters/registry.py +244 -0
- runops/cli/__init__.py +3 -0
- runops/cli/analyze.py +222 -0
- runops/cli/clone.py +104 -0
- runops/cli/config.py +217 -0
- runops/cli/context.py +56 -0
- runops/cli/create.py +263 -0
- runops/cli/dashboard.py +179 -0
- runops/cli/extend.py +204 -0
- runops/cli/history.py +105 -0
- runops/cli/init.py +1432 -0
- runops/cli/jobs.py +145 -0
- runops/cli/knowledge.py +1017 -0
- runops/cli/list.py +102 -0
- runops/cli/log.py +163 -0
- runops/cli/main.py +96 -0
- runops/cli/manage.py +231 -0
- runops/cli/new.py +343 -0
- runops/cli/notes.py +257 -0
- runops/cli/run_lookup.py +148 -0
- runops/cli/setup.py +174 -0
- runops/cli/status.py +187 -0
- runops/cli/submit.py +297 -0
- runops/cli/update.py +113 -0
- runops/cli/update_harness.py +245 -0
- runops/cli/update_refs.py +370 -0
- runops/core/__init__.py +3 -0
- runops/core/actions.py +1186 -0
- runops/core/analysis.py +1090 -0
- runops/core/campaign.py +156 -0
- runops/core/case.py +307 -0
- runops/core/context.py +426 -0
- runops/core/discovery.py +192 -0
- runops/core/environment.py +266 -0
- runops/core/exceptions.py +93 -0
- runops/core/knowledge.py +595 -0
- runops/core/knowledge_source.py +1204 -0
- runops/core/manifest.py +219 -0
- runops/core/project.py +171 -0
- runops/core/provenance.py +147 -0
- runops/core/retry.py +193 -0
- runops/core/run.py +170 -0
- runops/core/run_creation.py +456 -0
- runops/core/site.py +337 -0
- runops/core/state.py +197 -0
- runops/core/survey.py +380 -0
- runops/core/validation.py +40 -0
- runops/harness/__init__.py +27 -0
- runops/harness/builder.py +327 -0
- runops/harness/claude.py +189 -0
- runops/jobgen/__init__.py +3 -0
- runops/jobgen/generator.py +295 -0
- runops/launchers/__init__.py +17 -0
- runops/launchers/base.py +313 -0
- runops/launchers/mpiexec.py +131 -0
- runops/launchers/mpirun.py +132 -0
- runops/launchers/srun.py +126 -0
- runops/sites/__init__.py +0 -0
- runops/sites/camphor.md +98 -0
- runops/sites/camphor.toml +27 -0
- runops/slurm/__init__.py +3 -0
- runops/slurm/query.py +384 -0
- runops/slurm/submit.py +203 -0
- runops/templates/__init__.py +29 -0
- runops/templates/adapters/beach/agent_guide.md +50 -0
- runops/templates/adapters/beach/beach.toml +19 -0
- runops/templates/adapters/beach/case.toml +16 -0
- runops/templates/adapters/beach/summarize.py +272 -0
- runops/templates/adapters/emses/agent_guide.md +39 -0
- runops/templates/adapters/emses/case.toml +18 -0
- runops/templates/adapters/emses/plasma.toml +118 -0
- runops/templates/adapters/emses/summarize.py +413 -0
- runops/templates/adapters/generic/case.toml.j2 +13 -0
- runops/templates/adapters/generic/summarize.py +21 -0
- runops/templates/agent.md +156 -0
- runops/templates/rules/cookbook.md +22 -0
- runops/templates/scaffold/campaign.toml.j2 +10 -0
- runops/templates/scaffold/cases_claude.md +22 -0
- runops/templates/scaffold/facts.toml +2 -0
- runops/templates/scaffold/gitignore.txt +30 -0
- runops/templates/scaffold/notes/README.md +69 -0
- runops/templates/scaffold/rules/plan-before-act.md +17 -0
- runops/templates/scaffold/rules/runops-workflow.md +84 -0
- runops/templates/scaffold/rules/upstream-feedback.md +85 -0
- runops/templates/scaffold/runs_claude.md +24 -0
- runops/templates/scaffold/vscode_settings.json +9 -0
- runops/templates/skills/analyze/SKILL.md +40 -0
- runops/templates/skills/check-status/SKILL.md +29 -0
- runops/templates/skills/cleanup/SKILL.md +43 -0
- runops/templates/skills/create-run/SKILL.md +135 -0
- runops/templates/skills/debug-failed/SKILL.md +38 -0
- runops/templates/skills/learn/SKILL.md +54 -0
- runops/templates/skills/new-case/SKILL.md +108 -0
- runops/templates/skills/note/SKILL.md +107 -0
- runops/templates/skills/run-all/SKILL.md +47 -0
- runops/templates/skills/runops-reference/SKILL.md +203 -0
- runops/templates/skills/setup-campaign/SKILL.md +111 -0
- runops/templates/skills/setup-env/SKILL.md +32 -0
- runops/templates/skills/survey-design/SKILL.md +73 -0
- runops/templates/survey.toml.j2 +22 -0
- runops-0.2.0.dist-info/METADATA +491 -0
- runops-0.2.0.dist-info/RECORD +115 -0
- runops-0.2.0.dist-info/WHEEL +4 -0
- runops-0.2.0.dist-info/entry_points.txt +2 -0
- runops-0.2.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,837 @@
|
|
|
1
|
+
"""BEACH (BEM + Accumulated CHarge) simulator adapter.
|
|
2
|
+
|
|
3
|
+
Handles BEACH-specific TOML configuration (beach.toml), CSV output
|
|
4
|
+
detection, and OpenMP/MPI hybrid execution.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import logging
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
if sys.version_info >= (3, 11):
|
|
18
|
+
import tomllib
|
|
19
|
+
else:
|
|
20
|
+
import tomli as tomllib
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
import tomli_w
|
|
24
|
+
except ImportError:
|
|
25
|
+
tomli_w = None # type: ignore[assignment]
|
|
26
|
+
|
|
27
|
+
from runops.adapters._utils import find_venv
|
|
28
|
+
from runops.adapters._utils.toml_utils import apply_dotted_overrides
|
|
29
|
+
from runops.adapters.base import SimulatorAdapter
|
|
30
|
+
from runops.core.validation import ValidationIssue
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
INPUT_DIR = "input"
|
|
35
|
+
WORK_DIR = "work"
|
|
36
|
+
LATEST_OUTPUT_DIR = f"{WORK_DIR}/latest"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _relative_to_run(path: Path, run_dir: Path) -> str:
|
|
40
|
+
"""Return a stable POSIX-style relative path under the run directory."""
|
|
41
|
+
return path.relative_to(run_dir).as_posix()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# Known BEACH output files (label -> filename)
|
|
45
|
+
_OUTPUT_FILES = {
|
|
46
|
+
"summary": "summary.txt",
|
|
47
|
+
"charges": "charges.csv",
|
|
48
|
+
"mesh_triangles": "mesh_triangles.csv",
|
|
49
|
+
"mesh_sources": "mesh_sources.csv",
|
|
50
|
+
"charge_history": "charge_history.csv",
|
|
51
|
+
"potential_history": "potential_history.csv",
|
|
52
|
+
"mesh_potential": "mesh_potential.csv",
|
|
53
|
+
"rng_state": "rng_state.txt",
|
|
54
|
+
"performance_profile": "performance_profile.csv",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class BeachAdapter(SimulatorAdapter):
|
|
59
|
+
"""Adapter for the BEACH BEM surface-charging simulator.
|
|
60
|
+
|
|
61
|
+
BEACH uses TOML configuration files (``beach.toml``) and produces
|
|
62
|
+
CSV output files (``charges.csv``, ``summary.txt``, etc.).
|
|
63
|
+
|
|
64
|
+
Class Attributes:
|
|
65
|
+
adapter_name: Registry key for this adapter.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
adapter_name: str = "beach"
|
|
69
|
+
|
|
70
|
+
# ------------------------------------------------------------------
|
|
71
|
+
# SimulatorAdapter interface
|
|
72
|
+
# ------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
@classmethod
|
|
75
|
+
def default_config(cls) -> dict[str, Any]:
|
|
76
|
+
"""Return default simulators.toml entry for BEACH."""
|
|
77
|
+
return {
|
|
78
|
+
"adapter": "beach",
|
|
79
|
+
"resolver_mode": "package",
|
|
80
|
+
"executable": "beach",
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
def interactive_config(cls) -> dict[str, Any]:
|
|
85
|
+
"""Interactively prompt for BEACH configuration."""
|
|
86
|
+
import typer
|
|
87
|
+
|
|
88
|
+
typer.echo("\n Configuring 'beach' simulator (BEACH BEM):")
|
|
89
|
+
|
|
90
|
+
resolver_mode = typer.prompt(
|
|
91
|
+
" Resolver mode (package / local_executable / local_source)",
|
|
92
|
+
default="package",
|
|
93
|
+
)
|
|
94
|
+
executable = typer.prompt(
|
|
95
|
+
" Executable path or name",
|
|
96
|
+
default="beach",
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
default_modules = ["intel/2023.2", "intelmpi/2023.2"]
|
|
100
|
+
config: dict[str, Any] = {
|
|
101
|
+
"adapter": "beach",
|
|
102
|
+
"resolver_mode": resolver_mode,
|
|
103
|
+
"executable": executable,
|
|
104
|
+
"modules": default_modules,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if resolver_mode == "local_source":
|
|
108
|
+
config["source_repo"] = typer.prompt(
|
|
109
|
+
" BEACH source repository path", default=""
|
|
110
|
+
)
|
|
111
|
+
config["build_command"] = typer.prompt(
|
|
112
|
+
" Build command", default="make build"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
if typer.confirm(" Customize module list?", default=False):
|
|
116
|
+
modules_str = typer.prompt(
|
|
117
|
+
" Modules (comma-separated)",
|
|
118
|
+
default=", ".join(default_modules),
|
|
119
|
+
)
|
|
120
|
+
config["modules"] = [m.strip() for m in modules_str.split(",") if m.strip()]
|
|
121
|
+
|
|
122
|
+
return config
|
|
123
|
+
|
|
124
|
+
@classmethod
|
|
125
|
+
def case_template(cls) -> dict[str, str]:
|
|
126
|
+
"""Return template files for a new BEACH case."""
|
|
127
|
+
from runops.templates import load_static
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
"case.toml": load_static("adapters/beach/case.toml"),
|
|
131
|
+
"beach.toml": load_static("adapters/beach/beach.toml"),
|
|
132
|
+
"summarize.py": load_static("adapters/beach/summarize.py"),
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
@classmethod
|
|
136
|
+
def pip_packages(cls) -> list[str]:
|
|
137
|
+
"""Return pip packages for BEACH (simulator + analysis tools)."""
|
|
138
|
+
return [
|
|
139
|
+
"beach-bem",
|
|
140
|
+
"matplotlib",
|
|
141
|
+
"numpy",
|
|
142
|
+
"pandas",
|
|
143
|
+
]
|
|
144
|
+
|
|
145
|
+
@classmethod
|
|
146
|
+
def doc_repos(cls) -> list[tuple[str, str]]:
|
|
147
|
+
"""Return documentation repos for BEACH."""
|
|
148
|
+
return [
|
|
149
|
+
(
|
|
150
|
+
"https://github.com/Nkzono99/beach.git",
|
|
151
|
+
"beach",
|
|
152
|
+
),
|
|
153
|
+
]
|
|
154
|
+
|
|
155
|
+
@classmethod
|
|
156
|
+
def knowledge_sources(cls) -> dict[str, list[str]]:
|
|
157
|
+
"""Return knowledge-relevant file patterns for BEACH repos."""
|
|
158
|
+
return {
|
|
159
|
+
"beach": [
|
|
160
|
+
"README.md",
|
|
161
|
+
"docs/**/*.md",
|
|
162
|
+
"schemas/*.json",
|
|
163
|
+
"examples/**/*.toml",
|
|
164
|
+
"cookbook/COOKBOOK.md",
|
|
165
|
+
"cookbook/index.toml",
|
|
166
|
+
"cookbook/**/*.toml",
|
|
167
|
+
"cookbook/**/*.md",
|
|
168
|
+
],
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
@classmethod
|
|
172
|
+
def parameter_schema(cls) -> dict[str, dict[str, Any]]:
|
|
173
|
+
"""Return BEACH parameter schema."""
|
|
174
|
+
return {
|
|
175
|
+
"sim.dt": {
|
|
176
|
+
"type": "float",
|
|
177
|
+
"unit": "s",
|
|
178
|
+
"description": "Time step",
|
|
179
|
+
"range": [0.0, None],
|
|
180
|
+
"default": 1.0e-6,
|
|
181
|
+
"constraints": ["timestep_stability"],
|
|
182
|
+
"interdependencies": [
|
|
183
|
+
"environment.electron_density",
|
|
184
|
+
],
|
|
185
|
+
},
|
|
186
|
+
"sim.max_step": {
|
|
187
|
+
"type": "int",
|
|
188
|
+
"unit": "",
|
|
189
|
+
"description": "Maximum simulation steps",
|
|
190
|
+
"range": [1, None],
|
|
191
|
+
"default": 1000,
|
|
192
|
+
},
|
|
193
|
+
"sim.batch_count": {
|
|
194
|
+
"type": "int",
|
|
195
|
+
"unit": "",
|
|
196
|
+
"description": "Number of batches",
|
|
197
|
+
"range": [1, None],
|
|
198
|
+
"default": 100,
|
|
199
|
+
},
|
|
200
|
+
"sim.field_solver": {
|
|
201
|
+
"type": "str",
|
|
202
|
+
"description": "Field solver type (fmm, direct, etc.)",
|
|
203
|
+
"default": "fmm",
|
|
204
|
+
},
|
|
205
|
+
"environment.electron_density": {
|
|
206
|
+
"type": "float",
|
|
207
|
+
"unit": "m^-3",
|
|
208
|
+
"description": "Background electron number density",
|
|
209
|
+
"range": [0.0, None],
|
|
210
|
+
"default": 1.0e12,
|
|
211
|
+
"constraints": ["charge_neutrality"],
|
|
212
|
+
"interdependencies": [
|
|
213
|
+
"environment.ion_density",
|
|
214
|
+
],
|
|
215
|
+
},
|
|
216
|
+
"environment.electron_temperature": {
|
|
217
|
+
"type": "float",
|
|
218
|
+
"unit": "eV",
|
|
219
|
+
"description": "Electron temperature",
|
|
220
|
+
"range": [0.0, None],
|
|
221
|
+
"default": 1.0,
|
|
222
|
+
},
|
|
223
|
+
"environment.ion_density": {
|
|
224
|
+
"type": "float",
|
|
225
|
+
"unit": "m^-3",
|
|
226
|
+
"description": "Background ion number density",
|
|
227
|
+
"range": [0.0, None],
|
|
228
|
+
"default": 1.0e12,
|
|
229
|
+
"constraints": ["charge_neutrality"],
|
|
230
|
+
"interdependencies": [
|
|
231
|
+
"environment.electron_density",
|
|
232
|
+
],
|
|
233
|
+
},
|
|
234
|
+
"environment.ion_temperature": {
|
|
235
|
+
"type": "float",
|
|
236
|
+
"unit": "eV",
|
|
237
|
+
"description": "Ion temperature",
|
|
238
|
+
"range": [0.0, None],
|
|
239
|
+
"default": 1.0,
|
|
240
|
+
},
|
|
241
|
+
"mesh.obj_path": {
|
|
242
|
+
"type": "str",
|
|
243
|
+
"description": "Path to OBJ mesh file",
|
|
244
|
+
"constraints": ["mesh_file_exists"],
|
|
245
|
+
},
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
@classmethod
|
|
249
|
+
def default_plot_recipes(cls) -> dict[str, dict[str, Any]]:
|
|
250
|
+
"""Return default survey plot recipes for BEACH studies."""
|
|
251
|
+
return {
|
|
252
|
+
"charge-history-vs-dt": {
|
|
253
|
+
"description": (
|
|
254
|
+
"Check charge-history coverage as the BEACH timestep changes."
|
|
255
|
+
),
|
|
256
|
+
"x": ["param.sim.dt", "sim_dt"],
|
|
257
|
+
"y": ["output_counts.charge_history"],
|
|
258
|
+
"kind": "line",
|
|
259
|
+
"group_by": ["param.sim.field_solver", "sim_field_solver"],
|
|
260
|
+
"title": "BEACH charge-history coverage vs dt",
|
|
261
|
+
},
|
|
262
|
+
"potential-history-vs-steps": {
|
|
263
|
+
"description": (
|
|
264
|
+
"Compare potential-history output availability against max_step."
|
|
265
|
+
),
|
|
266
|
+
"x": ["param.sim.max_step", "sim_max_step"],
|
|
267
|
+
"y": ["output_counts.potential_history"],
|
|
268
|
+
"kind": "line",
|
|
269
|
+
"group_by": ["param.sim.field_solver", "sim_field_solver"],
|
|
270
|
+
"title": "BEACH potential-history coverage vs max_step",
|
|
271
|
+
},
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
def validate_params(
|
|
275
|
+
self,
|
|
276
|
+
case_data: dict[str, Any],
|
|
277
|
+
) -> list[ValidationIssue]:
|
|
278
|
+
"""Validate BEACH parameters against physics constraints.
|
|
279
|
+
|
|
280
|
+
Checks: positive physical quantities, timestep stability,
|
|
281
|
+
and charge neutrality.
|
|
282
|
+
"""
|
|
283
|
+
issues: list[ValidationIssue] = []
|
|
284
|
+
config = self._resolve_config(case_data)
|
|
285
|
+
if not config:
|
|
286
|
+
return issues
|
|
287
|
+
|
|
288
|
+
sim = config.get("sim", {})
|
|
289
|
+
env = config.get("environment", {})
|
|
290
|
+
|
|
291
|
+
dt = sim.get("dt")
|
|
292
|
+
max_step = sim.get("max_step")
|
|
293
|
+
e_density = env.get("electron_density")
|
|
294
|
+
e_temp = env.get("electron_temperature")
|
|
295
|
+
i_density = env.get("ion_density")
|
|
296
|
+
i_temp = env.get("ion_temperature")
|
|
297
|
+
|
|
298
|
+
# Positive required checks
|
|
299
|
+
positives = [
|
|
300
|
+
("sim.dt", dt),
|
|
301
|
+
("sim.max_step", max_step),
|
|
302
|
+
("environment.electron_density", e_density),
|
|
303
|
+
("environment.electron_temperature", e_temp),
|
|
304
|
+
("environment.ion_density", i_density),
|
|
305
|
+
("environment.ion_temperature", i_temp),
|
|
306
|
+
]
|
|
307
|
+
for param_name, value in positives:
|
|
308
|
+
if value is not None and float(value) <= 0:
|
|
309
|
+
issues.append(
|
|
310
|
+
ValidationIssue(
|
|
311
|
+
severity="error",
|
|
312
|
+
message=f"{param_name} must be positive, got {value}.",
|
|
313
|
+
parameter=param_name,
|
|
314
|
+
constraint_name="positive_required",
|
|
315
|
+
)
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
# Timestep stability: dt * omega_pe should be reasonable
|
|
319
|
+
# omega_pe = sqrt(n_e * e^2 / (m_e * eps0))
|
|
320
|
+
if dt is not None and e_density is not None and float(e_density) > 0:
|
|
321
|
+
import math
|
|
322
|
+
|
|
323
|
+
e_charge = 1.602176634e-19
|
|
324
|
+
m_electron = 9.10938370e-31
|
|
325
|
+
eps0 = 8.854187817e-12
|
|
326
|
+
omega_pe = math.sqrt(float(e_density) * e_charge**2 / (m_electron * eps0))
|
|
327
|
+
dt_omega = float(dt) * omega_pe
|
|
328
|
+
if dt_omega > 0.5:
|
|
329
|
+
issues.append(
|
|
330
|
+
ValidationIssue(
|
|
331
|
+
severity="warning",
|
|
332
|
+
message=(
|
|
333
|
+
f"dt * omega_pe = {dt_omega:.3f} > 0.5. "
|
|
334
|
+
f"Time step may be too large for plasma "
|
|
335
|
+
f"timescale. Consider dt < "
|
|
336
|
+
f"{0.5 / omega_pe:.2e} s."
|
|
337
|
+
),
|
|
338
|
+
parameter="sim.dt",
|
|
339
|
+
constraint_name="timestep_stability",
|
|
340
|
+
details={
|
|
341
|
+
"dt": float(dt),
|
|
342
|
+
"omega_pe": omega_pe,
|
|
343
|
+
"dt_omega_pe": dt_omega,
|
|
344
|
+
"recommended_max_dt": 0.5 / omega_pe,
|
|
345
|
+
},
|
|
346
|
+
)
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
# Charge neutrality
|
|
350
|
+
if e_density is not None and i_density is not None and float(e_density) > 0:
|
|
351
|
+
ratio = float(i_density) / float(e_density)
|
|
352
|
+
if abs(ratio - 1.0) > 0.1:
|
|
353
|
+
issues.append(
|
|
354
|
+
ValidationIssue(
|
|
355
|
+
severity="warning",
|
|
356
|
+
message=(
|
|
357
|
+
f"Charge neutrality: ion/electron density "
|
|
358
|
+
f"ratio = {ratio:.3f}. Significant imbalance "
|
|
359
|
+
f"may be intentional but verify."
|
|
360
|
+
),
|
|
361
|
+
parameter="environment.ion_density",
|
|
362
|
+
constraint_name="charge_neutrality",
|
|
363
|
+
details={
|
|
364
|
+
"electron_density": float(e_density),
|
|
365
|
+
"ion_density": float(i_density),
|
|
366
|
+
"ratio": ratio,
|
|
367
|
+
},
|
|
368
|
+
)
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
return issues
|
|
372
|
+
|
|
373
|
+
@staticmethod
|
|
374
|
+
def _resolve_config(case_data: dict[str, Any]) -> dict[str, Any]:
|
|
375
|
+
"""Load template config and apply param overrides."""
|
|
376
|
+
case_section = case_data.get("case", {})
|
|
377
|
+
params = case_data.get("params", {})
|
|
378
|
+
config: dict[str, Any] = {}
|
|
379
|
+
|
|
380
|
+
case_dir_str = case_section.get("case_dir", "")
|
|
381
|
+
if case_dir_str:
|
|
382
|
+
case_dir = Path(case_dir_str)
|
|
383
|
+
for name in ("beach.toml", "beach_template.toml"):
|
|
384
|
+
# Look in input/ subdirectory first, then case root for compat
|
|
385
|
+
for candidate in (case_dir / "input" / name, case_dir / name):
|
|
386
|
+
if candidate.is_file():
|
|
387
|
+
with open(candidate, "rb") as f:
|
|
388
|
+
config = tomllib.load(f)
|
|
389
|
+
break
|
|
390
|
+
if config:
|
|
391
|
+
break
|
|
392
|
+
|
|
393
|
+
if params and config:
|
|
394
|
+
config = apply_dotted_overrides(config, params)
|
|
395
|
+
|
|
396
|
+
return config
|
|
397
|
+
|
|
398
|
+
@classmethod
|
|
399
|
+
def agent_guide(cls) -> str:
|
|
400
|
+
"""Return AI agent guide for BEACH."""
|
|
401
|
+
from runops.templates import load_static
|
|
402
|
+
|
|
403
|
+
return load_static("adapters/beach/agent_guide.md")
|
|
404
|
+
|
|
405
|
+
@property
|
|
406
|
+
def name(self) -> str:
|
|
407
|
+
"""Return the canonical name of this adapter."""
|
|
408
|
+
return self.adapter_name
|
|
409
|
+
|
|
410
|
+
def render_inputs(
|
|
411
|
+
self,
|
|
412
|
+
case_data: dict[str, Any],
|
|
413
|
+
run_dir: Path,
|
|
414
|
+
) -> list[str]:
|
|
415
|
+
"""Generate BEACH input files in the run directory.
|
|
416
|
+
|
|
417
|
+
Reads a ``beach.toml`` template from the case directory, applies
|
|
418
|
+
parameter overrides via dot-notation, and writes the result to
|
|
419
|
+
``<run_dir>/input/beach.toml``.
|
|
420
|
+
|
|
421
|
+
Args:
|
|
422
|
+
case_data: Merged case/survey parameters.
|
|
423
|
+
run_dir: Target run directory.
|
|
424
|
+
|
|
425
|
+
Returns:
|
|
426
|
+
List of relative paths to generated input files.
|
|
427
|
+
|
|
428
|
+
Raises:
|
|
429
|
+
ValueError: If the case section is missing.
|
|
430
|
+
RuntimeError: If ``tomli_w`` is not installed.
|
|
431
|
+
"""
|
|
432
|
+
case_section = case_data.get("case", {})
|
|
433
|
+
if not case_section:
|
|
434
|
+
msg = "case_data must contain a 'case' section"
|
|
435
|
+
raise ValueError(msg)
|
|
436
|
+
|
|
437
|
+
params = case_data.get("params", {})
|
|
438
|
+
input_dir = run_dir / INPUT_DIR
|
|
439
|
+
input_dir.mkdir(parents=True, exist_ok=True)
|
|
440
|
+
(run_dir / WORK_DIR / "latest").mkdir(parents=True, exist_ok=True)
|
|
441
|
+
|
|
442
|
+
created: list[str] = []
|
|
443
|
+
|
|
444
|
+
# Find template configuration
|
|
445
|
+
case_dir_str = case_section.get("case_dir", "")
|
|
446
|
+
template_config: dict[str, Any] = {}
|
|
447
|
+
|
|
448
|
+
if case_dir_str:
|
|
449
|
+
case_dir = Path(case_dir_str)
|
|
450
|
+
for candidate_name in ("beach.toml", "beach_template.toml"):
|
|
451
|
+
# Look in input/ subdirectory first, then case root for compat
|
|
452
|
+
for candidate in (
|
|
453
|
+
case_dir / "input" / candidate_name,
|
|
454
|
+
case_dir / candidate_name,
|
|
455
|
+
):
|
|
456
|
+
if candidate.is_file():
|
|
457
|
+
with open(candidate, "rb") as f:
|
|
458
|
+
template_config = tomllib.load(f)
|
|
459
|
+
break
|
|
460
|
+
if template_config:
|
|
461
|
+
break
|
|
462
|
+
|
|
463
|
+
# Also check input_files list
|
|
464
|
+
input_files: list[str] = case_section.get("input_files", [])
|
|
465
|
+
for src_str in input_files:
|
|
466
|
+
src = Path(src_str)
|
|
467
|
+
if src.suffix == ".toml" and src.is_file():
|
|
468
|
+
if not template_config:
|
|
469
|
+
with open(src, "rb") as f:
|
|
470
|
+
template_config = tomllib.load(f)
|
|
471
|
+
elif src.name not in ("beach.toml", "beach_template.toml"):
|
|
472
|
+
dest = input_dir / src.name
|
|
473
|
+
shutil.copy2(src, dest)
|
|
474
|
+
created.append(_relative_to_run(dest, run_dir))
|
|
475
|
+
|
|
476
|
+
# Apply parameter overrides
|
|
477
|
+
if params and template_config:
|
|
478
|
+
template_config = apply_dotted_overrides(template_config, params)
|
|
479
|
+
|
|
480
|
+
# Set output directory relative to the run root.
|
|
481
|
+
if "output" not in template_config:
|
|
482
|
+
template_config["output"] = {}
|
|
483
|
+
template_config["output"]["dir"] = LATEST_OUTPUT_DIR
|
|
484
|
+
|
|
485
|
+
# Write beach.toml
|
|
486
|
+
if template_config:
|
|
487
|
+
if tomli_w is None:
|
|
488
|
+
msg = "tomli_w is required to write TOML files"
|
|
489
|
+
raise RuntimeError(msg)
|
|
490
|
+
beach_toml = input_dir / "beach.toml"
|
|
491
|
+
with open(beach_toml, "wb") as f:
|
|
492
|
+
tomli_w.dump(template_config, f)
|
|
493
|
+
created.append(_relative_to_run(beach_toml, run_dir))
|
|
494
|
+
|
|
495
|
+
# Copy OBJ mesh files if referenced
|
|
496
|
+
obj_path_str = template_config.get("mesh", {}).get("obj_path", "")
|
|
497
|
+
if obj_path_str:
|
|
498
|
+
obj_path = Path(obj_path_str)
|
|
499
|
+
if obj_path.is_file():
|
|
500
|
+
dest = input_dir / obj_path.name
|
|
501
|
+
shutil.copy2(obj_path, dest)
|
|
502
|
+
created.append(_relative_to_run(dest, run_dir))
|
|
503
|
+
|
|
504
|
+
return created
|
|
505
|
+
|
|
506
|
+
def resolve_runtime(
|
|
507
|
+
self,
|
|
508
|
+
simulator_config: dict[str, Any],
|
|
509
|
+
resolver_mode: str,
|
|
510
|
+
) -> dict[str, Any]:
|
|
511
|
+
"""Resolve the BEACH runtime (beach executable).
|
|
512
|
+
|
|
513
|
+
Args:
|
|
514
|
+
simulator_config: Simulator section from ``simulators.toml``.
|
|
515
|
+
resolver_mode: One of ``"package"``, ``"local_source"``,
|
|
516
|
+
``"local_executable"``.
|
|
517
|
+
|
|
518
|
+
Returns:
|
|
519
|
+
Runtime info dict.
|
|
520
|
+
|
|
521
|
+
Raises:
|
|
522
|
+
ValueError: If required keys are missing or mode is invalid.
|
|
523
|
+
"""
|
|
524
|
+
runtime: dict[str, Any] = {"resolver_mode": resolver_mode}
|
|
525
|
+
executable = simulator_config.get("executable", "beach")
|
|
526
|
+
|
|
527
|
+
venv_path = simulator_config.get("venv_path", "")
|
|
528
|
+
if not venv_path:
|
|
529
|
+
found = find_venv(Path.cwd())
|
|
530
|
+
if found:
|
|
531
|
+
venv_path = str(found)
|
|
532
|
+
if venv_path:
|
|
533
|
+
runtime["venv_path"] = venv_path
|
|
534
|
+
|
|
535
|
+
if resolver_mode == "package":
|
|
536
|
+
resolved = shutil.which(executable)
|
|
537
|
+
runtime["executable"] = resolved if resolved else executable
|
|
538
|
+
runtime["source"] = "package"
|
|
539
|
+
|
|
540
|
+
elif resolver_mode == "local_source":
|
|
541
|
+
source_repo = simulator_config.get("source_repo", "")
|
|
542
|
+
if not source_repo:
|
|
543
|
+
msg = "source_repo required for local_source mode"
|
|
544
|
+
raise ValueError(msg)
|
|
545
|
+
runtime["source_repo"] = source_repo
|
|
546
|
+
runtime["executable"] = executable
|
|
547
|
+
runtime["build_command"] = simulator_config.get(
|
|
548
|
+
"build_command", "make build"
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
elif resolver_mode == "local_executable":
|
|
552
|
+
exe_path = simulator_config.get("executable", "")
|
|
553
|
+
if not exe_path:
|
|
554
|
+
msg = "executable path required for local_executable mode"
|
|
555
|
+
raise ValueError(msg)
|
|
556
|
+
runtime["executable"] = exe_path
|
|
557
|
+
|
|
558
|
+
else:
|
|
559
|
+
msg = f"Unsupported resolver_mode: {resolver_mode}"
|
|
560
|
+
raise ValueError(msg)
|
|
561
|
+
|
|
562
|
+
return runtime
|
|
563
|
+
|
|
564
|
+
def build_program_command(
|
|
565
|
+
self,
|
|
566
|
+
runtime_info: dict[str, Any],
|
|
567
|
+
run_dir: Path,
|
|
568
|
+
) -> list[str]:
|
|
569
|
+
"""Build the BEACH execution command.
|
|
570
|
+
|
|
571
|
+
Args:
|
|
572
|
+
runtime_info: Output from :meth:`resolve_runtime`.
|
|
573
|
+
run_dir: The run directory.
|
|
574
|
+
|
|
575
|
+
Returns:
|
|
576
|
+
Command as a list of strings.
|
|
577
|
+
"""
|
|
578
|
+
executable = runtime_info.get("executable", "beach")
|
|
579
|
+
beach_toml = f"{INPUT_DIR}/beach.toml"
|
|
580
|
+
return [executable, beach_toml]
|
|
581
|
+
|
|
582
|
+
def detect_outputs(self, run_dir: Path) -> dict[str, Any]:
|
|
583
|
+
"""Detect BEACH output files.
|
|
584
|
+
|
|
585
|
+
Scans ``work/outputs/`` for known BEACH output files.
|
|
586
|
+
|
|
587
|
+
Args:
|
|
588
|
+
run_dir: The run directory.
|
|
589
|
+
|
|
590
|
+
Returns:
|
|
591
|
+
Dictionary of detected output labels to relative paths.
|
|
592
|
+
"""
|
|
593
|
+
outputs: dict[str, Any] = {}
|
|
594
|
+
work_dir = run_dir / WORK_DIR
|
|
595
|
+
|
|
596
|
+
# Search candidate output directories
|
|
597
|
+
for output_dir in (
|
|
598
|
+
work_dir / "latest",
|
|
599
|
+
work_dir / "outputs" / "latest",
|
|
600
|
+
work_dir / "outputs",
|
|
601
|
+
work_dir,
|
|
602
|
+
):
|
|
603
|
+
if not output_dir.is_dir():
|
|
604
|
+
continue
|
|
605
|
+
for label, filename in _OUTPUT_FILES.items():
|
|
606
|
+
f = output_dir / filename
|
|
607
|
+
if f.is_file():
|
|
608
|
+
outputs[label] = _relative_to_run(f, run_dir)
|
|
609
|
+
if outputs:
|
|
610
|
+
break
|
|
611
|
+
|
|
612
|
+
# Log files
|
|
613
|
+
logs: list[str] = []
|
|
614
|
+
for pattern in ("stdout.*.log", "stderr.*.log", "*.out", "*.err"):
|
|
615
|
+
for f in sorted(work_dir.glob(pattern)):
|
|
616
|
+
logs.append(_relative_to_run(f, run_dir))
|
|
617
|
+
if logs:
|
|
618
|
+
outputs["logs"] = logs
|
|
619
|
+
|
|
620
|
+
return outputs
|
|
621
|
+
|
|
622
|
+
def detect_status(self, run_dir: Path) -> str:
|
|
623
|
+
"""Infer BEACH simulation status from output files.
|
|
624
|
+
|
|
625
|
+
Detection logic:
|
|
626
|
+
|
|
627
|
+
1. If ``summary.txt`` exists -> ``"completed"``.
|
|
628
|
+
2. If error logs contain error keywords -> ``"failed"``.
|
|
629
|
+
3. If ``charges.csv`` exists (partial output) -> ``"running"``.
|
|
630
|
+
4. Otherwise -> ``"unknown"``.
|
|
631
|
+
|
|
632
|
+
Args:
|
|
633
|
+
run_dir: The run directory.
|
|
634
|
+
|
|
635
|
+
Returns:
|
|
636
|
+
A status string.
|
|
637
|
+
"""
|
|
638
|
+
work_dir = run_dir / WORK_DIR
|
|
639
|
+
|
|
640
|
+
# Check for summary.txt (written on normal completion)
|
|
641
|
+
for output_dir in (
|
|
642
|
+
work_dir / "latest",
|
|
643
|
+
work_dir / "outputs" / "latest",
|
|
644
|
+
work_dir / "outputs",
|
|
645
|
+
work_dir,
|
|
646
|
+
):
|
|
647
|
+
if (output_dir / "summary.txt").is_file():
|
|
648
|
+
return "completed"
|
|
649
|
+
|
|
650
|
+
# Check for errors in logs
|
|
651
|
+
for pattern in ("stderr.*.log", "*.err"):
|
|
652
|
+
for log in work_dir.glob(pattern):
|
|
653
|
+
try:
|
|
654
|
+
content = log.read_text(errors="replace")
|
|
655
|
+
if content.strip() and any(
|
|
656
|
+
kw in content.lower()
|
|
657
|
+
for kw in ("error", "fatal", "killed", "oom")
|
|
658
|
+
):
|
|
659
|
+
return "failed"
|
|
660
|
+
except OSError:
|
|
661
|
+
pass
|
|
662
|
+
|
|
663
|
+
# Partial outputs indicate running
|
|
664
|
+
for output_dir in (
|
|
665
|
+
work_dir / "latest",
|
|
666
|
+
work_dir / "outputs" / "latest",
|
|
667
|
+
work_dir / "outputs",
|
|
668
|
+
work_dir,
|
|
669
|
+
):
|
|
670
|
+
if (output_dir / "charges.csv").is_file():
|
|
671
|
+
return "running"
|
|
672
|
+
|
|
673
|
+
if work_dir.is_dir() and any(work_dir.iterdir()):
|
|
674
|
+
return "running"
|
|
675
|
+
|
|
676
|
+
return "unknown"
|
|
677
|
+
|
|
678
|
+
def summarize(self, run_dir: Path) -> dict[str, Any]:
|
|
679
|
+
"""Extract key metrics from BEACH outputs.
|
|
680
|
+
|
|
681
|
+
Parses ``summary.txt`` for simulation statistics and reads
|
|
682
|
+
configuration parameters from the input ``beach.toml``.
|
|
683
|
+
|
|
684
|
+
Args:
|
|
685
|
+
run_dir: The run directory.
|
|
686
|
+
|
|
687
|
+
Returns:
|
|
688
|
+
Summary dictionary.
|
|
689
|
+
"""
|
|
690
|
+
summary: dict[str, Any] = {}
|
|
691
|
+
work_dir = run_dir / WORK_DIR
|
|
692
|
+
|
|
693
|
+
summary["status"] = self.detect_status(run_dir)
|
|
694
|
+
|
|
695
|
+
# Parse summary.txt
|
|
696
|
+
for output_dir in (
|
|
697
|
+
work_dir / "latest",
|
|
698
|
+
work_dir / "outputs" / "latest",
|
|
699
|
+
work_dir / "outputs",
|
|
700
|
+
work_dir,
|
|
701
|
+
):
|
|
702
|
+
summary_file = output_dir / "summary.txt"
|
|
703
|
+
if summary_file.is_file():
|
|
704
|
+
try:
|
|
705
|
+
for line in summary_file.read_text().split("\n"):
|
|
706
|
+
line = line.strip()
|
|
707
|
+
if "=" not in line:
|
|
708
|
+
continue
|
|
709
|
+
key, value = line.split("=", 1)
|
|
710
|
+
key, value = key.strip(), value.strip()
|
|
711
|
+
try:
|
|
712
|
+
summary[key] = int(value)
|
|
713
|
+
except ValueError:
|
|
714
|
+
try:
|
|
715
|
+
summary[key] = float(value)
|
|
716
|
+
except ValueError:
|
|
717
|
+
summary[key] = value
|
|
718
|
+
except OSError:
|
|
719
|
+
pass
|
|
720
|
+
break
|
|
721
|
+
|
|
722
|
+
# Output counts
|
|
723
|
+
outputs = self.detect_outputs(run_dir)
|
|
724
|
+
summary["output_counts"] = {
|
|
725
|
+
k: len(v) if isinstance(v, list) else 1 for k, v in outputs.items()
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
# Config parameters
|
|
729
|
+
beach_toml = run_dir / INPUT_DIR / "beach.toml"
|
|
730
|
+
if beach_toml.is_file():
|
|
731
|
+
try:
|
|
732
|
+
with open(beach_toml, "rb") as f:
|
|
733
|
+
config = tomllib.load(f)
|
|
734
|
+
sim = config.get("sim", {})
|
|
735
|
+
for key in ("dt", "batch_count", "max_step", "field_solver"):
|
|
736
|
+
if key in sim:
|
|
737
|
+
summary[f"sim_{key}"] = sim[key]
|
|
738
|
+
except (tomllib.TOMLDecodeError, OSError):
|
|
739
|
+
pass
|
|
740
|
+
|
|
741
|
+
return summary
|
|
742
|
+
|
|
743
|
+
def collect_provenance(
|
|
744
|
+
self,
|
|
745
|
+
runtime_info: dict[str, Any],
|
|
746
|
+
) -> dict[str, Any]:
|
|
747
|
+
"""Collect BEACH provenance information.
|
|
748
|
+
|
|
749
|
+
Args:
|
|
750
|
+
runtime_info: Output from :meth:`resolve_runtime`.
|
|
751
|
+
|
|
752
|
+
Returns:
|
|
753
|
+
Provenance dict with executable hash and git info.
|
|
754
|
+
"""
|
|
755
|
+
provenance: dict[str, Any] = {
|
|
756
|
+
"resolver_mode": runtime_info.get("resolver_mode", ""),
|
|
757
|
+
"executable": runtime_info.get("executable", ""),
|
|
758
|
+
"exe_hash": "",
|
|
759
|
+
"git_commit": "",
|
|
760
|
+
"git_dirty": False,
|
|
761
|
+
"source_repo": runtime_info.get("source_repo", ""),
|
|
762
|
+
"build_command": runtime_info.get("build_command", ""),
|
|
763
|
+
"package_version": "",
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
# Executable hash
|
|
767
|
+
exe_path = Path(runtime_info.get("executable", ""))
|
|
768
|
+
if exe_path.is_file():
|
|
769
|
+
h = hashlib.sha256()
|
|
770
|
+
with exe_path.open("rb") as f:
|
|
771
|
+
for chunk in iter(lambda: f.read(8192), b""):
|
|
772
|
+
h.update(chunk)
|
|
773
|
+
provenance["exe_hash"] = f"sha256:{h.hexdigest()}"
|
|
774
|
+
|
|
775
|
+
# Git provenance for local_source
|
|
776
|
+
if runtime_info.get("resolver_mode") == "local_source":
|
|
777
|
+
repo = runtime_info.get("source_repo", "")
|
|
778
|
+
if repo and Path(repo).is_dir():
|
|
779
|
+
try:
|
|
780
|
+
result = subprocess.run(
|
|
781
|
+
["git", "rev-parse", "HEAD"],
|
|
782
|
+
capture_output=True,
|
|
783
|
+
text=True,
|
|
784
|
+
cwd=repo,
|
|
785
|
+
check=False,
|
|
786
|
+
)
|
|
787
|
+
if result.returncode == 0:
|
|
788
|
+
provenance["git_commit"] = result.stdout.strip()
|
|
789
|
+
result = subprocess.run(
|
|
790
|
+
["git", "status", "--porcelain"],
|
|
791
|
+
capture_output=True,
|
|
792
|
+
text=True,
|
|
793
|
+
cwd=repo,
|
|
794
|
+
check=False,
|
|
795
|
+
)
|
|
796
|
+
if result.returncode == 0:
|
|
797
|
+
provenance["git_dirty"] = bool(result.stdout.strip())
|
|
798
|
+
except FileNotFoundError:
|
|
799
|
+
logger.debug("git not found; skipping git provenance")
|
|
800
|
+
|
|
801
|
+
return provenance
|
|
802
|
+
|
|
803
|
+
# ------------------------------------------------------------------
|
|
804
|
+
# BEACH-specific helpers (used by CLI / jobgen integration)
|
|
805
|
+
# ------------------------------------------------------------------
|
|
806
|
+
|
|
807
|
+
def get_setup_commands(self, run_dir: Path) -> list[str]:
|
|
808
|
+
"""Return setup commands for the BEACH job script."""
|
|
809
|
+
beach_toml = run_dir / INPUT_DIR / "beach.toml"
|
|
810
|
+
return [
|
|
811
|
+
"date",
|
|
812
|
+
f"beach-estimate-workload {beach_toml}"
|
|
813
|
+
" --mpi-ranks $SLURM_NTASKS 2>/dev/null || true",
|
|
814
|
+
]
|
|
815
|
+
|
|
816
|
+
def get_post_commands(self, run_dir: Path) -> list[str]:
|
|
817
|
+
"""Return post-execution commands for the BEACH job script."""
|
|
818
|
+
output_dir = run_dir / WORK_DIR / "latest"
|
|
819
|
+
return [
|
|
820
|
+
"date",
|
|
821
|
+
f"beach-inspect {output_dir}"
|
|
822
|
+
f" --save-bar {output_dir}/charges_bar.png"
|
|
823
|
+
f" --save-mesh {output_dir}/charges_mesh.png"
|
|
824
|
+
" 2>/dev/null || true",
|
|
825
|
+
]
|
|
826
|
+
|
|
827
|
+
def get_modules(self) -> list[str]:
|
|
828
|
+
"""Return default modules for BEACH."""
|
|
829
|
+
return ["intel/2023.2", "intelmpi/2023.2"]
|
|
830
|
+
|
|
831
|
+
def get_extra_env(self) -> dict[str, str]:
|
|
832
|
+
"""Return default environment variables for BEACH."""
|
|
833
|
+
return {
|
|
834
|
+
"OMP_NUM_THREADS": "${SLURM_DPC_CPUS:-1}",
|
|
835
|
+
"OMP_PROC_BIND": "spread",
|
|
836
|
+
"OMP_PLACES": "cores",
|
|
837
|
+
}
|