Skip to content

Commit 0ebaf9d

Browse files
author
Elwardi
committed
feat: json file format support for parameter input
1 parent 4b7def6 commit 0ebaf9d

4 files changed

Lines changed: 181 additions & 62 deletions

File tree

src/foambo/archive.py

Lines changed: 46 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -143,8 +143,12 @@ def _resolve_trial_indices(include_trials: str | None, cfg, client) -> list[int]
143143
if include_trials == "all":
144144
trial_dest = cfg.get("optimization", {}).get("case_runner", {}).get("trial_destination", "trials")
145145
if os.path.isdir(trial_dest):
146-
return sorted(int(d.split("_")[-1]) for d in os.listdir(trial_dest)
147-
if os.path.isdir(os.path.join(trial_dest, d)))
146+
entries = os.listdir(trial_dest)
147+
has_dirs = any(os.path.isdir(os.path.join(trial_dest, d)) for d in entries)
148+
has_files = any(f.endswith(".json") and os.path.isfile(os.path.join(trial_dest, f)) for f in entries)
149+
if has_dirs or has_files:
150+
# Mix: prefer Ax trial index mapping to avoid fragile name parsing
151+
return sorted(exp.trials.keys())
148152
return sorted(exp.trials.keys())
149153

150154
if include_trials == "best":
@@ -318,28 +322,48 @@ def pack(config_path: str, include_trials: str | None = None,
318322
if trial is None:
319323
log.warning(f"Trial {tidx} not found in experiment")
320324
continue
321-
# Find trial folder
325+
# Find trial folder OR caseless json file
322326
trial_dir = None
327+
trial_file = None
323328
run_meta = trial.run_metadata or {}
324329
case_path = run_meta.get("case_path") or run_meta.get("job", {}).get("case_path")
325330
if case_path and os.path.isdir(case_path):
326331
trial_dir = case_path
332+
elif case_path and os.path.isfile(case_path):
333+
trial_file = case_path
327334
else:
328-
# Try common naming patterns
335+
# Try common naming patterns (directory, then flat-file caseless)
336+
import glob
329337
for pattern in [f"{exp_name}_trial_*_{tidx}", f"trial_{tidx}", f"*_trial_{tidx:04d}"]:
330-
import glob
331338
matches = glob.glob(os.path.join(trial_destination, pattern))
332339
if matches:
333340
trial_dir = matches[0]
334341
break
342+
if not trial_dir:
343+
for pattern in [f"{exp_name}_trial_*.json", f"trial_{tidx}.json"]:
344+
matches = glob.glob(os.path.join(trial_destination, pattern))
345+
if matches:
346+
trial_file = matches[0]
347+
break
335348
if trial_dir and os.path.isdir(trial_dir):
336349
tgrp = trials_grp.create_group(str(tidx))
337350
tgrp.attrs["status"] = trial.status.name
338351
tgrp.attrs["parameters"] = json.dumps(trial.arm.parameters if trial.arm else {})
339352
n, sz = _store_directory(tgrp, trial_dir, all_skip)
340353
print(f" + trials/{tidx}/ ({n} files, {sz / 1024 / 1024:.1f} MB)")
354+
elif trial_file and os.path.isfile(trial_file):
355+
tgrp = trials_grp.create_group(str(tidx))
356+
tgrp.attrs["status"] = trial.status.name
357+
tgrp.attrs["parameters"] = json.dumps(trial.arm.parameters if trial.arm else {})
358+
tgrp.attrs["caseless"] = True
359+
tgrp.attrs["filename"] = os.path.basename(trial_file)
360+
with open(trial_file, "rb") as tf:
361+
data = tf.read()
362+
ds = tgrp.create_dataset("__file__", data=data, **_compression())
363+
ds.attrs["mode"] = os.stat(trial_file).st_mode & 0o777
364+
print(f" + trials/{tidx} (caseless file, {len(data)} bytes)")
341365
else:
342-
log.warning(f"Trial {tidx} folder not found")
366+
log.warning(f"Trial {tidx} folder/file not found")
343367

344368
archive_size = os.path.getsize(archive_path)
345369
print(f"\nArchive created: {archive_path} ({archive_size / 1024 / 1024:.1f} MB)")
@@ -413,10 +437,23 @@ def unpack(archive_path: str, output_dir: str | None = None) -> str:
413437
# Trials
414438
if "trials" in f:
415439
trials_dir = os.path.join(target, "trials")
440+
os.makedirs(trials_dir, exist_ok=True)
416441
for tidx_str in f["trials"]:
417-
tdir = os.path.join(trials_dir, f"{exp_name}_trial_{tidx_str}")
418-
n = _extract_directory(f["trials"][tidx_str], tdir)
419-
print(f" + trials/{tidx_str}/ ({n} files)")
442+
tgrp = f["trials"][tidx_str]
443+
if tgrp.attrs.get("caseless", False):
444+
fname = tgrp.attrs.get("filename", f"{exp_name}_trial_{tidx_str}.json")
445+
if isinstance(fname, bytes):
446+
fname = fname.decode()
447+
out_path = os.path.join(trials_dir, fname)
448+
with open(out_path, "wb") as out:
449+
out.write(bytes(tgrp["__file__"][()]))
450+
mode = tgrp["__file__"].attrs.get("mode", 0o644)
451+
os.chmod(out_path, mode)
452+
print(f" + trials/{fname} (caseless file)")
453+
else:
454+
tdir = os.path.join(trials_dir, f"{exp_name}_trial_{tidx_str}")
455+
n = _extract_directory(tgrp, tdir)
456+
print(f" + trials/{tidx_str}/ ({n} files)")
420457

421458
# Rewrite config paths
422459
cfg = OmegaConf.load_from_string(config_bytes.decode())

src/foambo/common.py

Lines changed: 90 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22
Common constants and shared definitions for the foambo package.
33
"""
44

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
78
from omegaconf import DictConfig, OmegaConf
89
from foamlib import FoamCase, FoamFile
910
from pydantic import BaseModel, ConfigDict
@@ -140,6 +141,56 @@ def assign_foam_path(foamfile, dotted_path, new_value):
140141
return foamfile[last]
141142

142143

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+
143194
class CasePreprocessor:
144195
"""Protocol for case setup before trial execution.
145196
@@ -171,18 +222,45 @@ def __str__(self):
171222

172223

173224
class NoCasePreprocessor(CasePreprocessor):
174-
"""No-op preprocessor for caseless (pure-Python) optimization.
225+
"""Caseless preprocessor: writes a single JSON file per trial.
175226
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.
178230
"""
179-
_counter: int = 0
180-
181231
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}
186264

187265

188266
# Module-level default preprocessor; can be swapped by users
@@ -255,7 +333,7 @@ def prepare_case(
255333
elm = elmt['file']
256334
elmv = elmt['parameter_scopes']
257335
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:
259337
for param in elmv:
260338
try:
261339
if param in parameters.keys():

src/foambo/orchestrate.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from .common import *
1010
from .common import FoamBOBaseModel
1111
from pydantic import Field, field_validator, model_validator
12-
from typing import Any, Iterable, List, Literal, Dict
12+
from typing import Any, Iterable, List, Literal, Dict, Optional
1313
from functools import reduce
1414
from ax.global_stopping.strategies import BaseGlobalStoppingStrategy, ImprovementGlobalStoppingStrategy
1515
from ax.api.configs import StorageConfig, RangeParameterConfig, ChoiceParameterConfig
@@ -835,11 +835,15 @@ def set_generation_strategy(self, client):
835835

836836

837837
class VariableSubstOptions(FoamBOBaseModel):
838-
"""Maps parameters to OpenFOAM file fields for value substitution via foamlib."""
839-
file: str = Field(description="Relative path to the OpenFOAM file (e.g. `/0orig/field`)",
838+
"""Maps parameters to file fields (OpenFOAM dict, JSON, or YAML) for value substitution."""
839+
file: str = Field(description="Relative path to the target file (e.g. `/0orig/field`, `/params.json`). Empty/`.` in caseless mode targets the trial's auto-generated JSON.",
840840
examples=["/0orig/field"])
841841
parameter_scopes: Dict[str, str] = Field(description="Mapping of parameter name to dotted path in the file (e.g. `x: someDict.x`)",
842842
examples=[{"x": "someDict.x"}])
843+
format: Optional[Literal["openfoam", "json", "yaml"]] = Field(
844+
default=None,
845+
description="File format. `None` (default) = openfoam (backward-compat). Set to `json` or `yaml` for non-OpenFOAM files.",
846+
examples=["openfoam"])
843847

844848

845849
class FileSubstOptions(FoamBOBaseModel):

0 commit comments

Comments
 (0)