@zigrivers/scaffold 3.14.0 → 3.16.0

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 (122) hide show
  1. package/README.md +50 -21
  2. package/content/knowledge/core/automated-review-tooling.md +21 -26
  3. package/content/knowledge/core/multi-model-review-dispatch.md +30 -55
  4. package/content/knowledge/research/research-architecture.md +385 -0
  5. package/content/knowledge/research/research-conventions.md +248 -0
  6. package/content/knowledge/research/research-dev-environment.md +303 -0
  7. package/content/knowledge/research/research-experiment-loop.md +429 -0
  8. package/content/knowledge/research/research-experiment-tracking.md +336 -0
  9. package/content/knowledge/research/research-ml-architecture-search.md +383 -0
  10. package/content/knowledge/research/research-ml-evaluation.md +407 -0
  11. package/content/knowledge/research/research-ml-experiment-tracking.md +466 -0
  12. package/content/knowledge/research/research-ml-training-patterns.md +413 -0
  13. package/content/knowledge/research/research-observability.md +395 -0
  14. package/content/knowledge/research/research-overfitting-prevention.md +306 -0
  15. package/content/knowledge/research/research-project-structure.md +264 -0
  16. package/content/knowledge/research/research-quant-backtesting.md +326 -0
  17. package/content/knowledge/research/research-quant-market-data.md +366 -0
  18. package/content/knowledge/research/research-quant-metrics.md +335 -0
  19. package/content/knowledge/research/research-quant-requirements.md +223 -0
  20. package/content/knowledge/research/research-quant-risk.md +469 -0
  21. package/content/knowledge/research/research-quant-strategy-patterns.md +412 -0
  22. package/content/knowledge/research/research-requirements.md +201 -0
  23. package/content/knowledge/research/research-security.md +374 -0
  24. package/content/knowledge/research/research-sim-compute-management.md +538 -0
  25. package/content/knowledge/research/research-sim-engine-patterns.md +448 -0
  26. package/content/knowledge/research/research-sim-parameter-spaces.md +425 -0
  27. package/content/knowledge/research/research-sim-validation.md +456 -0
  28. package/content/knowledge/research/research-testing.md +334 -0
  29. package/content/methodology/research-ml-research.yml +23 -0
  30. package/content/methodology/research-overlay.yml +65 -0
  31. package/content/methodology/research-quant-finance.yml +29 -0
  32. package/content/methodology/research-simulation.yml +23 -0
  33. package/content/tools/post-implementation-review.md +36 -7
  34. package/content/tools/review-code.md +33 -8
  35. package/content/tools/review-pr.md +79 -95
  36. package/dist/cli/commands/adopt.d.ts.map +1 -1
  37. package/dist/cli/commands/adopt.js +22 -1
  38. package/dist/cli/commands/adopt.js.map +1 -1
  39. package/dist/cli/commands/adopt.serialization.test.js +41 -0
  40. package/dist/cli/commands/adopt.serialization.test.js.map +1 -1
  41. package/dist/cli/commands/init.d.ts +4 -0
  42. package/dist/cli/commands/init.d.ts.map +1 -1
  43. package/dist/cli/commands/init.js +32 -2
  44. package/dist/cli/commands/init.js.map +1 -1
  45. package/dist/cli/init-flag-families.d.ts +6 -1
  46. package/dist/cli/init-flag-families.d.ts.map +1 -1
  47. package/dist/cli/init-flag-families.js +32 -1
  48. package/dist/cli/init-flag-families.js.map +1 -1
  49. package/dist/cli/init-flag-families.test.js +47 -0
  50. package/dist/cli/init-flag-families.test.js.map +1 -1
  51. package/dist/config/schema.d.ts +272 -16
  52. package/dist/config/schema.d.ts.map +1 -1
  53. package/dist/config/schema.js +25 -1
  54. package/dist/config/schema.js.map +1 -1
  55. package/dist/config/schema.test.js +103 -3
  56. package/dist/config/schema.test.js.map +1 -1
  57. package/dist/core/assembly/overlay-loader.d.ts +12 -0
  58. package/dist/core/assembly/overlay-loader.d.ts.map +1 -1
  59. package/dist/core/assembly/overlay-loader.js +30 -0
  60. package/dist/core/assembly/overlay-loader.js.map +1 -1
  61. package/dist/core/assembly/overlay-loader.test.js +66 -1
  62. package/dist/core/assembly/overlay-loader.test.js.map +1 -1
  63. package/dist/core/assembly/overlay-state-resolver.d.ts.map +1 -1
  64. package/dist/core/assembly/overlay-state-resolver.js +48 -19
  65. package/dist/core/assembly/overlay-state-resolver.js.map +1 -1
  66. package/dist/core/assembly/overlay-state-resolver.test.js +80 -0
  67. package/dist/core/assembly/overlay-state-resolver.test.js.map +1 -1
  68. package/dist/e2e/project-type-overlays.test.js +119 -0
  69. package/dist/e2e/project-type-overlays.test.js.map +1 -1
  70. package/dist/project/adopt.d.ts.map +1 -1
  71. package/dist/project/adopt.js +3 -1
  72. package/dist/project/adopt.js.map +1 -1
  73. package/dist/project/detectors/disambiguate.js +1 -1
  74. package/dist/project/detectors/disambiguate.js.map +1 -1
  75. package/dist/project/detectors/index.d.ts.map +1 -1
  76. package/dist/project/detectors/index.js +2 -1
  77. package/dist/project/detectors/index.js.map +1 -1
  78. package/dist/project/detectors/ml.d.ts.map +1 -1
  79. package/dist/project/detectors/ml.js +2 -6
  80. package/dist/project/detectors/ml.js.map +1 -1
  81. package/dist/project/detectors/research.d.ts +4 -0
  82. package/dist/project/detectors/research.d.ts.map +1 -0
  83. package/dist/project/detectors/research.js +141 -0
  84. package/dist/project/detectors/research.js.map +1 -0
  85. package/dist/project/detectors/research.test.d.ts +2 -0
  86. package/dist/project/detectors/research.test.d.ts.map +1 -0
  87. package/dist/project/detectors/research.test.js +235 -0
  88. package/dist/project/detectors/research.test.js.map +1 -0
  89. package/dist/project/detectors/shared-signals.d.ts +3 -0
  90. package/dist/project/detectors/shared-signals.d.ts.map +1 -0
  91. package/dist/project/detectors/shared-signals.js +9 -0
  92. package/dist/project/detectors/shared-signals.js.map +1 -0
  93. package/dist/project/detectors/types.d.ts +6 -2
  94. package/dist/project/detectors/types.d.ts.map +1 -1
  95. package/dist/project/detectors/types.js.map +1 -1
  96. package/dist/types/config.d.ts +7 -1
  97. package/dist/types/config.d.ts.map +1 -1
  98. package/dist/wizard/copy/core.d.ts.map +1 -1
  99. package/dist/wizard/copy/core.js +4 -0
  100. package/dist/wizard/copy/core.js.map +1 -1
  101. package/dist/wizard/copy/index.d.ts.map +1 -1
  102. package/dist/wizard/copy/index.js +2 -0
  103. package/dist/wizard/copy/index.js.map +1 -1
  104. package/dist/wizard/copy/research.d.ts +3 -0
  105. package/dist/wizard/copy/research.d.ts.map +1 -0
  106. package/dist/wizard/copy/research.js +27 -0
  107. package/dist/wizard/copy/research.js.map +1 -0
  108. package/dist/wizard/copy/types.d.ts +5 -1
  109. package/dist/wizard/copy/types.d.ts.map +1 -1
  110. package/dist/wizard/flags.d.ts +7 -1
  111. package/dist/wizard/flags.d.ts.map +1 -1
  112. package/dist/wizard/questions.d.ts +4 -2
  113. package/dist/wizard/questions.d.ts.map +1 -1
  114. package/dist/wizard/questions.js +27 -1
  115. package/dist/wizard/questions.js.map +1 -1
  116. package/dist/wizard/questions.test.js +51 -0
  117. package/dist/wizard/questions.test.js.map +1 -1
  118. package/dist/wizard/wizard.d.ts +3 -2
  119. package/dist/wizard/wizard.d.ts.map +1 -1
  120. package/dist/wizard/wizard.js +3 -1
  121. package/dist/wizard/wizard.js.map +1 -1
  122. package/package.json +1 -1
@@ -0,0 +1,448 @@
1
+ ---
2
+ name: research-sim-engine-patterns
3
+ description: Simulation engine integration patterns including wrapping solvers as callable experiments, configuration management, mesh handling, batch job submission, and result parsing
4
+ topics: [research, simulation, engine-integration, openfoam, fenics, simpy, solver-configuration, mesh-management, batch-jobs, result-parsing]
5
+ ---
6
+
7
+ Simulation engines (OpenFOAM, FEniCS, SimPy, COMSOL, Ansys) are typically standalone tools with their own input/output formats, solver configurations, and execution models. Wrapping them as callable experiments requires a uniform interface that abstracts engine-specific details while preserving access to solver parameters that matter for optimization. The key design challenge is creating a thin adapter layer that makes any simulation engine look like a function from parameters to results, without hiding failure modes or losing important solver diagnostics.
8
+
9
+ ## Summary
10
+
11
+ Wrap simulation engines behind a `SimulationExperiment` interface that accepts a parameter dictionary and returns structured results including convergence status, wall-clock time, and domain-specific outputs. Manage solver configuration as declarative parameter objects that can be serialized for reproducibility. Handle mesh generation as a separate cacheable step with independence checks. Submit batch jobs through an abstraction that works locally or on HPC clusters. Parse results from engine-specific output files into a normalized format for the experiment tracker.
12
+
13
+ ## Deep Guidance
14
+
15
+ ### Simulation Experiment Interface
16
+
17
+ Define a uniform interface that any simulation engine adapter must implement:
18
+
19
+ ```python
20
+ # src/simulation/interface.py
21
+ from abc import ABC, abstractmethod
22
+ from dataclasses import dataclass, field
23
+ from enum import Enum
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ class SimulationStatus(Enum):
28
+ CONVERGED = "converged"
29
+ DIVERGED = "diverged"
30
+ MAX_ITERATIONS = "max_iterations"
31
+ TIMEOUT = "timeout"
32
+ ERROR = "error"
33
+
34
+ @dataclass
35
+ class SimulationResult:
36
+ """Normalized result from any simulation engine."""
37
+ status: SimulationStatus
38
+ outputs: dict[str, float] # Named scalar outputs (drag_coeff, stress_max, etc.)
39
+ fields: dict[str, Path] # Paths to field data files (pressure, velocity, etc.)
40
+ residuals: list[float] # Final residual history
41
+ wall_time_seconds: float
42
+ iterations: int
43
+ metadata: dict[str, Any] = field(default_factory=dict)
44
+
45
+ @property
46
+ def is_valid(self) -> bool:
47
+ return self.status == SimulationStatus.CONVERGED
48
+
49
+ class SimulationExperiment(ABC):
50
+ """Interface for wrapping any simulation engine as a callable experiment."""
51
+
52
+ @abstractmethod
53
+ def setup(self, params: dict[str, Any]) -> Path:
54
+ """Generate input files for the simulation. Returns case directory."""
55
+
56
+ @abstractmethod
57
+ def run(self, case_dir: Path, timeout_seconds: int | None = None) -> SimulationResult:
58
+ """Execute the simulation and return parsed results."""
59
+
60
+ @abstractmethod
61
+ def validate_params(self, params: dict[str, Any]) -> list[str]:
62
+ """Check parameter validity before running. Returns list of issues."""
63
+
64
+ def __call__(self, params: dict[str, Any], timeout: int | None = None) -> SimulationResult:
65
+ """Run the full pipeline: validate -> setup -> run."""
66
+ issues = self.validate_params(params)
67
+ if issues:
68
+ raise ValueError(f"Invalid parameters: {issues}")
69
+ case_dir = self.setup(params)
70
+ return self.run(case_dir, timeout)
71
+ ```
72
+
73
+ ### OpenFOAM Adapter
74
+
75
+ OpenFOAM uses directory-based case structures with text dictionaries for configuration:
76
+
77
+ ```python
78
+ # src/simulation/engines/openfoam.py
79
+ import subprocess
80
+ import time
81
+ from pathlib import Path
82
+ from typing import Any
83
+
84
+ from src.simulation.interface import SimulationExperiment, SimulationResult, SimulationStatus
85
+
86
+ class OpenFOAMExperiment(SimulationExperiment):
87
+ """Wraps OpenFOAM as a callable simulation experiment."""
88
+
89
+ def __init__(self, template_dir: Path, solver: str = "simpleFoam"):
90
+ self.template_dir = template_dir
91
+ self.solver = solver
92
+
93
+ def validate_params(self, params: dict[str, Any]) -> list[str]:
94
+ issues = []
95
+ if "inlet_velocity" in params and params["inlet_velocity"] <= 0:
96
+ issues.append("inlet_velocity must be positive")
97
+ if "turbulence_model" in params:
98
+ valid_models = ["kEpsilon", "kOmegaSST", "SpalartAllmaras"]
99
+ if params["turbulence_model"] not in valid_models:
100
+ issues.append(f"turbulence_model must be one of {valid_models}")
101
+ return issues
102
+
103
+ def setup(self, params: dict[str, Any]) -> Path:
104
+ """Generate OpenFOAM case from template with parameter substitution."""
105
+ import shutil
106
+ import hashlib
107
+ import json
108
+
109
+ # Create unique case directory based on parameters
110
+ param_hash = hashlib.md5(json.dumps(params, sort_keys=True).encode()).hexdigest()[:8]
111
+ case_dir = Path(f"runs/openfoam_{param_hash}")
112
+ if case_dir.exists():
113
+ shutil.rmtree(case_dir)
114
+ shutil.copytree(self.template_dir, case_dir)
115
+
116
+ # Substitute parameters into OpenFOAM dictionaries
117
+ self._write_transport_properties(case_dir, params)
118
+ self._write_boundary_conditions(case_dir, params)
119
+ self._write_control_dict(case_dir, params)
120
+
121
+ return case_dir
122
+
123
+ def run(self, case_dir: Path, timeout_seconds: int | None = None) -> SimulationResult:
124
+ """Execute OpenFOAM solver and parse results."""
125
+ start = time.time()
126
+
127
+ # Run mesh generation if needed
128
+ self._run_mesh(case_dir)
129
+
130
+ # Run solver
131
+ result = subprocess.run(
132
+ [self.solver, "-case", str(case_dir)],
133
+ capture_output=True, text=True,
134
+ timeout=timeout_seconds,
135
+ )
136
+ wall_time = time.time() - start
137
+
138
+ # Parse results
139
+ return self._parse_results(case_dir, result, wall_time)
140
+
141
+ def _parse_results(self, case_dir: Path, proc, wall_time: float) -> SimulationResult:
142
+ """Parse OpenFOAM log and postProcessing output."""
143
+ residuals = self._extract_residuals(proc.stdout)
144
+ status = self._determine_status(residuals, proc.returncode)
145
+ outputs = self._read_force_coefficients(case_dir)
146
+ fields = self._find_field_files(case_dir)
147
+
148
+ return SimulationResult(
149
+ status=status,
150
+ outputs=outputs,
151
+ fields=fields,
152
+ residuals=residuals,
153
+ wall_time_seconds=wall_time,
154
+ iterations=len(residuals),
155
+ metadata={"solver": self.solver, "case_dir": str(case_dir)},
156
+ )
157
+
158
+ def _extract_residuals(self, log: str) -> list[float]:
159
+ """Extract residual values from solver log."""
160
+ import re
161
+ pattern = r"Solving for Ux.*Final residual = ([0-9.e+-]+)"
162
+ return [float(m) for m in re.findall(pattern, log)]
163
+
164
+ def _determine_status(self, residuals: list[float], returncode: int) -> SimulationStatus:
165
+ if returncode != 0:
166
+ return SimulationStatus.ERROR
167
+ if not residuals:
168
+ return SimulationStatus.ERROR
169
+ if residuals[-1] < 1e-6:
170
+ return SimulationStatus.CONVERGED
171
+ if residuals[-1] > residuals[0] * 100:
172
+ return SimulationStatus.DIVERGED
173
+ return SimulationStatus.MAX_ITERATIONS
174
+
175
+ def _run_mesh(self, case_dir: Path) -> None:
176
+ subprocess.run(["blockMesh", "-case", str(case_dir)], check=True, capture_output=True)
177
+
178
+ def _write_transport_properties(self, case_dir: Path, params: dict) -> None:
179
+ """Write constant/transportProperties with parameter values."""
180
+ props_file = case_dir / "constant" / "transportProperties"
181
+ nu = params.get("kinematic_viscosity", 1e-6)
182
+ props_file.write_text(
183
+ f"FoamFile {{ version 2.0; class dictionary; object transportProperties; }}\n"
184
+ f"nu [0 2 -1 0 0 0 0] {nu};\n"
185
+ )
186
+
187
+ def _write_boundary_conditions(self, case_dir: Path, params: dict) -> None:
188
+ """Write 0/ directory boundary condition files."""
189
+ # Implementation substitutes inlet velocity, turbulence quantities, etc.
190
+ pass
191
+
192
+ def _write_control_dict(self, case_dir: Path, params: dict) -> None:
193
+ """Write system/controlDict with iteration limits and write intervals."""
194
+ pass
195
+
196
+ def _read_force_coefficients(self, case_dir: Path) -> dict[str, float]:
197
+ """Read postProcessing/forceCoeffs output."""
198
+ coeffs_file = case_dir / "postProcessing" / "forceCoeffs" / "0" / "coefficient.dat"
199
+ if not coeffs_file.exists():
200
+ return {}
201
+ lines = coeffs_file.read_text().strip().split("\n")
202
+ last_line = lines[-1].split()
203
+ return {"Cd": float(last_line[1]), "Cl": float(last_line[2])}
204
+
205
+ def _find_field_files(self, case_dir: Path) -> dict[str, Path]:
206
+ """Find the latest time directory with field outputs."""
207
+ time_dirs = sorted(
208
+ [d for d in case_dir.iterdir() if d.is_dir() and d.name.replace(".", "").isdigit()],
209
+ key=lambda d: float(d.name),
210
+ )
211
+ if not time_dirs:
212
+ return {}
213
+ latest = time_dirs[-1]
214
+ return {f.stem: f for f in latest.iterdir() if f.is_file()}
215
+ ```
216
+
217
+ ### FEniCS Adapter
218
+
219
+ FEniCS uses Python-native problem definitions with mesh objects and variational forms:
220
+
221
+ ```python
222
+ # src/simulation/engines/fenics_adapter.py
223
+ from pathlib import Path
224
+ from typing import Any
225
+ import numpy as np
226
+
227
+ from src.simulation.interface import SimulationExperiment, SimulationResult, SimulationStatus
228
+
229
+ class FEniCSExperiment(SimulationExperiment):
230
+ """Wraps FEniCS as a callable experiment for PDE solving."""
231
+
232
+ def __init__(self, mesh_path: Path, problem_class: type):
233
+ self.mesh_path = mesh_path
234
+ self.problem_class = problem_class
235
+
236
+ def validate_params(self, params: dict[str, Any]) -> list[str]:
237
+ issues = []
238
+ if "mesh_refinement" in params and params["mesh_refinement"] < 1:
239
+ issues.append("mesh_refinement must be >= 1")
240
+ if "youngs_modulus" in params and params["youngs_modulus"] <= 0:
241
+ issues.append("youngs_modulus must be positive")
242
+ return issues
243
+
244
+ def setup(self, params: dict[str, Any]) -> Path:
245
+ """Prepare mesh and problem configuration."""
246
+ import json
247
+ case_dir = Path(f"runs/fenics_{hash(frozenset(params.items())) % 10**8:08d}")
248
+ case_dir.mkdir(parents=True, exist_ok=True)
249
+ (case_dir / "params.json").write_text(json.dumps(params))
250
+ return case_dir
251
+
252
+ def run(self, case_dir: Path, timeout_seconds: int | None = None) -> SimulationResult:
253
+ """Solve the PDE problem with given parameters."""
254
+ import json
255
+ import time
256
+
257
+ params = json.loads((case_dir / "params.json").read_text())
258
+ start = time.time()
259
+
260
+ try:
261
+ problem = self.problem_class(self.mesh_path, params)
262
+ solution, residuals = problem.solve()
263
+ wall_time = time.time() - start
264
+
265
+ outputs = problem.extract_quantities(solution)
266
+ solution_path = case_dir / "solution.xdmf"
267
+ problem.save_solution(solution, solution_path)
268
+
269
+ status = (
270
+ SimulationStatus.CONVERGED
271
+ if residuals[-1] < problem.tolerance
272
+ else SimulationStatus.MAX_ITERATIONS
273
+ )
274
+
275
+ return SimulationResult(
276
+ status=status,
277
+ outputs=outputs,
278
+ fields={"solution": solution_path},
279
+ residuals=residuals,
280
+ wall_time_seconds=wall_time,
281
+ iterations=len(residuals),
282
+ )
283
+ except Exception as e:
284
+ return SimulationResult(
285
+ status=SimulationStatus.ERROR,
286
+ outputs={},
287
+ fields={},
288
+ residuals=[],
289
+ wall_time_seconds=time.time() - start,
290
+ iterations=0,
291
+ metadata={"error": str(e)},
292
+ )
293
+ ```
294
+
295
+ ### Solver Configuration as Parameters
296
+
297
+ Treat solver settings as first-class parameters that the optimizer can tune:
298
+
299
+ ```python
300
+ # src/simulation/config.py
301
+ from dataclasses import dataclass
302
+ from typing import Any
303
+
304
+ @dataclass
305
+ class SolverConfig:
306
+ """Declarative solver configuration -- serializable and reproducible."""
307
+ solver_type: str # "GAMG", "PCG", "BiCGStab"
308
+ preconditioner: str # "DIC", "DILU", "none"
309
+ tolerance: float = 1e-6
310
+ relative_tolerance: float = 0.01
311
+ max_iterations: int = 1000
312
+ relaxation_factor: float = 0.7
313
+
314
+ def to_dict(self) -> dict[str, Any]:
315
+ return {
316
+ "solver": self.solver_type,
317
+ "preconditioner": self.preconditioner,
318
+ "tolerance": self.tolerance,
319
+ "relTol": self.relative_tolerance,
320
+ "maxIter": self.max_iterations,
321
+ "relaxationFactor": self.relaxation_factor,
322
+ }
323
+
324
+ def solver_param_space() -> dict[str, Any]:
325
+ """Define solver parameters as an optimizable space."""
326
+ return {
327
+ "solver_type": {"type": "categorical", "choices": ["GAMG", "PCG", "BiCGStab"]},
328
+ "preconditioner": {"type": "categorical", "choices": ["DIC", "DILU", "none"]},
329
+ "tolerance": {"type": "continuous", "low": 1e-8, "high": 1e-4, "log": True},
330
+ "relaxation_factor": {"type": "continuous", "low": 0.3, "high": 0.95},
331
+ "max_iterations": {"type": "discrete", "low": 100, "high": 5000, "step": 100},
332
+ }
333
+ ```
334
+
335
+ ### Batch Job Submission
336
+
337
+ Abstract job submission to work locally or on HPC clusters:
338
+
339
+ ```python
340
+ # src/simulation/batch.py
341
+ from abc import ABC, abstractmethod
342
+ from dataclasses import dataclass
343
+ from enum import Enum
344
+ from pathlib import Path
345
+
346
+ class JobStatus(Enum):
347
+ PENDING = "pending"
348
+ RUNNING = "running"
349
+ COMPLETED = "completed"
350
+ FAILED = "failed"
351
+ TIMEOUT = "timeout"
352
+
353
+ @dataclass
354
+ class JobSpec:
355
+ """Specification for a simulation job."""
356
+ case_dir: Path
357
+ command: list[str]
358
+ num_cores: int = 1
359
+ wall_time_hours: float = 1.0
360
+ memory_gb: float = 4.0
361
+ partition: str = "default"
362
+
363
+ class JobSubmitter(ABC):
364
+ """Abstract job submission interface."""
365
+
366
+ @abstractmethod
367
+ def submit(self, spec: JobSpec) -> str:
368
+ """Submit job, return job ID."""
369
+
370
+ @abstractmethod
371
+ def status(self, job_id: str) -> JobStatus:
372
+ """Check job status."""
373
+
374
+ @abstractmethod
375
+ def wait(self, job_id: str, poll_interval: float = 30.0) -> JobStatus:
376
+ """Block until job completes."""
377
+
378
+ class LocalSubmitter(JobSubmitter):
379
+ """Run jobs locally as subprocesses."""
380
+
381
+ def __init__(self):
382
+ self._processes: dict[str, Any] = {}
383
+ self._counter = 0
384
+
385
+ def submit(self, spec: JobSpec) -> str:
386
+ import subprocess
387
+ self._counter += 1
388
+ job_id = f"local_{self._counter}"
389
+ proc = subprocess.Popen(
390
+ spec.command,
391
+ cwd=spec.case_dir,
392
+ stdout=open(spec.case_dir / "stdout.log", "w"),
393
+ stderr=open(spec.case_dir / "stderr.log", "w"),
394
+ )
395
+ self._processes[job_id] = proc
396
+ return job_id
397
+
398
+ def status(self, job_id: str) -> JobStatus:
399
+ proc = self._processes[job_id]
400
+ if proc.poll() is None:
401
+ return JobStatus.RUNNING
402
+ return JobStatus.COMPLETED if proc.returncode == 0 else JobStatus.FAILED
403
+
404
+ def wait(self, job_id: str, poll_interval: float = 30.0) -> JobStatus:
405
+ self._processes[job_id].wait()
406
+ return self.status(job_id)
407
+ ```
408
+
409
+ ### Result Parsing
410
+
411
+ Parse engine-specific output into normalized formats for the experiment tracker:
412
+
413
+ ```python
414
+ # src/simulation/parsers.py
415
+ from pathlib import Path
416
+ from typing import Any
417
+ import re
418
+
419
+ def parse_openfoam_log(log_path: Path) -> dict[str, Any]:
420
+ """Extract convergence data from OpenFOAM solver log."""
421
+ text = log_path.read_text()
422
+ residual_pattern = r"Time = (\d+)\n.*?Solving for (\w+).*?Final residual = ([0-9.e+-]+)"
423
+ matches = re.findall(residual_pattern, text, re.DOTALL)
424
+
425
+ residuals_by_field: dict[str, list[float]] = {}
426
+ for time_step, field_name, residual in matches:
427
+ residuals_by_field.setdefault(field_name, []).append(float(residual))
428
+
429
+ execution_time_match = re.search(r"ExecutionTime = ([0-9.]+) s", text)
430
+ exec_time = float(execution_time_match.group(1)) if execution_time_match else 0.0
431
+
432
+ return {
433
+ "residuals_by_field": residuals_by_field,
434
+ "execution_time": exec_time,
435
+ "num_iterations": len(matches) // max(len(residuals_by_field), 1),
436
+ }
437
+
438
+ def parse_csv_results(results_path: Path, output_columns: list[str]) -> dict[str, float]:
439
+ """Parse final row of CSV results file for scalar outputs."""
440
+ import csv
441
+ with open(results_path) as f:
442
+ reader = csv.DictReader(f)
443
+ rows = list(reader)
444
+ if not rows:
445
+ return {}
446
+ last_row = rows[-1]
447
+ return {col: float(last_row[col]) for col in output_columns if col in last_row}
448
+ ```