|
2 | 2 | Common constants and shared definitions for the foambo package. |
3 | 3 | """ |
4 | 4 |
|
5 | | -import hashlib, os, shutil, time |
6 | | -from typing import List, Literal, Dict |
| 5 | +import hashlib, json, os, shutil, time |
| 6 | +from contextlib import contextmanager |
| 7 | +from typing import List, Literal, Dict, Optional |
7 | 8 | from omegaconf import DictConfig, OmegaConf |
8 | 9 | from foamlib import FoamCase, FoamFile |
9 | 10 | from pydantic import BaseModel, ConfigDict |
@@ -140,6 +141,56 @@ def assign_foam_path(foamfile, dotted_path, new_value): |
140 | 141 | return foamfile[last] |
141 | 142 |
|
142 | 143 |
|
| 144 | +def _detect_format(path: str, explicit: Optional[str] = None) -> str: |
| 145 | + """Pick file-format handler. Explicit wins. Else extension. Else openfoam.""" |
| 146 | + if explicit: |
| 147 | + return explicit |
| 148 | + ext = os.path.splitext(path)[1].lower() |
| 149 | + if ext == ".json": |
| 150 | + return "json" |
| 151 | + if ext in (".yaml", ".yml"): |
| 152 | + return "yaml" |
| 153 | + return "openfoam" |
| 154 | + |
| 155 | + |
| 156 | +@contextmanager |
| 157 | +def _open_param_file(path: str, fmt: Optional[str] = None): |
| 158 | + """Open a parameter file for dotted-path mutation across formats. |
| 159 | +
|
| 160 | + Yields a mapping-like object that supports __getitem__/__setitem__. |
| 161 | + Writes back on exit for json/yaml (foamlib handles persistence itself). |
| 162 | + """ |
| 163 | + resolved = _detect_format(path, fmt) |
| 164 | + if resolved == "openfoam": |
| 165 | + with FoamFile(path) as ff: |
| 166 | + yield ff |
| 167 | + return |
| 168 | + if resolved == "json": |
| 169 | + if os.path.exists(path): |
| 170 | + with open(path, "r") as f: |
| 171 | + data = json.load(f) |
| 172 | + else: |
| 173 | + data = {} |
| 174 | + yield data |
| 175 | + with open(path, "w") as f: |
| 176 | + json.dump(data, f, indent=2) |
| 177 | + return |
| 178 | + if resolved == "yaml": |
| 179 | + from ruamel.yaml import YAML |
| 180 | + yaml = YAML() |
| 181 | + yaml.preserve_quotes = True |
| 182 | + if os.path.exists(path): |
| 183 | + with open(path, "r") as f: |
| 184 | + data = yaml.load(f) or {} |
| 185 | + else: |
| 186 | + data = {} |
| 187 | + yield data |
| 188 | + with open(path, "w") as f: |
| 189 | + yaml.dump(data, f) |
| 190 | + return |
| 191 | + raise ValueError(f"Unknown parameter file format: {resolved}") |
| 192 | + |
| 193 | + |
143 | 194 | class CasePreprocessor: |
144 | 195 | """Protocol for case setup before trial execution. |
145 | 196 |
|
@@ -171,18 +222,45 @@ def __str__(self): |
171 | 222 |
|
172 | 223 |
|
173 | 224 | class NoCasePreprocessor(CasePreprocessor): |
174 | | - """No-op preprocessor for caseless (pure-Python) optimization. |
| 225 | + """Caseless preprocessor: writes a single JSON file per trial. |
175 | 226 |
|
176 | | - Uses a virtual path as the trial "case path" — no directories are |
177 | | - created on disk since there are no files to store. |
| 227 | + Output: `{trial_destination}/{EXPERIMENT_NAME}_trial_{hash}.json` |
| 228 | + containing the full parameter dict. No case directory is created. |
| 229 | + Any `variable_substitution` entries with empty/`.` file target this JSON. |
178 | 230 | """ |
179 | | - _counter: int = 0 |
180 | | - |
181 | 231 | def setup(self, parameters: dict, cfg) -> dict: |
182 | | - NoCasePreprocessor._counter += 1 |
183 | | - virtual_path = f"<caseless_trial_{NoCasePreprocessor._counter}>" |
184 | | - fake = _FakeCasePath(virtual_path) |
185 | | - return {"case": fake, "casename": virtual_path} |
| 232 | + hash = hashlib.md5() |
| 233 | + encoded = repr(OmegaConf.to_yaml(parameters)).encode() |
| 234 | + hash.update(encoded + f"{time.time()}".encode()) |
| 235 | + hash = hash.hexdigest()[:DEFAULT_HASH_LENGTH] |
| 236 | + |
| 237 | + trial_dest = cfg['template_case'].get('trial_destination', 'trials') \ |
| 238 | + if isinstance(cfg, (dict, DictConfig)) and 'template_case' in cfg \ |
| 239 | + else 'trials' |
| 240 | + os.makedirs(trial_dest, exist_ok=True) |
| 241 | + trial_name = f"{EXPERIMENT_NAME}_trial_{hash}" |
| 242 | + trial_path = os.path.join(trial_dest, f"{trial_name}.json") |
| 243 | + |
| 244 | + payload = dict(parameters) |
| 245 | + with open(trial_path, "w") as f: |
| 246 | + json.dump(payload, f, indent=2) |
| 247 | + |
| 248 | + # Apply variable_substitution entries targeting the trial JSON |
| 249 | + if isinstance(cfg, (dict, DictConfig)) and 'templating' in cfg: |
| 250 | + for elmt in cfg['templating'].get('variables', []) or []: |
| 251 | + tgt = (elmt.get('file') or '').strip() |
| 252 | + if tgt in ('', '.', '/'): |
| 253 | + # Entry targets the trial JSON itself — already written as flat dict. |
| 254 | + # Dotted paths in parameter_scopes allow nested rewrites. |
| 255 | + elmv = elmt['parameter_scopes'] |
| 256 | + with _open_param_file(trial_path, 'json') as pf: |
| 257 | + for p in elmv: |
| 258 | + if p in parameters: |
| 259 | + assign_foam_path(pf, elmv[p], parameters[p]) |
| 260 | + |
| 261 | + fake = _FakeCasePath(trial_path) |
| 262 | + fake.name = trial_name |
| 263 | + return {"case": fake, "casename": trial_path} |
186 | 264 |
|
187 | 265 |
|
188 | 266 | # Module-level default preprocessor; can be swapped by users |
@@ -255,7 +333,7 @@ def prepare_case( |
255 | 333 | elm = elmt['file'] |
256 | 334 | elmv = elmt['parameter_scopes'] |
257 | 335 | param_file_path = os.path.join(case.path, elm.lstrip('/')) |
258 | | - with FoamFile(param_file_path) as paramFile: |
| 336 | + with _open_param_file(param_file_path, elmt.get('format')) as paramFile: |
259 | 337 | for param in elmv: |
260 | 338 | try: |
261 | 339 | if param in parameters.keys(): |
|
0 commit comments