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,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
+ }