Skip to content
64 changes: 64 additions & 0 deletions libensemble/gen_classes/external/sampling.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
from gest_api.vocs import VOCS
from gest_api import Generator
import numpy as np

__all__ = [
"UniformSample",
"UniformSampleArray",
]


class UniformSample(Generator):
"""
This sampler adheres to the gest-api VOCS interface and data structures (no numpy).

Each variable is a scalar.
"""

def __init__(self, VOCS: VOCS):
self.VOCS = VOCS
self.rng = np.random.default_rng(1)
super().__init__(VOCS)

def _validate_vocs(self, VOCS):
assert len(self.VOCS.variables), "VOCS must contain variables."

def suggest(self, n_trials):
output = []
for _ in range(n_trials):
trial = {}
for key in self.VOCS.variables.keys():
trial[key] = self.rng.uniform(self.VOCS.variables[key].domain[0], self.VOCS.variables[key].domain[1])
output.append(trial)
return output

def ingest(self, calc_in):
pass # random sample so nothing to tell


class UniformSampleArray(Generator):
"""
This sampler adheres to the gest-api VOCS interface and data structures.

Uses one array variable of any dimension. Array is a numpy array.
"""

def __init__(self, VOCS: VOCS):
self.VOCS = VOCS
self.rng = np.random.default_rng(1)
super().__init__(VOCS)

def _validate_vocs(self, VOCS):
assert len(self.VOCS.variables) == 1, "VOCS must contain exactly one variable."

def suggest(self, n_trials):
output = []
key = list(self.VOCS.variables.keys())[0]
var = self.VOCS.variables[key]
for _ in range(n_trials):
trial = {key: np.array([self.rng.uniform(bounds[0], bounds[1]) for bounds in var.domain])}
output.append(trial)
return output

def ingest(self, calc_in):
pass # random sample so nothing to tell
4 changes: 2 additions & 2 deletions libensemble/generators.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ def export(
local_H = unmap_numpy_array(local_H, self.variables_mapping)
if as_dicts and local_H is not None:
if user_fields and self.variables_mapping:
local_H = np_to_list_dicts(local_H, self.variables_mapping, allow_arrays=True)
local_H = np_to_list_dicts(local_H, self.variables_mapping)
else:
local_H = np_to_list_dicts(local_H, allow_arrays=True)
local_H = np_to_list_dicts(local_H)
return (local_H, persis_info, tag)
6 changes: 3 additions & 3 deletions libensemble/libE.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,10 +241,10 @@ def libE(
for spec in [ensemble.sim_specs, ensemble.gen_specs, ensemble.alloc_specs, ensemble.libE_specs]
]
exit_criteria = specs_dump(ensemble.exit_criteria, by_alias=True, exclude_none=True)

# Restore the generator object (don't use serialized version)
if hasattr(ensemble.gen_specs, 'generator') and ensemble.gen_specs.generator is not None:
gen_specs['generator'] = ensemble.gen_specs.generator
if hasattr(ensemble.gen_specs, "generator") and ensemble.gen_specs.generator is not None:
gen_specs["generator"] = ensemble.gen_specs.generator

# Extract platform info from settings or environment
platform_info = get_platform(libE_specs)
Expand Down
86 changes: 86 additions & 0 deletions libensemble/sim_funcs/gest_api_wrapper.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"""
Wrapper for simulation functions in the gest-api format.

Gest-api functions take an input_dict (single point as dictionary) with
VOCS variables and constants, and return a dict with VOCS objectives,
observables, and constraints.
"""

import numpy as np

__all__ = ["gest_api_sim"]


def gest_api_sim(H, persis_info, sim_specs, libE_info):
"""
LibEnsemble sim_f wrapper for gest-api format simulation functions.

Converts between libEnsemble's numpy structured array format and
gest-api's dictionary format for individual points.

Parameters
----------
H : numpy structured array
Input points from libEnsemble containing VOCS variables and constants
persis_info : dict
Persistent information dictionary
sim_specs : dict
Simulation specifications. Must contain:
- "vocs": VOCS object defining variables, constants, objectives, etc.
- "simulator": The gest-api function
libE_info : dict
LibEnsemble information dictionary

Returns
-------
H_o : numpy structured array
Output array with VOCS objectives, observables, and constraints
persis_info : dict
Updated persistent information

Notes
-----
The gest-api simulator function should have signature:
def simulator(input_dict: dict, **kwargs) -> dict

Where input_dict contains VOCS variables and constants,
and the return dict contains VOCS objectives, observables, and constraints.
"""

simulator = sim_specs["simulator"]
vocs = sim_specs["vocs"]
sim_kwargs = sim_specs.get("user", {}).get("simulator_kwargs", {})

batch = len(H)
H_o = np.zeros(batch, dtype=sim_specs["out"])

# Helper to get fields from VOCS (handles both object and dict)
def get_vocs_fields(vocs, attr_names):
fields = []
is_object = hasattr(vocs, attr_names[0])
for attr in attr_names:
obj = getattr(vocs, attr, None) if is_object else vocs.get(attr)
if obj:
fields.extend(list(obj.keys()))
return fields

# Get input fields (variables + constants) and output fields (objectives + observables + constraints)
input_fields = get_vocs_fields(vocs, ["variables", "constants"])
output_fields = get_vocs_fields(vocs, ["objectives", "observables", "constraints"])

# Process each point in the batch
for i in range(batch):
# Build input_dict from H for this point
input_dict = {}
for field in input_fields:
input_dict[field] = H[field][i]

# Call the gest-api simulator
output_dict = simulator(input_dict, **sim_kwargs)

# Extract outputs from the returned dict
for field in output_fields:
if field in output_dict:
H_o[field][i] = output_dict[field]

return H_o, persis_info
43 changes: 35 additions & 8 deletions libensemble/specs.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,21 @@
"""


def _convert_dtype_to_output_tuple(name: str, dtype):
"""Convert dtype to proper output tuple format for NumPy dtype specification."""
if dtype is None:
dtype = float
if isinstance(dtype, tuple):
# Check if first element is a type (type, (shape,)) format
if len(dtype) > 1 and (isinstance(dtype[0], type) or isinstance(dtype[0], str)):
return (name, dtype[0], dtype[1])
else:
# Just shape (shape,) format, default to float
return (name, float, dtype)
else:
return (name, dtype)


class SimSpecs(BaseModel):
"""
Specifications for configuring a Simulation Function.
Expand All @@ -30,6 +45,12 @@ class SimSpecs(BaseModel):
produced by a generator function.
"""

simulator: object | None = None
"""
A pre-initialized simulator object or callable in gest-api format.
When provided, sim_f defaults to gest_api_sim wrapper.
"""

inputs: list[str] | None = Field(default=[], alias="in")
"""
list of **field names** out of the complete history to pass
Expand Down Expand Up @@ -79,25 +100,31 @@ class SimSpecs(BaseModel):
@model_validator(mode="after")
def set_fields_from_vocs(self):
"""Set inputs and outputs from VOCS if vocs is provided and fields are not set."""
# If simulator is provided but sim_f is not, default to gest_api_sim
if self.simulator is not None and self.sim_f is None:
from libensemble.sim_funcs.gest_api_wrapper import gest_api_sim

self.sim_f = gest_api_sim

if self.vocs is None:
return self

# Set inputs: variables + constants (what the sim receives)
if not self.inputs:
input_fields = []
for attr in ["variables", "constants"]:
if (obj := getattr(self.vocs, attr, None)):
if obj := getattr(self.vocs, attr, None):
input_fields.extend(list(obj.keys()))
self.inputs = input_fields

# Set outputs: objectives + observables + constraints (what the sim produces)
if not self.outputs:
out_fields = []
for attr in ["objectives", "observables", "constraints"]:
if (obj := getattr(self.vocs, attr, None)):
if obj := getattr(self.vocs, attr, None):
for name, field in obj.items():
dtype = getattr(field, "dtype", None) or float
out_fields.append((name, dtype))
dtype = getattr(field, "dtype", None)
out_fields.append(_convert_dtype_to_output_tuple(name, dtype))
self.outputs = out_fields

return self
Expand Down Expand Up @@ -191,18 +218,18 @@ def set_fields_from_vocs(self):
if not self.persis_in:
persis_in_fields = []
for attr in ["variables", "constants", "objectives", "observables", "constraints"]:
if (obj := getattr(self.vocs, attr, None)):
if obj := getattr(self.vocs, attr, None):
persis_in_fields.extend(list(obj.keys()))
self.persis_in = persis_in_fields

# Set outputs: variables + constants (what the generator produces)
if not self.outputs:
out_fields = []
for attr in ["variables", "constants"]:
if (obj := getattr(self.vocs, attr, None)):
if obj := getattr(self.vocs, attr, None):
for name, field in obj.items():
dtype = getattr(field, "dtype", None) or float
out_fields.append((name, dtype))
dtype = getattr(field, "dtype", None)
out_fields.append(_convert_dtype_to_output_tuple(name, dtype))
self.outputs = out_fields

return self
Expand Down
36 changes: 2 additions & 34 deletions libensemble/tests/functionality_tests/test_asktell_sampling.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
# TESTSUITE_NPROCS: 2 4

import numpy as np
from gest_api import Generator
from gest_api.vocs import VOCS

# Import libEnsemble items for this test
Expand All @@ -24,32 +23,6 @@
from libensemble.tools import add_unique_random_streams, parse_args


class StandardSample(Generator):
"""
This sampler only adheres to the complete standard interface, with no additional numpy methods.
"""

def __init__(self, VOCS: VOCS):
self.VOCS = VOCS
self.rng = np.random.default_rng(1)
super().__init__(VOCS)

def _validate_vocs(self, VOCS):
assert len(self.VOCS.variables), "VOCS must contain variables."

def suggest(self, n_trials):
output = []
for _ in range(n_trials):
trial = {}
for key in self.VOCS.variables.keys():
trial[key] = self.rng.uniform(self.VOCS.variables[key].domain[0], self.VOCS.variables[key].domain[1])
output.append(trial)
return output

def ingest(self, calc_in):
pass # random sample so nothing to tell


def sim_f(In):
Out = np.zeros(1, dtype=[("f", float)])
Out["f"] = np.linalg.norm(In)
Expand Down Expand Up @@ -87,18 +60,13 @@ def sim_f(In):
exit_criteria = {"gen_max": 201}
persis_info = add_unique_random_streams({}, nworkers + 1, seed=1234)

for test in range(3):
for test in range(2):
if test == 0:
generator = StandardSample(vocs)

elif test == 1:
persis_info["num_gens_started"] = 0
generator = UniformSample(vocs)

elif test == 2:
elif test == 1:
persis_info["num_gens_started"] = 0
generator = UniformSample(vocs, variables_mapping={"x": ["x0", "x1"], "f": ["energy"]})

gen_specs["generator"] = generator
H, persis_info, flag = libE(
sim_specs, gen_specs, exit_criteria, persis_info, alloc_specs, libE_specs=libE_specs
Expand Down
Loading
Loading