@zigrivers/scaffold 3.14.0 → 3.15.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.
- package/README.md +31 -9
- package/content/knowledge/research/research-architecture.md +385 -0
- package/content/knowledge/research/research-conventions.md +248 -0
- package/content/knowledge/research/research-dev-environment.md +303 -0
- package/content/knowledge/research/research-experiment-loop.md +429 -0
- package/content/knowledge/research/research-experiment-tracking.md +336 -0
- package/content/knowledge/research/research-ml-architecture-search.md +383 -0
- package/content/knowledge/research/research-ml-evaluation.md +407 -0
- package/content/knowledge/research/research-ml-experiment-tracking.md +466 -0
- package/content/knowledge/research/research-ml-training-patterns.md +413 -0
- package/content/knowledge/research/research-observability.md +395 -0
- package/content/knowledge/research/research-overfitting-prevention.md +306 -0
- package/content/knowledge/research/research-project-structure.md +264 -0
- package/content/knowledge/research/research-quant-backtesting.md +326 -0
- package/content/knowledge/research/research-quant-market-data.md +366 -0
- package/content/knowledge/research/research-quant-metrics.md +335 -0
- package/content/knowledge/research/research-quant-requirements.md +223 -0
- package/content/knowledge/research/research-quant-risk.md +469 -0
- package/content/knowledge/research/research-quant-strategy-patterns.md +412 -0
- package/content/knowledge/research/research-requirements.md +201 -0
- package/content/knowledge/research/research-security.md +374 -0
- package/content/knowledge/research/research-sim-compute-management.md +538 -0
- package/content/knowledge/research/research-sim-engine-patterns.md +448 -0
- package/content/knowledge/research/research-sim-parameter-spaces.md +425 -0
- package/content/knowledge/research/research-sim-validation.md +456 -0
- package/content/knowledge/research/research-testing.md +334 -0
- package/content/methodology/research-ml-research.yml +23 -0
- package/content/methodology/research-overlay.yml +65 -0
- package/content/methodology/research-quant-finance.yml +29 -0
- package/content/methodology/research-simulation.yml +23 -0
- package/dist/cli/commands/adopt.d.ts.map +1 -1
- package/dist/cli/commands/adopt.js +22 -1
- package/dist/cli/commands/adopt.js.map +1 -1
- package/dist/cli/commands/adopt.serialization.test.js +41 -0
- package/dist/cli/commands/adopt.serialization.test.js.map +1 -1
- package/dist/cli/commands/init.d.ts +4 -0
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +32 -2
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/init-flag-families.d.ts +6 -1
- package/dist/cli/init-flag-families.d.ts.map +1 -1
- package/dist/cli/init-flag-families.js +32 -1
- package/dist/cli/init-flag-families.js.map +1 -1
- package/dist/cli/init-flag-families.test.js +47 -0
- package/dist/cli/init-flag-families.test.js.map +1 -1
- package/dist/config/schema.d.ts +272 -16
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +25 -1
- package/dist/config/schema.js.map +1 -1
- package/dist/config/schema.test.js +103 -3
- package/dist/config/schema.test.js.map +1 -1
- package/dist/core/assembly/overlay-loader.d.ts +12 -0
- package/dist/core/assembly/overlay-loader.d.ts.map +1 -1
- package/dist/core/assembly/overlay-loader.js +30 -0
- package/dist/core/assembly/overlay-loader.js.map +1 -1
- package/dist/core/assembly/overlay-loader.test.js +66 -1
- package/dist/core/assembly/overlay-loader.test.js.map +1 -1
- package/dist/core/assembly/overlay-state-resolver.d.ts.map +1 -1
- package/dist/core/assembly/overlay-state-resolver.js +48 -19
- package/dist/core/assembly/overlay-state-resolver.js.map +1 -1
- package/dist/core/assembly/overlay-state-resolver.test.js +80 -0
- package/dist/core/assembly/overlay-state-resolver.test.js.map +1 -1
- package/dist/e2e/project-type-overlays.test.js +119 -0
- package/dist/e2e/project-type-overlays.test.js.map +1 -1
- package/dist/project/adopt.d.ts.map +1 -1
- package/dist/project/adopt.js +3 -1
- package/dist/project/adopt.js.map +1 -1
- package/dist/project/detectors/disambiguate.js +1 -1
- package/dist/project/detectors/disambiguate.js.map +1 -1
- package/dist/project/detectors/index.d.ts.map +1 -1
- package/dist/project/detectors/index.js +2 -1
- package/dist/project/detectors/index.js.map +1 -1
- package/dist/project/detectors/ml.d.ts.map +1 -1
- package/dist/project/detectors/ml.js +2 -6
- package/dist/project/detectors/ml.js.map +1 -1
- package/dist/project/detectors/research.d.ts +4 -0
- package/dist/project/detectors/research.d.ts.map +1 -0
- package/dist/project/detectors/research.js +141 -0
- package/dist/project/detectors/research.js.map +1 -0
- package/dist/project/detectors/research.test.d.ts +2 -0
- package/dist/project/detectors/research.test.d.ts.map +1 -0
- package/dist/project/detectors/research.test.js +235 -0
- package/dist/project/detectors/research.test.js.map +1 -0
- package/dist/project/detectors/shared-signals.d.ts +3 -0
- package/dist/project/detectors/shared-signals.d.ts.map +1 -0
- package/dist/project/detectors/shared-signals.js +9 -0
- package/dist/project/detectors/shared-signals.js.map +1 -0
- package/dist/project/detectors/types.d.ts +6 -2
- package/dist/project/detectors/types.d.ts.map +1 -1
- package/dist/project/detectors/types.js.map +1 -1
- package/dist/types/config.d.ts +7 -1
- package/dist/types/config.d.ts.map +1 -1
- package/dist/wizard/copy/core.d.ts.map +1 -1
- package/dist/wizard/copy/core.js +4 -0
- package/dist/wizard/copy/core.js.map +1 -1
- package/dist/wizard/copy/index.d.ts.map +1 -1
- package/dist/wizard/copy/index.js +2 -0
- package/dist/wizard/copy/index.js.map +1 -1
- package/dist/wizard/copy/research.d.ts +3 -0
- package/dist/wizard/copy/research.d.ts.map +1 -0
- package/dist/wizard/copy/research.js +27 -0
- package/dist/wizard/copy/research.js.map +1 -0
- package/dist/wizard/copy/types.d.ts +5 -1
- package/dist/wizard/copy/types.d.ts.map +1 -1
- package/dist/wizard/flags.d.ts +7 -1
- package/dist/wizard/flags.d.ts.map +1 -1
- package/dist/wizard/questions.d.ts +4 -2
- package/dist/wizard/questions.d.ts.map +1 -1
- package/dist/wizard/questions.js +27 -1
- package/dist/wizard/questions.js.map +1 -1
- package/dist/wizard/questions.test.js +51 -0
- package/dist/wizard/questions.test.js.map +1 -1
- package/dist/wizard/wizard.d.ts +3 -2
- package/dist/wizard/wizard.d.ts.map +1 -1
- package/dist/wizard/wizard.js +3 -1
- package/dist/wizard/wizard.js.map +1 -1
- 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
|
+
```
|