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.
Files changed (115) hide show
  1. runops/__init__.py +5 -0
  2. runops/_data/README.md +476 -0
  3. runops/adapters/__init__.py +29 -0
  4. runops/adapters/_utils/__init__.py +36 -0
  5. runops/adapters/_utils/toml_utils.py +81 -0
  6. runops/adapters/base.py +335 -0
  7. runops/adapters/contrib/__init__.py +5 -0
  8. runops/adapters/contrib/beach.py +837 -0
  9. runops/adapters/contrib/emses.py +1010 -0
  10. runops/adapters/generic.py +439 -0
  11. runops/adapters/registry.py +244 -0
  12. runops/cli/__init__.py +3 -0
  13. runops/cli/analyze.py +222 -0
  14. runops/cli/clone.py +104 -0
  15. runops/cli/config.py +217 -0
  16. runops/cli/context.py +56 -0
  17. runops/cli/create.py +263 -0
  18. runops/cli/dashboard.py +179 -0
  19. runops/cli/extend.py +204 -0
  20. runops/cli/history.py +105 -0
  21. runops/cli/init.py +1432 -0
  22. runops/cli/jobs.py +145 -0
  23. runops/cli/knowledge.py +1017 -0
  24. runops/cli/list.py +102 -0
  25. runops/cli/log.py +163 -0
  26. runops/cli/main.py +96 -0
  27. runops/cli/manage.py +231 -0
  28. runops/cli/new.py +343 -0
  29. runops/cli/notes.py +257 -0
  30. runops/cli/run_lookup.py +148 -0
  31. runops/cli/setup.py +174 -0
  32. runops/cli/status.py +187 -0
  33. runops/cli/submit.py +297 -0
  34. runops/cli/update.py +113 -0
  35. runops/cli/update_harness.py +245 -0
  36. runops/cli/update_refs.py +370 -0
  37. runops/core/__init__.py +3 -0
  38. runops/core/actions.py +1186 -0
  39. runops/core/analysis.py +1090 -0
  40. runops/core/campaign.py +156 -0
  41. runops/core/case.py +307 -0
  42. runops/core/context.py +426 -0
  43. runops/core/discovery.py +192 -0
  44. runops/core/environment.py +266 -0
  45. runops/core/exceptions.py +93 -0
  46. runops/core/knowledge.py +595 -0
  47. runops/core/knowledge_source.py +1204 -0
  48. runops/core/manifest.py +219 -0
  49. runops/core/project.py +171 -0
  50. runops/core/provenance.py +147 -0
  51. runops/core/retry.py +193 -0
  52. runops/core/run.py +170 -0
  53. runops/core/run_creation.py +456 -0
  54. runops/core/site.py +337 -0
  55. runops/core/state.py +197 -0
  56. runops/core/survey.py +380 -0
  57. runops/core/validation.py +40 -0
  58. runops/harness/__init__.py +27 -0
  59. runops/harness/builder.py +327 -0
  60. runops/harness/claude.py +189 -0
  61. runops/jobgen/__init__.py +3 -0
  62. runops/jobgen/generator.py +295 -0
  63. runops/launchers/__init__.py +17 -0
  64. runops/launchers/base.py +313 -0
  65. runops/launchers/mpiexec.py +131 -0
  66. runops/launchers/mpirun.py +132 -0
  67. runops/launchers/srun.py +126 -0
  68. runops/sites/__init__.py +0 -0
  69. runops/sites/camphor.md +98 -0
  70. runops/sites/camphor.toml +27 -0
  71. runops/slurm/__init__.py +3 -0
  72. runops/slurm/query.py +384 -0
  73. runops/slurm/submit.py +203 -0
  74. runops/templates/__init__.py +29 -0
  75. runops/templates/adapters/beach/agent_guide.md +50 -0
  76. runops/templates/adapters/beach/beach.toml +19 -0
  77. runops/templates/adapters/beach/case.toml +16 -0
  78. runops/templates/adapters/beach/summarize.py +272 -0
  79. runops/templates/adapters/emses/agent_guide.md +39 -0
  80. runops/templates/adapters/emses/case.toml +18 -0
  81. runops/templates/adapters/emses/plasma.toml +118 -0
  82. runops/templates/adapters/emses/summarize.py +413 -0
  83. runops/templates/adapters/generic/case.toml.j2 +13 -0
  84. runops/templates/adapters/generic/summarize.py +21 -0
  85. runops/templates/agent.md +156 -0
  86. runops/templates/rules/cookbook.md +22 -0
  87. runops/templates/scaffold/campaign.toml.j2 +10 -0
  88. runops/templates/scaffold/cases_claude.md +22 -0
  89. runops/templates/scaffold/facts.toml +2 -0
  90. runops/templates/scaffold/gitignore.txt +30 -0
  91. runops/templates/scaffold/notes/README.md +69 -0
  92. runops/templates/scaffold/rules/plan-before-act.md +17 -0
  93. runops/templates/scaffold/rules/runops-workflow.md +84 -0
  94. runops/templates/scaffold/rules/upstream-feedback.md +85 -0
  95. runops/templates/scaffold/runs_claude.md +24 -0
  96. runops/templates/scaffold/vscode_settings.json +9 -0
  97. runops/templates/skills/analyze/SKILL.md +40 -0
  98. runops/templates/skills/check-status/SKILL.md +29 -0
  99. runops/templates/skills/cleanup/SKILL.md +43 -0
  100. runops/templates/skills/create-run/SKILL.md +135 -0
  101. runops/templates/skills/debug-failed/SKILL.md +38 -0
  102. runops/templates/skills/learn/SKILL.md +54 -0
  103. runops/templates/skills/new-case/SKILL.md +108 -0
  104. runops/templates/skills/note/SKILL.md +107 -0
  105. runops/templates/skills/run-all/SKILL.md +47 -0
  106. runops/templates/skills/runops-reference/SKILL.md +203 -0
  107. runops/templates/skills/setup-campaign/SKILL.md +111 -0
  108. runops/templates/skills/setup-env/SKILL.md +32 -0
  109. runops/templates/skills/survey-design/SKILL.md +73 -0
  110. runops/templates/survey.toml.j2 +22 -0
  111. runops-0.2.0.dist-info/METADATA +491 -0
  112. runops-0.2.0.dist-info/RECORD +115 -0
  113. runops-0.2.0.dist-info/WHEEL +4 -0
  114. runops-0.2.0.dist-info/entry_points.txt +2 -0
  115. 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