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,1010 @@
|
|
|
1
|
+
"""EMSES (Electromagnetic Particle-in-Cell) simulator adapter.
|
|
2
|
+
|
|
3
|
+
Handles EMSES TOML configuration (plasma.toml), HDF5/ASCII output
|
|
4
|
+
detection, and MPI-based execution via srun.
|
|
5
|
+
|
|
6
|
+
EMSES now uses TOML configuration (format_version 2 with structured
|
|
7
|
+
``[[species]]``, ``[[ptcond.objects]]``, etc.). Legacy Fortran
|
|
8
|
+
namelist (plasma.inp) is no longer required.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
import logging
|
|
15
|
+
import shutil
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
if sys.version_info >= (3, 11):
|
|
22
|
+
import tomllib
|
|
23
|
+
else:
|
|
24
|
+
import tomli as tomllib
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
import tomli_w
|
|
28
|
+
except ImportError:
|
|
29
|
+
tomli_w = None # type: ignore[assignment]
|
|
30
|
+
|
|
31
|
+
from runops.adapters._utils import find_venv
|
|
32
|
+
from runops.adapters._utils.toml_utils import apply_dotted_overrides
|
|
33
|
+
from runops.adapters.base import SimulatorAdapter
|
|
34
|
+
from runops.core.validation import ValidationIssue
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
INPUT_DIR = "input"
|
|
39
|
+
WORK_DIR = "work"
|
|
40
|
+
LATEST_OUTPUT_DIR = f"{WORK_DIR}/latest"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _relative_to_run(path: Path, run_dir: Path) -> str:
|
|
44
|
+
"""Return a stable POSIX-style relative path under the run directory."""
|
|
45
|
+
return path.relative_to(run_dir).as_posix()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# Domain decomposition: [mpi] group, nodes = [nxdiv, nydiv, nzdiv]
|
|
49
|
+
DOMAIN_DECOMP_SECTION = "mpi"
|
|
50
|
+
DOMAIN_DECOMP_KEY = "nodes"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def compute_mpi_processes(config: dict[str, Any]) -> int | None:
|
|
54
|
+
"""Compute the required MPI process count from domain decomposition.
|
|
55
|
+
|
|
56
|
+
In MPIEMSES3D, the ``[mpi]`` section's ``nodes`` parameter
|
|
57
|
+
(a list ``[nxdiv, nydiv, nzdiv]``) defines the domain
|
|
58
|
+
decomposition. Total processes = product(nodes).
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
config: Parsed plasma.toml configuration dictionary.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
Total MPI process count, or ``None`` if nodes is not specified.
|
|
65
|
+
"""
|
|
66
|
+
mpi_section = config.get(DOMAIN_DECOMP_SECTION, {})
|
|
67
|
+
nodes = mpi_section.get(DOMAIN_DECOMP_KEY)
|
|
68
|
+
if nodes is None:
|
|
69
|
+
return None
|
|
70
|
+
if isinstance(nodes, (list, tuple)):
|
|
71
|
+
result = 1
|
|
72
|
+
for n in nodes:
|
|
73
|
+
result *= int(n)
|
|
74
|
+
return result
|
|
75
|
+
return int(nodes)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class EmseAdapter(SimulatorAdapter):
|
|
79
|
+
"""Adapter for the EMSES electromagnetic PIC simulator.
|
|
80
|
+
|
|
81
|
+
EMSES uses TOML configuration files (``plasma.toml``) and produces
|
|
82
|
+
HDF5 field data and ASCII time-series diagnostics.
|
|
83
|
+
|
|
84
|
+
Class Attributes:
|
|
85
|
+
adapter_name: Registry key for this adapter.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
adapter_name: str = "emses"
|
|
89
|
+
|
|
90
|
+
# ------------------------------------------------------------------
|
|
91
|
+
# SimulatorAdapter interface
|
|
92
|
+
# ------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
@classmethod
|
|
95
|
+
def default_config(cls) -> dict[str, Any]:
|
|
96
|
+
"""Return default simulators.toml entry for EMSES."""
|
|
97
|
+
return {
|
|
98
|
+
"adapter": "emses",
|
|
99
|
+
"resolver_mode": "package",
|
|
100
|
+
"executable": "mpiemses3D",
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
@classmethod
|
|
104
|
+
def interactive_config(cls) -> dict[str, Any]:
|
|
105
|
+
"""Interactively prompt for EMSES configuration."""
|
|
106
|
+
import typer
|
|
107
|
+
|
|
108
|
+
typer.echo("\n Configuring 'emses' simulator (EMSES PIC):")
|
|
109
|
+
|
|
110
|
+
resolver_mode = typer.prompt(
|
|
111
|
+
" Resolver mode (package / local_executable / local_source)",
|
|
112
|
+
default="package",
|
|
113
|
+
)
|
|
114
|
+
executable = typer.prompt(
|
|
115
|
+
" Executable path or name",
|
|
116
|
+
default="mpiemses3D",
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
config: dict[str, Any] = {
|
|
120
|
+
"adapter": "emses",
|
|
121
|
+
"resolver_mode": resolver_mode,
|
|
122
|
+
"executable": executable,
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if resolver_mode == "local_source":
|
|
126
|
+
config["source_repo"] = typer.prompt(
|
|
127
|
+
" EMSES source repository path", default=""
|
|
128
|
+
)
|
|
129
|
+
config["build_command"] = typer.prompt(
|
|
130
|
+
" Build command", default="make -j"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
return config
|
|
134
|
+
|
|
135
|
+
@classmethod
|
|
136
|
+
def case_template(cls) -> dict[str, str]:
|
|
137
|
+
"""Return template files for a new EMSES case."""
|
|
138
|
+
from runops.templates import load_static
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
"case.toml": load_static("adapters/emses/case.toml"),
|
|
142
|
+
"plasma.toml": load_static("adapters/emses/plasma.toml"),
|
|
143
|
+
"summarize.py": load_static("adapters/emses/summarize.py"),
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
@classmethod
|
|
147
|
+
def pip_packages(cls) -> list[str]:
|
|
148
|
+
"""Return pip packages for EMSES (simulator + analysis tools)."""
|
|
149
|
+
return [
|
|
150
|
+
"MPIEMSES3D @ git+https://github.com/CS12-Laboratory/MPIEMSES3D.git",
|
|
151
|
+
"emout",
|
|
152
|
+
"h5py",
|
|
153
|
+
"matplotlib",
|
|
154
|
+
"numpy",
|
|
155
|
+
]
|
|
156
|
+
|
|
157
|
+
@classmethod
|
|
158
|
+
def doc_repos(cls) -> list[tuple[str, str]]:
|
|
159
|
+
"""Return documentation repos for EMSES."""
|
|
160
|
+
return [
|
|
161
|
+
(
|
|
162
|
+
"https://github.com/CS12-Laboratory/MPIEMSES3D.git",
|
|
163
|
+
"MPIEMSES3D",
|
|
164
|
+
),
|
|
165
|
+
(
|
|
166
|
+
"https://github.com/Nkzono99/emout.git",
|
|
167
|
+
"emout",
|
|
168
|
+
),
|
|
169
|
+
]
|
|
170
|
+
|
|
171
|
+
@classmethod
|
|
172
|
+
def knowledge_sources(cls) -> dict[str, list[str]]:
|
|
173
|
+
"""Return knowledge-relevant file patterns for EMSES repos."""
|
|
174
|
+
return {
|
|
175
|
+
"MPIEMSES3D": [
|
|
176
|
+
"README.md",
|
|
177
|
+
"docs/**/*.md",
|
|
178
|
+
"schemas/*.json",
|
|
179
|
+
"examples/**/*.toml",
|
|
180
|
+
"cookbook/COOKBOOK.md",
|
|
181
|
+
"cookbook/index.toml",
|
|
182
|
+
"cookbook/**/*.toml",
|
|
183
|
+
"cookbook/**/*.md",
|
|
184
|
+
],
|
|
185
|
+
"emout": [
|
|
186
|
+
"README.md",
|
|
187
|
+
"docs/agent-user-guide.md",
|
|
188
|
+
],
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
@classmethod
|
|
192
|
+
def parameter_schema(cls) -> dict[str, dict[str, Any]]:
|
|
193
|
+
"""Return EMSES parameter schema."""
|
|
194
|
+
return {
|
|
195
|
+
"jobcon.nstep": {
|
|
196
|
+
"type": "int",
|
|
197
|
+
"unit": "",
|
|
198
|
+
"description": "Total simulation time steps",
|
|
199
|
+
"range": [1, None],
|
|
200
|
+
"default": 10000,
|
|
201
|
+
"constraints": [],
|
|
202
|
+
"interdependencies": [],
|
|
203
|
+
},
|
|
204
|
+
"tmgrid.dt": {
|
|
205
|
+
"type": "float",
|
|
206
|
+
"unit": "1/omega_pe",
|
|
207
|
+
"description": "Time step in normalized units",
|
|
208
|
+
"range": [0.0, None],
|
|
209
|
+
"default": 1.0,
|
|
210
|
+
"constraints": ["cfl_condition"],
|
|
211
|
+
"derived_from": "Must satisfy dt < dx / cv",
|
|
212
|
+
"interdependencies": [
|
|
213
|
+
"tmgrid.nx",
|
|
214
|
+
"plasma.cv",
|
|
215
|
+
],
|
|
216
|
+
},
|
|
217
|
+
"tmgrid.nx": {
|
|
218
|
+
"type": "int",
|
|
219
|
+
"unit": "cells",
|
|
220
|
+
"description": "Grid cells in X direction",
|
|
221
|
+
"range": [1, None],
|
|
222
|
+
"default": 64,
|
|
223
|
+
"constraints": ["debye_resolution", "grid_divisibility"],
|
|
224
|
+
"interdependencies": ["mpi.nodes"],
|
|
225
|
+
},
|
|
226
|
+
"tmgrid.ny": {
|
|
227
|
+
"type": "int",
|
|
228
|
+
"unit": "cells",
|
|
229
|
+
"description": "Grid cells in Y direction",
|
|
230
|
+
"range": [1, None],
|
|
231
|
+
"default": 64,
|
|
232
|
+
"constraints": ["debye_resolution", "grid_divisibility"],
|
|
233
|
+
"interdependencies": ["mpi.nodes"],
|
|
234
|
+
},
|
|
235
|
+
"tmgrid.nz": {
|
|
236
|
+
"type": "int",
|
|
237
|
+
"unit": "cells",
|
|
238
|
+
"description": "Grid cells in Z direction",
|
|
239
|
+
"range": [1, None],
|
|
240
|
+
"default": 64,
|
|
241
|
+
"constraints": ["debye_resolution", "grid_divisibility"],
|
|
242
|
+
"interdependencies": ["mpi.nodes"],
|
|
243
|
+
},
|
|
244
|
+
"plasma.cv": {
|
|
245
|
+
"type": "float",
|
|
246
|
+
"unit": "dx/dt_norm",
|
|
247
|
+
"description": "Speed of light in normalized units",
|
|
248
|
+
"range": [0.0, None],
|
|
249
|
+
"default": 1.0,
|
|
250
|
+
"constraints": ["cfl_condition"],
|
|
251
|
+
"interdependencies": ["tmgrid.dt"],
|
|
252
|
+
},
|
|
253
|
+
"mpi.nodes": {
|
|
254
|
+
"type": "list[int]",
|
|
255
|
+
"unit": "",
|
|
256
|
+
"description": (
|
|
257
|
+
"Domain decomposition [nxdiv, nydiv, nzdiv]. "
|
|
258
|
+
"Product must equal ntasks."
|
|
259
|
+
),
|
|
260
|
+
"range": [1, None],
|
|
261
|
+
"constraints": [
|
|
262
|
+
"domain_decomp_consistency",
|
|
263
|
+
"grid_divisibility",
|
|
264
|
+
],
|
|
265
|
+
"interdependencies": [
|
|
266
|
+
"tmgrid.nx",
|
|
267
|
+
"tmgrid.ny",
|
|
268
|
+
"tmgrid.nz",
|
|
269
|
+
],
|
|
270
|
+
},
|
|
271
|
+
"species.N.wp": {
|
|
272
|
+
"type": "float",
|
|
273
|
+
"unit": "omega_pe",
|
|
274
|
+
"description": "Plasma frequency of species N",
|
|
275
|
+
"range": [0.0, None],
|
|
276
|
+
"derived_from": "sqrt(n * q^2 / (m * eps0))",
|
|
277
|
+
"constraints": ["debye_resolution"],
|
|
278
|
+
"interdependencies": ["species.N.qm", "tmgrid.nx"],
|
|
279
|
+
},
|
|
280
|
+
"species.N.qm": {
|
|
281
|
+
"type": "float",
|
|
282
|
+
"unit": "e/m_e",
|
|
283
|
+
"description": "Charge-to-mass ratio of species N",
|
|
284
|
+
"interdependencies": ["species.N.wp"],
|
|
285
|
+
},
|
|
286
|
+
"species.N.npin": {
|
|
287
|
+
"type": "int",
|
|
288
|
+
"unit": "",
|
|
289
|
+
"description": "Number of macro-particles for species N",
|
|
290
|
+
"range": [0, None],
|
|
291
|
+
},
|
|
292
|
+
"emfield.ex0": {
|
|
293
|
+
"type": "float",
|
|
294
|
+
"unit": "normalized",
|
|
295
|
+
"description": "External electric field (X)",
|
|
296
|
+
"default": 0.0,
|
|
297
|
+
},
|
|
298
|
+
"emfield.bx0": {
|
|
299
|
+
"type": "float",
|
|
300
|
+
"unit": "normalized",
|
|
301
|
+
"description": "External magnetic field (X)",
|
|
302
|
+
"default": 0.0,
|
|
303
|
+
},
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
@classmethod
|
|
307
|
+
def default_plot_recipes(cls) -> dict[str, dict[str, Any]]:
|
|
308
|
+
"""Return default survey plot recipes for EMSES studies."""
|
|
309
|
+
return {
|
|
310
|
+
"completion-vs-dt": {
|
|
311
|
+
"description": (
|
|
312
|
+
"Check how far each run advanced as the EMSES timestep changes."
|
|
313
|
+
),
|
|
314
|
+
"x": ["param.tmgrid.dt", "dt"],
|
|
315
|
+
"y": ["last_step"],
|
|
316
|
+
"kind": "line",
|
|
317
|
+
"group_by": ["origin.case"],
|
|
318
|
+
"title": "EMSES completion vs dt",
|
|
319
|
+
},
|
|
320
|
+
"progress-vs-target": {
|
|
321
|
+
"description": (
|
|
322
|
+
"Compare achieved steps against requested nstep across runs."
|
|
323
|
+
),
|
|
324
|
+
"x": ["nstep"],
|
|
325
|
+
"y": ["last_step"],
|
|
326
|
+
"kind": "scatter",
|
|
327
|
+
"group_by": ["origin.case"],
|
|
328
|
+
"title": "EMSES progress vs target steps",
|
|
329
|
+
},
|
|
330
|
+
"field-output-vs-nx": {
|
|
331
|
+
"description": (
|
|
332
|
+
"Track how many HDF5 field outputs were produced at each "
|
|
333
|
+
"x-grid size."
|
|
334
|
+
),
|
|
335
|
+
"x": ["param.tmgrid.nx", "nx"],
|
|
336
|
+
"y": ["output_counts.hdf5_fields"],
|
|
337
|
+
"kind": "line",
|
|
338
|
+
"group_by": ["origin.case"],
|
|
339
|
+
"title": "EMSES field outputs vs nx",
|
|
340
|
+
},
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
def validate_params(
|
|
344
|
+
self,
|
|
345
|
+
case_data: dict[str, Any],
|
|
346
|
+
) -> list[ValidationIssue]:
|
|
347
|
+
"""Validate EMSES parameters against physics constraints.
|
|
348
|
+
|
|
349
|
+
Checks: CFL condition, Debye length resolution, domain
|
|
350
|
+
decomposition consistency, and grid divisibility.
|
|
351
|
+
"""
|
|
352
|
+
issues: list[ValidationIssue] = []
|
|
353
|
+
config = self._resolve_config(case_data)
|
|
354
|
+
if not config:
|
|
355
|
+
return issues
|
|
356
|
+
|
|
357
|
+
tmgrid = config.get("tmgrid", {})
|
|
358
|
+
plasma = config.get("plasma", {})
|
|
359
|
+
esorem = config.get("esorem", {})
|
|
360
|
+
mpi_sec = config.get(DOMAIN_DECOMP_SECTION, {})
|
|
361
|
+
species_list = config.get("species", [])
|
|
362
|
+
|
|
363
|
+
dt = tmgrid.get("dt")
|
|
364
|
+
nx = tmgrid.get("nx")
|
|
365
|
+
ny = tmgrid.get("ny")
|
|
366
|
+
nz = tmgrid.get("nz")
|
|
367
|
+
cv = plasma.get("cv", 1.0)
|
|
368
|
+
|
|
369
|
+
# CFL condition: dt * cv < dx (dx = 1.0 in normalized units)
|
|
370
|
+
# Only applies to electromagnetic mode (emflag=1); in electrostatic
|
|
371
|
+
# mode (emflag=0) cv is a normalization constant, not a wave speed.
|
|
372
|
+
emflag = esorem.get("emflag", 1)
|
|
373
|
+
if dt is not None and cv is not None and emflag != 0:
|
|
374
|
+
cfl_ratio = float(dt) * float(cv)
|
|
375
|
+
if cfl_ratio >= 1.0:
|
|
376
|
+
issues.append(
|
|
377
|
+
ValidationIssue(
|
|
378
|
+
severity="error",
|
|
379
|
+
message=(
|
|
380
|
+
f"CFL condition violated: dt*cv = {cfl_ratio:.3f} >= 1.0. "
|
|
381
|
+
f"Reduce dt below {1.0 / float(cv):.3f}."
|
|
382
|
+
),
|
|
383
|
+
parameter="tmgrid.dt",
|
|
384
|
+
constraint_name="cfl_condition",
|
|
385
|
+
details={
|
|
386
|
+
"dt": dt,
|
|
387
|
+
"cv": cv,
|
|
388
|
+
"cfl_ratio": cfl_ratio,
|
|
389
|
+
"max_dt": 1.0 / float(cv),
|
|
390
|
+
},
|
|
391
|
+
)
|
|
392
|
+
)
|
|
393
|
+
elif cfl_ratio > 0.8:
|
|
394
|
+
issues.append(
|
|
395
|
+
ValidationIssue(
|
|
396
|
+
severity="warning",
|
|
397
|
+
message=(
|
|
398
|
+
f"CFL ratio dt*cv = {cfl_ratio:.3f} is close to "
|
|
399
|
+
f"stability limit (1.0). Consider reducing dt."
|
|
400
|
+
),
|
|
401
|
+
parameter="tmgrid.dt",
|
|
402
|
+
constraint_name="cfl_condition",
|
|
403
|
+
details={"cfl_ratio": cfl_ratio},
|
|
404
|
+
)
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
# Debye length resolution check
|
|
408
|
+
for i, sp in enumerate(species_list):
|
|
409
|
+
wp = sp.get("wp")
|
|
410
|
+
vdthz = sp.get("vdthz") or sp.get("vdth", {}).get("z")
|
|
411
|
+
if wp and vdthz and float(wp) > 0:
|
|
412
|
+
debye = float(vdthz) / float(wp)
|
|
413
|
+
# dx = 1.0 in normalized units; want debye >= ~0.5 dx
|
|
414
|
+
if debye < 0.5:
|
|
415
|
+
issues.append(
|
|
416
|
+
ValidationIssue(
|
|
417
|
+
severity="warning",
|
|
418
|
+
message=(
|
|
419
|
+
f"Species {i}: Debye length ({debye:.3f} dx) "
|
|
420
|
+
f"is under-resolved by grid (dx=1). "
|
|
421
|
+
f"Increase grid resolution or reduce density."
|
|
422
|
+
),
|
|
423
|
+
parameter=f"species.{i}.wp",
|
|
424
|
+
constraint_name="debye_resolution",
|
|
425
|
+
details={
|
|
426
|
+
"species_index": i,
|
|
427
|
+
"debye_length": debye,
|
|
428
|
+
"wp": float(wp),
|
|
429
|
+
"vdthz": float(vdthz),
|
|
430
|
+
},
|
|
431
|
+
)
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
# Domain decomposition consistency
|
|
435
|
+
nodes = mpi_sec.get(DOMAIN_DECOMP_KEY)
|
|
436
|
+
if nodes and isinstance(nodes, (list, tuple)):
|
|
437
|
+
total_procs = 1
|
|
438
|
+
for n in nodes:
|
|
439
|
+
total_procs *= int(n)
|
|
440
|
+
|
|
441
|
+
# Grid divisibility
|
|
442
|
+
dims = [("nx", nx), ("ny", ny), ("nz", nz)]
|
|
443
|
+
for idx, (dim_name, dim_val) in enumerate(dims):
|
|
444
|
+
if dim_val is not None and idx < len(nodes):
|
|
445
|
+
ndiv = int(nodes[idx])
|
|
446
|
+
if int(dim_val) % ndiv != 0:
|
|
447
|
+
issues.append(
|
|
448
|
+
ValidationIssue(
|
|
449
|
+
severity="error",
|
|
450
|
+
message=(
|
|
451
|
+
f"Grid {dim_name}={dim_val} is not "
|
|
452
|
+
f"divisible by MPI decomposition "
|
|
453
|
+
f"nodes[{idx}]={ndiv}."
|
|
454
|
+
),
|
|
455
|
+
parameter=f"tmgrid.{dim_name}",
|
|
456
|
+
constraint_name="grid_divisibility",
|
|
457
|
+
details={
|
|
458
|
+
"dimension": dim_name,
|
|
459
|
+
"grid_size": int(dim_val),
|
|
460
|
+
"mpi_division": ndiv,
|
|
461
|
+
},
|
|
462
|
+
)
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
return issues
|
|
466
|
+
|
|
467
|
+
@staticmethod
|
|
468
|
+
def _resolve_config(case_data: dict[str, Any]) -> dict[str, Any]:
|
|
469
|
+
"""Load template config and apply param overrides."""
|
|
470
|
+
case_section = case_data.get("case", {})
|
|
471
|
+
params = case_data.get("params", {})
|
|
472
|
+
config: dict[str, Any] = {}
|
|
473
|
+
|
|
474
|
+
case_dir_str = case_section.get("case_dir", "")
|
|
475
|
+
if case_dir_str:
|
|
476
|
+
candidate = Path(case_dir_str) / "plasma.toml"
|
|
477
|
+
if candidate.is_file():
|
|
478
|
+
with open(candidate, "rb") as f:
|
|
479
|
+
config = tomllib.load(f)
|
|
480
|
+
|
|
481
|
+
if params and config:
|
|
482
|
+
config = apply_dotted_overrides(config, params)
|
|
483
|
+
|
|
484
|
+
return config
|
|
485
|
+
|
|
486
|
+
@classmethod
|
|
487
|
+
def agent_guide(cls) -> str:
|
|
488
|
+
"""Return AI agent guide for EMSES."""
|
|
489
|
+
from runops.templates import load_static
|
|
490
|
+
|
|
491
|
+
return load_static("adapters/emses/agent_guide.md")
|
|
492
|
+
|
|
493
|
+
@property
|
|
494
|
+
def name(self) -> str:
|
|
495
|
+
"""Return the canonical name of this adapter."""
|
|
496
|
+
return self.adapter_name
|
|
497
|
+
|
|
498
|
+
def render_inputs(
|
|
499
|
+
self,
|
|
500
|
+
case_data: dict[str, Any],
|
|
501
|
+
run_dir: Path,
|
|
502
|
+
) -> list[str]:
|
|
503
|
+
"""Generate EMSES input files in the run directory.
|
|
504
|
+
|
|
505
|
+
Reads ``plasma.toml`` from the case directory, applies parameter
|
|
506
|
+
overrides via dot-notation, and writes to ``<run_dir>/input/plasma.toml``.
|
|
507
|
+
|
|
508
|
+
Args:
|
|
509
|
+
case_data: Merged case/survey parameters. Expects a
|
|
510
|
+
``case`` section with ``case_dir`` pointing to the
|
|
511
|
+
template directory, and an optional ``params`` section.
|
|
512
|
+
run_dir: Target run directory.
|
|
513
|
+
|
|
514
|
+
Returns:
|
|
515
|
+
List of relative paths to generated input files.
|
|
516
|
+
|
|
517
|
+
Raises:
|
|
518
|
+
ValueError: If the case section is missing.
|
|
519
|
+
RuntimeError: If ``tomli_w`` is not installed.
|
|
520
|
+
"""
|
|
521
|
+
case_section = case_data.get("case", {})
|
|
522
|
+
if not case_section:
|
|
523
|
+
msg = "case_data must contain a 'case' section"
|
|
524
|
+
raise ValueError(msg)
|
|
525
|
+
|
|
526
|
+
params = case_data.get("params", {})
|
|
527
|
+
input_dir = run_dir / INPUT_DIR
|
|
528
|
+
input_dir.mkdir(parents=True, exist_ok=True)
|
|
529
|
+
(run_dir / WORK_DIR / "latest").mkdir(parents=True, exist_ok=True)
|
|
530
|
+
|
|
531
|
+
created: list[str] = []
|
|
532
|
+
|
|
533
|
+
# Locate template plasma.toml from case directory
|
|
534
|
+
case_dir_str = case_section.get("case_dir", "")
|
|
535
|
+
template_config: dict[str, Any] = {}
|
|
536
|
+
|
|
537
|
+
if case_dir_str:
|
|
538
|
+
case_dir = Path(case_dir_str)
|
|
539
|
+
# Look in input/ subdirectory first, then case root for compat
|
|
540
|
+
for candidate in (
|
|
541
|
+
case_dir / "input" / "plasma.toml",
|
|
542
|
+
case_dir / "plasma.toml",
|
|
543
|
+
):
|
|
544
|
+
if candidate.is_file():
|
|
545
|
+
with open(candidate, "rb") as f:
|
|
546
|
+
template_config = tomllib.load(f)
|
|
547
|
+
break
|
|
548
|
+
|
|
549
|
+
# Also check explicit input_files list
|
|
550
|
+
input_files: list[str] = case_section.get("input_files", [])
|
|
551
|
+
for src_str in input_files:
|
|
552
|
+
src = Path(src_str)
|
|
553
|
+
if src.suffix == ".toml" and src.is_file():
|
|
554
|
+
if not template_config:
|
|
555
|
+
with open(src, "rb") as f:
|
|
556
|
+
template_config = tomllib.load(f)
|
|
557
|
+
elif src.name != "plasma.toml":
|
|
558
|
+
dest = input_dir / src.name
|
|
559
|
+
shutil.copy2(src, dest)
|
|
560
|
+
created.append(_relative_to_run(dest, run_dir))
|
|
561
|
+
|
|
562
|
+
# Apply parameter overrides
|
|
563
|
+
if params and template_config:
|
|
564
|
+
template_config = apply_dotted_overrides(template_config, params)
|
|
565
|
+
|
|
566
|
+
# Write plasma.toml
|
|
567
|
+
if template_config:
|
|
568
|
+
if tomli_w is None:
|
|
569
|
+
msg = "tomli_w is required to write TOML files"
|
|
570
|
+
raise RuntimeError(msg)
|
|
571
|
+
plasma_toml = input_dir / "plasma.toml"
|
|
572
|
+
with open(plasma_toml, "wb") as f:
|
|
573
|
+
tomli_w.dump(template_config, f)
|
|
574
|
+
created.append(_relative_to_run(plasma_toml, run_dir))
|
|
575
|
+
|
|
576
|
+
# Copy additional input files (e.g., mesh files)
|
|
577
|
+
for src_str in input_files:
|
|
578
|
+
src = Path(src_str)
|
|
579
|
+
if not src.is_file():
|
|
580
|
+
logger.warning("Input file not found, skipping: %s", src)
|
|
581
|
+
continue
|
|
582
|
+
if src.suffix == ".toml":
|
|
583
|
+
continue # Already handled
|
|
584
|
+
dest = input_dir / src.name
|
|
585
|
+
shutil.copy2(src, dest)
|
|
586
|
+
created.append(_relative_to_run(dest, run_dir))
|
|
587
|
+
|
|
588
|
+
return created
|
|
589
|
+
|
|
590
|
+
def resolve_runtime(
|
|
591
|
+
self,
|
|
592
|
+
simulator_config: dict[str, Any],
|
|
593
|
+
resolver_mode: str,
|
|
594
|
+
) -> dict[str, Any]:
|
|
595
|
+
"""Resolve the EMSES runtime (mpiemses3D executable).
|
|
596
|
+
|
|
597
|
+
Args:
|
|
598
|
+
simulator_config: Simulator section from ``simulators.toml``.
|
|
599
|
+
resolver_mode: One of ``"package"``, ``"local_source"``,
|
|
600
|
+
``"local_executable"``.
|
|
601
|
+
|
|
602
|
+
Returns:
|
|
603
|
+
Runtime info dict with at least ``executable`` and
|
|
604
|
+
``resolver_mode`` keys.
|
|
605
|
+
|
|
606
|
+
Raises:
|
|
607
|
+
ValueError: If required keys are missing or mode is invalid.
|
|
608
|
+
"""
|
|
609
|
+
runtime: dict[str, Any] = {"resolver_mode": resolver_mode}
|
|
610
|
+
executable = simulator_config.get("executable", "mpiemses3D")
|
|
611
|
+
|
|
612
|
+
venv_path = simulator_config.get("venv_path", "")
|
|
613
|
+
if not venv_path:
|
|
614
|
+
found = find_venv(Path.cwd())
|
|
615
|
+
if found:
|
|
616
|
+
venv_path = str(found)
|
|
617
|
+
if venv_path:
|
|
618
|
+
runtime["venv_path"] = venv_path
|
|
619
|
+
|
|
620
|
+
if resolver_mode == "package":
|
|
621
|
+
resolved = shutil.which(executable)
|
|
622
|
+
runtime["executable"] = resolved if resolved else executable
|
|
623
|
+
runtime["source"] = "package"
|
|
624
|
+
|
|
625
|
+
elif resolver_mode == "local_source":
|
|
626
|
+
source_repo = simulator_config.get("source_repo", "")
|
|
627
|
+
if not source_repo:
|
|
628
|
+
msg = "source_repo required for local_source mode"
|
|
629
|
+
raise ValueError(msg)
|
|
630
|
+
runtime["source_repo"] = source_repo
|
|
631
|
+
runtime["executable"] = executable
|
|
632
|
+
runtime["build_command"] = simulator_config.get("build_command", "")
|
|
633
|
+
|
|
634
|
+
elif resolver_mode == "local_executable":
|
|
635
|
+
exe_path = simulator_config.get("executable", "")
|
|
636
|
+
if not exe_path:
|
|
637
|
+
msg = "executable path required for local_executable mode"
|
|
638
|
+
raise ValueError(msg)
|
|
639
|
+
runtime["executable"] = exe_path
|
|
640
|
+
|
|
641
|
+
else:
|
|
642
|
+
msg = f"Unsupported resolver_mode: {resolver_mode}"
|
|
643
|
+
raise ValueError(msg)
|
|
644
|
+
|
|
645
|
+
return runtime
|
|
646
|
+
|
|
647
|
+
def build_program_command(
|
|
648
|
+
self,
|
|
649
|
+
runtime_info: dict[str, Any],
|
|
650
|
+
run_dir: Path,
|
|
651
|
+
) -> list[str]:
|
|
652
|
+
"""Build the EMSES execution command.
|
|
653
|
+
|
|
654
|
+
Returns a command that runs ``mpiemses3D`` with ``plasma.toml``.
|
|
655
|
+
|
|
656
|
+
Args:
|
|
657
|
+
runtime_info: Output from :meth:`resolve_runtime`.
|
|
658
|
+
run_dir: The run directory.
|
|
659
|
+
|
|
660
|
+
Returns:
|
|
661
|
+
Command as a list of strings.
|
|
662
|
+
"""
|
|
663
|
+
executable = runtime_info.get("executable", "mpiemses3D")
|
|
664
|
+
plasma_toml = f"{INPUT_DIR}/plasma.toml"
|
|
665
|
+
return [executable, plasma_toml, "-o", LATEST_OUTPUT_DIR]
|
|
666
|
+
|
|
667
|
+
def detect_outputs(self, run_dir: Path) -> dict[str, Any]:
|
|
668
|
+
"""Detect EMSES output files in ``work/``.
|
|
669
|
+
|
|
670
|
+
Scans for HDF5 field data, ASCII diagnostics, and snapshot files.
|
|
671
|
+
|
|
672
|
+
Args:
|
|
673
|
+
run_dir: The run directory.
|
|
674
|
+
|
|
675
|
+
Returns:
|
|
676
|
+
Dictionary of output categories to file lists.
|
|
677
|
+
"""
|
|
678
|
+
work_dir = run_dir / WORK_DIR
|
|
679
|
+
if not work_dir.is_dir():
|
|
680
|
+
return {}
|
|
681
|
+
|
|
682
|
+
outputs: dict[str, Any] = {}
|
|
683
|
+
|
|
684
|
+
log_patterns = {"*.out", "*.err", "*.log"}
|
|
685
|
+
for output_dir in (work_dir / "latest", work_dir):
|
|
686
|
+
if not output_dir.is_dir():
|
|
687
|
+
continue
|
|
688
|
+
|
|
689
|
+
h5_files = sorted(output_dir.glob("*.h5"))
|
|
690
|
+
if h5_files:
|
|
691
|
+
outputs["hdf5_fields"] = [
|
|
692
|
+
_relative_to_run(f, run_dir) for f in h5_files
|
|
693
|
+
]
|
|
694
|
+
|
|
695
|
+
diag_files: list[str] = []
|
|
696
|
+
for f in sorted(output_dir.iterdir()):
|
|
697
|
+
if not f.is_file() or f.suffix == ".h5":
|
|
698
|
+
continue
|
|
699
|
+
if any(f.match(p) for p in log_patterns):
|
|
700
|
+
continue
|
|
701
|
+
diag_files.append(_relative_to_run(f, run_dir))
|
|
702
|
+
if diag_files:
|
|
703
|
+
outputs["diagnostics"] = diag_files
|
|
704
|
+
|
|
705
|
+
snapshot_dir = output_dir / "SNAPSHOT1"
|
|
706
|
+
if snapshot_dir.is_dir():
|
|
707
|
+
snap_files = sorted(snapshot_dir.glob("esdat*.h5"))
|
|
708
|
+
if snap_files:
|
|
709
|
+
outputs["snapshots"] = [
|
|
710
|
+
_relative_to_run(f, run_dir) for f in snap_files
|
|
711
|
+
]
|
|
712
|
+
|
|
713
|
+
if outputs:
|
|
714
|
+
break
|
|
715
|
+
|
|
716
|
+
# Log files
|
|
717
|
+
logs: list[str] = []
|
|
718
|
+
for pattern in ("stdout.*.log", "stderr.*.log", "*.out", "*.err"):
|
|
719
|
+
for f in sorted(work_dir.glob(pattern)):
|
|
720
|
+
logs.append(_relative_to_run(f, run_dir))
|
|
721
|
+
if logs:
|
|
722
|
+
outputs["logs"] = logs
|
|
723
|
+
|
|
724
|
+
return outputs
|
|
725
|
+
|
|
726
|
+
def detect_status(self, run_dir: Path) -> str:
|
|
727
|
+
"""Infer EMSES simulation status from output files.
|
|
728
|
+
|
|
729
|
+
Detection logic:
|
|
730
|
+
|
|
731
|
+
1. If stderr contains error keywords -> ``"failed"``.
|
|
732
|
+
2. If energy file shows completion to *nstep* -> ``"completed"``.
|
|
733
|
+
3. If output files exist -> ``"running"``.
|
|
734
|
+
4. Otherwise -> ``"unknown"``.
|
|
735
|
+
|
|
736
|
+
Args:
|
|
737
|
+
run_dir: The run directory.
|
|
738
|
+
|
|
739
|
+
Returns:
|
|
740
|
+
A status string.
|
|
741
|
+
"""
|
|
742
|
+
work_dir = run_dir / WORK_DIR
|
|
743
|
+
if not work_dir.is_dir():
|
|
744
|
+
return "unknown"
|
|
745
|
+
|
|
746
|
+
# Check for errors in log files
|
|
747
|
+
for pattern in ("stderr.*.log", "*.err"):
|
|
748
|
+
for log in work_dir.glob(pattern):
|
|
749
|
+
try:
|
|
750
|
+
content = log.read_text(errors="replace")
|
|
751
|
+
if any(
|
|
752
|
+
kw in content.lower()
|
|
753
|
+
for kw in ("error", "segmentation fault", "killed", "oom")
|
|
754
|
+
):
|
|
755
|
+
return "failed"
|
|
756
|
+
except OSError:
|
|
757
|
+
pass
|
|
758
|
+
|
|
759
|
+
# Check energy file for simulation progress
|
|
760
|
+
for output_dir in (work_dir / "latest", work_dir):
|
|
761
|
+
energy_file = output_dir / "energy"
|
|
762
|
+
if not energy_file.is_file():
|
|
763
|
+
continue
|
|
764
|
+
try:
|
|
765
|
+
nstep = self._get_expected_nstep(run_dir)
|
|
766
|
+
lines = [
|
|
767
|
+
line
|
|
768
|
+
for line in energy_file.read_text().strip().split("\n")
|
|
769
|
+
if line.strip()
|
|
770
|
+
]
|
|
771
|
+
if lines and nstep:
|
|
772
|
+
last_parts = lines[-1].strip().split()
|
|
773
|
+
if last_parts:
|
|
774
|
+
last_step = int(float(last_parts[0]))
|
|
775
|
+
if last_step >= nstep:
|
|
776
|
+
return "completed"
|
|
777
|
+
return "running"
|
|
778
|
+
except (ValueError, IndexError, OSError):
|
|
779
|
+
pass
|
|
780
|
+
|
|
781
|
+
# Fallback: check for any output files
|
|
782
|
+
for output_dir in (work_dir / "latest", work_dir):
|
|
783
|
+
if list(output_dir.glob("*.h5")):
|
|
784
|
+
return "running"
|
|
785
|
+
|
|
786
|
+
return "unknown"
|
|
787
|
+
|
|
788
|
+
def summarize(self, run_dir: Path) -> dict[str, Any]:
|
|
789
|
+
"""Extract key metrics from EMSES outputs.
|
|
790
|
+
|
|
791
|
+
Args:
|
|
792
|
+
run_dir: The run directory.
|
|
793
|
+
|
|
794
|
+
Returns:
|
|
795
|
+
Summary dictionary with status, output counts, energy data,
|
|
796
|
+
and simulation parameters.
|
|
797
|
+
"""
|
|
798
|
+
summary: dict[str, Any] = {}
|
|
799
|
+
work_dir = run_dir / WORK_DIR
|
|
800
|
+
|
|
801
|
+
summary["status"] = self.detect_status(run_dir)
|
|
802
|
+
|
|
803
|
+
# Count outputs by category
|
|
804
|
+
outputs = self.detect_outputs(run_dir)
|
|
805
|
+
summary["output_counts"] = {
|
|
806
|
+
k: len(v) if isinstance(v, list) else 1 for k, v in outputs.items()
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
# Energy diagnostics
|
|
810
|
+
for output_dir in (work_dir / "latest", work_dir):
|
|
811
|
+
energy_file = output_dir / "energy"
|
|
812
|
+
if not energy_file.is_file():
|
|
813
|
+
continue
|
|
814
|
+
try:
|
|
815
|
+
lines = [
|
|
816
|
+
line
|
|
817
|
+
for line in energy_file.read_text().strip().split("\n")
|
|
818
|
+
if line.strip()
|
|
819
|
+
]
|
|
820
|
+
if lines:
|
|
821
|
+
summary["total_energy_lines"] = len(lines)
|
|
822
|
+
last_parts = lines[-1].strip().split()
|
|
823
|
+
if last_parts:
|
|
824
|
+
summary["last_step"] = int(float(last_parts[0]))
|
|
825
|
+
except (ValueError, OSError):
|
|
826
|
+
pass
|
|
827
|
+
break
|
|
828
|
+
|
|
829
|
+
# Simulation parameters from plasma.toml
|
|
830
|
+
config = self._load_input_config(run_dir)
|
|
831
|
+
if config:
|
|
832
|
+
tmgrid = config.get("tmgrid", {})
|
|
833
|
+
for key in ("nx", "ny", "nz", "dt"):
|
|
834
|
+
if key in tmgrid:
|
|
835
|
+
summary[key] = tmgrid[key]
|
|
836
|
+
jobcon = config.get("jobcon", {})
|
|
837
|
+
if "nstep" in jobcon:
|
|
838
|
+
summary["nstep"] = jobcon["nstep"]
|
|
839
|
+
|
|
840
|
+
return summary
|
|
841
|
+
|
|
842
|
+
def collect_provenance(
|
|
843
|
+
self,
|
|
844
|
+
runtime_info: dict[str, Any],
|
|
845
|
+
) -> dict[str, Any]:
|
|
846
|
+
"""Collect EMSES provenance information.
|
|
847
|
+
|
|
848
|
+
Args:
|
|
849
|
+
runtime_info: Output from :meth:`resolve_runtime`.
|
|
850
|
+
|
|
851
|
+
Returns:
|
|
852
|
+
Provenance dictionary with executable hash and git info.
|
|
853
|
+
"""
|
|
854
|
+
provenance: dict[str, Any] = {
|
|
855
|
+
"resolver_mode": runtime_info.get("resolver_mode", ""),
|
|
856
|
+
"executable": runtime_info.get("executable", ""),
|
|
857
|
+
"exe_hash": "",
|
|
858
|
+
"git_commit": "",
|
|
859
|
+
"git_dirty": False,
|
|
860
|
+
"source_repo": runtime_info.get("source_repo", ""),
|
|
861
|
+
"build_command": runtime_info.get("build_command", ""),
|
|
862
|
+
"package_version": "",
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
# Executable hash
|
|
866
|
+
exe_path = Path(runtime_info.get("executable", ""))
|
|
867
|
+
if exe_path.is_file():
|
|
868
|
+
h = hashlib.sha256()
|
|
869
|
+
with exe_path.open("rb") as f:
|
|
870
|
+
for chunk in iter(lambda: f.read(8192), b""):
|
|
871
|
+
h.update(chunk)
|
|
872
|
+
provenance["exe_hash"] = f"sha256:{h.hexdigest()}"
|
|
873
|
+
|
|
874
|
+
# Git provenance for local_source
|
|
875
|
+
if runtime_info.get("resolver_mode") == "local_source":
|
|
876
|
+
repo = runtime_info.get("source_repo", "")
|
|
877
|
+
if repo and Path(repo).is_dir():
|
|
878
|
+
try:
|
|
879
|
+
result = subprocess.run(
|
|
880
|
+
["git", "rev-parse", "HEAD"],
|
|
881
|
+
capture_output=True,
|
|
882
|
+
text=True,
|
|
883
|
+
cwd=repo,
|
|
884
|
+
check=False,
|
|
885
|
+
)
|
|
886
|
+
if result.returncode == 0:
|
|
887
|
+
provenance["git_commit"] = result.stdout.strip()
|
|
888
|
+
result = subprocess.run(
|
|
889
|
+
["git", "status", "--porcelain"],
|
|
890
|
+
capture_output=True,
|
|
891
|
+
text=True,
|
|
892
|
+
cwd=repo,
|
|
893
|
+
check=False,
|
|
894
|
+
)
|
|
895
|
+
if result.returncode == 0:
|
|
896
|
+
provenance["git_dirty"] = bool(result.stdout.strip())
|
|
897
|
+
except FileNotFoundError:
|
|
898
|
+
logger.debug("git not found; skipping git provenance")
|
|
899
|
+
|
|
900
|
+
return provenance
|
|
901
|
+
|
|
902
|
+
# ------------------------------------------------------------------
|
|
903
|
+
# EMSES-specific helpers (used by CLI / jobgen integration)
|
|
904
|
+
# ------------------------------------------------------------------
|
|
905
|
+
|
|
906
|
+
def get_setup_commands(self, run_dir: Path) -> list[str]:
|
|
907
|
+
"""Return setup commands for the EMSES job script."""
|
|
908
|
+
input_dir = run_dir / INPUT_DIR
|
|
909
|
+
return [
|
|
910
|
+
f"cp {input_dir}/plasma.toml . 2>/dev/null || true",
|
|
911
|
+
"rm -f *_0000.h5",
|
|
912
|
+
"date",
|
|
913
|
+
]
|
|
914
|
+
|
|
915
|
+
def get_post_commands(self) -> list[str]:
|
|
916
|
+
"""Return post-execution commands."""
|
|
917
|
+
return ["date"]
|
|
918
|
+
|
|
919
|
+
def get_modules(self) -> list[str]:
|
|
920
|
+
"""Return default module names for EMSES.
|
|
921
|
+
|
|
922
|
+
Returns empty list — modules are now managed via sites/*.toml
|
|
923
|
+
and simulators.toml, not hardcoded in the adapter.
|
|
924
|
+
"""
|
|
925
|
+
return []
|
|
926
|
+
|
|
927
|
+
def get_extra_env(self) -> dict[str, str]:
|
|
928
|
+
"""Return default environment variables for EMSES."""
|
|
929
|
+
return {"EMSES_DEBUG": "no"}
|
|
930
|
+
|
|
931
|
+
def setup_continuation(
|
|
932
|
+
self,
|
|
933
|
+
source_dir: Path,
|
|
934
|
+
new_dir: Path,
|
|
935
|
+
nstep_override: int | None = None,
|
|
936
|
+
) -> dict[str, Any]:
|
|
937
|
+
"""Set up EMSES continuation from snapshot.
|
|
938
|
+
|
|
939
|
+
Links SNAPSHOT1 from source as SNAPSHOT0 in new run,
|
|
940
|
+
and updates jobcon.jobnum for restart.
|
|
941
|
+
|
|
942
|
+
Args:
|
|
943
|
+
source_dir: Completed run directory.
|
|
944
|
+
new_dir: New run directory.
|
|
945
|
+
nstep_override: Override nstep if given.
|
|
946
|
+
|
|
947
|
+
Returns:
|
|
948
|
+
Info dict with continuation details.
|
|
949
|
+
"""
|
|
950
|
+
info: dict[str, Any] = {}
|
|
951
|
+
work_dir = new_dir / WORK_DIR
|
|
952
|
+
work_dir.mkdir(parents=True, exist_ok=True)
|
|
953
|
+
|
|
954
|
+
# Link SNAPSHOT1 -> SNAPSHOT0
|
|
955
|
+
source_snapshot = source_dir / WORK_DIR / "SNAPSHOT1"
|
|
956
|
+
if source_snapshot.is_dir():
|
|
957
|
+
target_link = work_dir / "SNAPSHOT0"
|
|
958
|
+
if not target_link.exists():
|
|
959
|
+
target_link.symlink_to(source_snapshot.resolve())
|
|
960
|
+
info["snapshot_link"] = f"SNAPSHOT0 -> {source_snapshot}"
|
|
961
|
+
|
|
962
|
+
# Update plasma.toml for restart
|
|
963
|
+
plasma_toml = new_dir / INPUT_DIR / "plasma.toml"
|
|
964
|
+
if plasma_toml.is_file() and tomli_w is not None:
|
|
965
|
+
with open(plasma_toml, "rb") as f:
|
|
966
|
+
config = tomllib.load(f)
|
|
967
|
+
|
|
968
|
+
# Set jobnum = [1, 1] for restart
|
|
969
|
+
if "jobcon" not in config:
|
|
970
|
+
config["jobcon"] = {}
|
|
971
|
+
config["jobcon"]["jobnum"] = [1, 1]
|
|
972
|
+
info["jobnum"] = [1, 1]
|
|
973
|
+
|
|
974
|
+
if nstep_override is not None:
|
|
975
|
+
config["jobcon"]["nstep"] = nstep_override
|
|
976
|
+
info["nstep"] = nstep_override
|
|
977
|
+
|
|
978
|
+
with open(plasma_toml, "wb") as f:
|
|
979
|
+
tomli_w.dump(config, f)
|
|
980
|
+
|
|
981
|
+
return info
|
|
982
|
+
|
|
983
|
+
# ------------------------------------------------------------------
|
|
984
|
+
# Internal
|
|
985
|
+
# ------------------------------------------------------------------
|
|
986
|
+
|
|
987
|
+
@staticmethod
|
|
988
|
+
def _load_input_config(run_dir: Path) -> dict[str, Any]:
|
|
989
|
+
"""Load the plasma.toml from the run's input directory."""
|
|
990
|
+
plasma_toml = run_dir / INPUT_DIR / "plasma.toml"
|
|
991
|
+
if plasma_toml.is_file():
|
|
992
|
+
try:
|
|
993
|
+
with open(plasma_toml, "rb") as f:
|
|
994
|
+
return tomllib.load(f)
|
|
995
|
+
except (tomllib.TOMLDecodeError, OSError):
|
|
996
|
+
pass
|
|
997
|
+
return {}
|
|
998
|
+
|
|
999
|
+
@staticmethod
|
|
1000
|
+
def _get_expected_nstep(run_dir: Path) -> int | None:
|
|
1001
|
+
"""Read ``nstep`` from the run's plasma.toml."""
|
|
1002
|
+
plasma_toml = run_dir / INPUT_DIR / "plasma.toml"
|
|
1003
|
+
if plasma_toml.is_file():
|
|
1004
|
+
try:
|
|
1005
|
+
with open(plasma_toml, "rb") as f:
|
|
1006
|
+
config = tomllib.load(f)
|
|
1007
|
+
return int(config.get("jobcon", {}).get("nstep", 0)) or None
|
|
1008
|
+
except (tomllib.TOMLDecodeError, ValueError, OSError):
|
|
1009
|
+
pass
|
|
1010
|
+
return None
|