From c91a824fbde523de01e3f723652386ed3d2172ab Mon Sep 17 00:00:00 2001 From: shudson Date: Thu, 20 Nov 2025 14:08:15 -0600 Subject: [PATCH 01/13] Remove automapping --- libensemble/generators.py | 4 ++-- libensemble/utils/misc.py | 37 +++++++------------------------------ 2 files changed, 9 insertions(+), 32 deletions(-) diff --git a/libensemble/generators.py b/libensemble/generators.py index 7c7c5b933..da2020b79 100644 --- a/libensemble/generators.py +++ b/libensemble/generators.py @@ -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) diff --git a/libensemble/utils/misc.py b/libensemble/utils/misc.py index dfc39e538..bdc6e70b9 100644 --- a/libensemble/utils/misc.py +++ b/libensemble/utils/misc.py @@ -64,18 +64,8 @@ def specs_checker_setattr(obj, key, value): def _combine_names(names: list) -> list: - """combine fields with same name *except* for final digits""" - out_names = [] - stripped = list(i.rstrip("0123456789") for i in names) # ['x', 'x', y', 'z', 'a'] - for name in names: - stripped_name = name.rstrip("0123456789") - if stripped.count(stripped_name) > 1: # if name appears >= 1, will combine, don't keep int suffix - out_names.append(stripped_name) - else: - out_names.append(name) # name appears once, keep integer suffix, e.g. "co2" - - # intending [x, y, z, a0] from [x0, x1, y, z0, z1, z2, z3, a0] - return list(set(out_names)) + """Return unique field names without auto-combining""" + return list(dict.fromkeys(names)) # preserves order, removes duplicates def _get_new_dtype_fields(first: dict, mapping: dict = {}) -> list: @@ -91,15 +81,8 @@ def _get_new_dtype_fields(first: dict, mapping: dict = {}) -> list: def _get_combinable_multidim_names(first: dict, new_dtype_names: list) -> list: - """inspect the input dict for fields that can be combined (e.g. x0, x1)""" - combinable_names = [] - for name in new_dtype_names: - combinable_group = [i for i in first.keys() if i.rstrip("0123456789") == name] - if len(combinable_group) > 1: # multiple similar names, e.g. x0, x1 - combinable_names.append(combinable_group) - else: # single name, e.g. local_pt, a0 *AS LONG AS THERE ISNT AN A1* - combinable_names.append([name]) - return combinable_names + """Return each field name as a single-element list without auto-grouping""" + return [[name] for name in new_dtype_names] def _decide_dtype(name: str, entry, size: int) -> tuple: @@ -228,7 +211,7 @@ def unmap_numpy_array(array: npt.NDArray, mapping: dict = {}) -> npt.NDArray: return unmapped_array -def np_to_list_dicts(array: npt.NDArray, mapping: dict = {}, allow_arrays: bool = False) -> List[dict]: +def np_to_list_dicts(array: npt.NDArray, mapping: dict = {}) -> List[dict]: if array is None: return None out = [] @@ -237,15 +220,9 @@ def np_to_list_dicts(array: npt.NDArray, mapping: dict = {}, allow_arrays: bool new_dict = {} for field in row.dtype.names: - # non-string arrays, lists, etc. if field not in list(mapping.keys()): - if _is_multidim(row[field]) and not allow_arrays: - for i, x in enumerate(row[field]): - new_dict[field + str(i)] = x - - else: - new_dict[field] = row[field] - + # Unmapped fields: copy directly (no auto-unpacking) + new_dict[field] = row[field] else: # keys from mapping and array unpacked into corresponding fields in dicts field_shape = array.dtype[field].shape[0] if len(array.dtype[field].shape) > 0 else 1 assert field_shape == len(mapping[field]), ( From 00bbebef63949bc4ea25d0fd262ffe360c8bce1b Mon Sep 17 00:00:00 2001 From: shudson Date: Fri, 21 Nov 2025 15:20:02 -0600 Subject: [PATCH 02/13] Add array dtype support --- libensemble/specs.py | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/libensemble/specs.py b/libensemble/specs.py index 52d70582f..c6bc42d4e 100644 --- a/libensemble/specs.py +++ b/libensemble/specs.py @@ -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. @@ -96,8 +111,8 @@ def set_fields_from_vocs(self): for attr in ["objectives", "observables", "constraints"]: 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 @@ -201,8 +216,8 @@ def set_fields_from_vocs(self): for attr in ["variables", "constants"]: 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 From 379868de06674ff2e2370a349ef8c21eb9697c83 Mon Sep 17 00:00:00 2001 From: shudson Date: Fri, 21 Nov 2025 15:22:56 -0600 Subject: [PATCH 03/13] Update vocs sampling tests * Split tests libEnsemble and external generators * Have an vocs generator that uses arrays * Array generator disabled as requires gest-api update. --- libensemble/gen_classes/external/sampling.py | 67 +++++++++++++ .../test_asktell_sampling.py | 36 +------ .../test_asktell_sampling_external_gen.py | 95 +++++++++++++++++++ 3 files changed, 165 insertions(+), 33 deletions(-) create mode 100644 libensemble/gen_classes/external/sampling.py create mode 100644 libensemble/tests/functionality_tests/test_asktell_sampling_external_gen.py diff --git a/libensemble/gen_classes/external/sampling.py b/libensemble/gen_classes/external/sampling.py new file mode 100644 index 000000000..36b1fe4a2 --- /dev/null +++ b/libensemble/gen_classes/external/sampling.py @@ -0,0 +1,67 @@ +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 diff --git a/libensemble/tests/functionality_tests/test_asktell_sampling.py b/libensemble/tests/functionality_tests/test_asktell_sampling.py index 55e3b7afc..06537acac 100644 --- a/libensemble/tests/functionality_tests/test_asktell_sampling.py +++ b/libensemble/tests/functionality_tests/test_asktell_sampling.py @@ -21,35 +21,10 @@ from libensemble.alloc_funcs.start_only_persistent import only_persistent_gens as alloc_f from libensemble.gen_classes.sampling import UniformSample from libensemble.libE import libE +from libensemble.specs import GenSpecs 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) @@ -87,18 +62,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 diff --git a/libensemble/tests/functionality_tests/test_asktell_sampling_external_gen.py b/libensemble/tests/functionality_tests/test_asktell_sampling_external_gen.py new file mode 100644 index 000000000..e567aee8b --- /dev/null +++ b/libensemble/tests/functionality_tests/test_asktell_sampling_external_gen.py @@ -0,0 +1,95 @@ +""" +Runs libEnsemble with Latin hypercube sampling on a simple 1D problem + +using external gest_api compatible generators. + +Execute via one of the following commands (e.g. 3 workers): + mpiexec -np 4 python test_asktell_sampling_external_gen.py + python test_asktell_sampling_external_gen.py --nworkers 3 --comms local + python test_asktell_sampling_external_gen.py --nworkers 3 --comms tcp + +The number of concurrent evaluations of the objective function will be 3. +""" + +# Do not change these lines - they are parsed by run-tests.sh +# TESTSUITE_COMMS: mpi local +# TESTSUITE_NPROCS: 2 4 + +import numpy as np +from gest_api import Generator +from gest_api.vocs import VOCS +from gest_api.vocs import ContinuousVariable + +# Import libEnsemble items for this test +from libensemble.alloc_funcs.start_only_persistent import only_persistent_gens as alloc_f +# from libensemble.gen_classes.external.sampling import UniformSampleArray +from libensemble.gen_classes.external.sampling import UniformSample +from libensemble import Ensemble +from libensemble.specs import GenSpecs, SimSpecs, AllocSpecs, ExitCriteria, LibeSpecs + + +def sim_f_array(In): + Out = np.zeros(1, dtype=[("f", float)]) + Out["f"] = np.linalg.norm(In) + return Out + + +def sim_f_scalar(In): + Out = np.zeros(1, dtype=[("f", float)]) + Out["f"] = np.linalg.norm(In["x0"], In["x1"]) + return Out + + +if __name__ == "__main__": + + libE_specs = LibeSpecs(gen_on_manager=True) + + for test in range(1): # 2 + + objectives = {"f": "EXPLORE"} + + if test == 0: + sim_f = sim_f_scalar + variables = {"x0": [-3, 3], "x1": [-2, 2]} + vocs = VOCS(variables=variables, objectives=objectives) + generator = UniformSample(vocs) + + # Requires gest-api variables array bounds update + # elif test == 1: + # sim_f = sim_f_array + # variables = {"x": ContinuousVariable(dtype=(float, (2,)),domain=[[-3, 3], [-2, 2]])} + # vocs = VOCS(variables=variables, objectives=objectives) + # generator = UniformSampleArray(vocs) + + sim_specs = SimSpecs( + sim_f=sim_f, + vocs=vocs, + ) + + gen_specs = GenSpecs( + generator=generator, + initial_batch_size=20, + batch_size=10, + vocs=vocs, + ) + + alloc_specs = AllocSpecs(alloc_f=alloc_f) + exit_criteria = ExitCriteria(gen_max=201) + + gen_specs.generator = generator + ensemble = Ensemble( + parse_args=True, + sim_specs=sim_specs, + gen_specs=gen_specs, + exit_criteria=exit_criteria, + alloc_specs=alloc_specs, + libE_specs=libE_specs, + ) + + ensemble.add_random_streams() + ensemble.run() + + if ensemble.is_manager: + print(ensemble.H[["sim_id", "x0", "x1", "f"]][:10]) + # print(ensemble.H[["sim_id", "x", "f"]][:10]) # For array variables + assert len(ensemble.H) >= 201, f"H has length {len(ensemble.H)}" From 1c1e99678cfaff7489bae868204ca7e4ea442dfa Mon Sep 17 00:00:00 2001 From: shudson Date: Fri, 21 Nov 2025 15:33:59 -0600 Subject: [PATCH 04/13] Remove redundant line --- .../functionality_tests/test_asktell_sampling_external_gen.py | 1 - 1 file changed, 1 deletion(-) diff --git a/libensemble/tests/functionality_tests/test_asktell_sampling_external_gen.py b/libensemble/tests/functionality_tests/test_asktell_sampling_external_gen.py index e567aee8b..309fe95c3 100644 --- a/libensemble/tests/functionality_tests/test_asktell_sampling_external_gen.py +++ b/libensemble/tests/functionality_tests/test_asktell_sampling_external_gen.py @@ -76,7 +76,6 @@ def sim_f_scalar(In): alloc_specs = AllocSpecs(alloc_f=alloc_f) exit_criteria = ExitCriteria(gen_max=201) - gen_specs.generator = generator ensemble = Ensemble( parse_args=True, sim_specs=sim_specs, From f6b2ce247f053e3cd4b8d0fe54d68fe5817845be Mon Sep 17 00:00:00 2001 From: shudson Date: Fri, 21 Nov 2025 15:34:37 -0600 Subject: [PATCH 05/13] Remove another redundant line --- .../functionality_tests/test_asktell_sampling_external_gen.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libensemble/tests/functionality_tests/test_asktell_sampling_external_gen.py b/libensemble/tests/functionality_tests/test_asktell_sampling_external_gen.py index 309fe95c3..a382a2e4e 100644 --- a/libensemble/tests/functionality_tests/test_asktell_sampling_external_gen.py +++ b/libensemble/tests/functionality_tests/test_asktell_sampling_external_gen.py @@ -18,7 +18,7 @@ import numpy as np from gest_api import Generator from gest_api.vocs import VOCS -from gest_api.vocs import ContinuousVariable +# from gest_api.vocs import ContinuousVariable # Import libEnsemble items for this test from libensemble.alloc_funcs.start_only_persistent import only_persistent_gens as alloc_f From 4ed9efddbf3027d6695f34707d6114a0413600e0 Mon Sep 17 00:00:00 2001 From: shudson Date: Fri, 21 Nov 2025 15:58:27 -0600 Subject: [PATCH 06/13] Formatting --- .../functionality_tests/test_asktell_sampling.py | 2 -- .../test_asktell_sampling_external_gen.py | 5 ++--- libensemble/tests/regression_tests/test_xopt_EI.py | 11 ++++------- 3 files changed, 6 insertions(+), 12 deletions(-) diff --git a/libensemble/tests/functionality_tests/test_asktell_sampling.py b/libensemble/tests/functionality_tests/test_asktell_sampling.py index 06537acac..3f4ab0577 100644 --- a/libensemble/tests/functionality_tests/test_asktell_sampling.py +++ b/libensemble/tests/functionality_tests/test_asktell_sampling.py @@ -14,14 +14,12 @@ # 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 from libensemble.alloc_funcs.start_only_persistent import only_persistent_gens as alloc_f from libensemble.gen_classes.sampling import UniformSample from libensemble.libE import libE -from libensemble.specs import GenSpecs from libensemble.tools import add_unique_random_streams, parse_args diff --git a/libensemble/tests/functionality_tests/test_asktell_sampling_external_gen.py b/libensemble/tests/functionality_tests/test_asktell_sampling_external_gen.py index a382a2e4e..84fec8ea8 100644 --- a/libensemble/tests/functionality_tests/test_asktell_sampling_external_gen.py +++ b/libensemble/tests/functionality_tests/test_asktell_sampling_external_gen.py @@ -16,7 +16,6 @@ # TESTSUITE_NPROCS: 2 4 import numpy as np -from gest_api import Generator from gest_api.vocs import VOCS # from gest_api.vocs import ContinuousVariable @@ -37,7 +36,7 @@ def sim_f_array(In): def sim_f_scalar(In): Out = np.zeros(1, dtype=[("f", float)]) Out["f"] = np.linalg.norm(In["x0"], In["x1"]) - return Out + return Out if __name__ == "__main__": @@ -45,7 +44,7 @@ def sim_f_scalar(In): libE_specs = LibeSpecs(gen_on_manager=True) for test in range(1): # 2 - + objectives = {"f": "EXPLORE"} if test == 0: diff --git a/libensemble/tests/regression_tests/test_xopt_EI.py b/libensemble/tests/regression_tests/test_xopt_EI.py index 6d15278da..7de8f5a7f 100644 --- a/libensemble/tests/regression_tests/test_xopt_EI.py +++ b/libensemble/tests/regression_tests/test_xopt_EI.py @@ -17,9 +17,6 @@ # TESTSUITE_NPROCS: 4 # TESTSUITE_EXTRA: true -import sys -import warnings - import numpy as np from gest_api.vocs import VOCS from xopt.generators.bayesian.expected_improvement import ExpectedImprovementGenerator @@ -29,7 +26,7 @@ from libensemble.specs import AllocSpecs, ExitCriteria, GenSpecs, LibeSpecs, SimSpecs -# SH TODO - should check constant1 is present +# SH TODO - should check constant1 is present # From Xopt/xopt/resources/testing.py def xtest_sim(H, persis_info, sim_specs, _): """ @@ -43,7 +40,7 @@ def xtest_sim(H, persis_info, sim_specs, _): x1 = H["x1"][i] x2 = H["x2"][i] # constant1 is available but not used in the calculation - + H_o["y1"][i] = x2 H_o["c1"][i] = x1 @@ -57,12 +54,12 @@ def xtest_sim(H, persis_info, sim_specs, _): batch_size = 4 libE_specs = LibeSpecs(gen_on_manager=True, nworkers=batch_size) - + vocs = VOCS( variables={"x1": [0, 1.0], "x2": [0, 10.0]}, objectives={"y1": "MINIMIZE"}, constraints={"c1": ["GREATER_THAN", 0.5]}, - constants={"constant1": 1.0}, + constants={"constant1": 1.0}, ) gen = ExpectedImprovementGenerator(vocs=vocs) From a02e8d47f027c651a885783d6e543128ebe99c84 Mon Sep 17 00:00:00 2001 From: shudson Date: Fri, 21 Nov 2025 21:44:30 -0600 Subject: [PATCH 07/13] Add gest_api simulator wrapper --- libensemble/sim_funcs/gest_api_wrapper.py | 87 +++++++++++++++++++++++ libensemble/specs.py | 11 +++ 2 files changed, 98 insertions(+) create mode 100644 libensemble/sim_funcs/gest_api_wrapper.py diff --git a/libensemble/sim_funcs/gest_api_wrapper.py b/libensemble/sim_funcs/gest_api_wrapper.py new file mode 100644 index 000000000..c87cdcac1 --- /dev/null +++ b/libensemble/sim_funcs/gest_api_wrapper.py @@ -0,0 +1,87 @@ +""" +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 + diff --git a/libensemble/specs.py b/libensemble/specs.py index c6bc42d4e..f0db52d36 100644 --- a/libensemble/specs.py +++ b/libensemble/specs.py @@ -45,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 @@ -94,6 +100,11 @@ 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 From 104424e4aeba371cb9f5523b54c922d8007e7b58 Mon Sep 17 00:00:00 2001 From: shudson Date: Fri, 21 Nov 2025 21:45:29 -0600 Subject: [PATCH 08/13] Add version of xopt test that uses xopt simulator --- .../tests/regression_tests/test_xopt_EI.py | 2 +- .../regression_tests/test_xopt_EI_xopt_sim.py | 96 +++++++++++++++++++ 2 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 libensemble/tests/regression_tests/test_xopt_EI_xopt_sim.py diff --git a/libensemble/tests/regression_tests/test_xopt_EI.py b/libensemble/tests/regression_tests/test_xopt_EI.py index 7de8f5a7f..b31fd6323 100644 --- a/libensemble/tests/regression_tests/test_xopt_EI.py +++ b/libensemble/tests/regression_tests/test_xopt_EI.py @@ -27,7 +27,7 @@ # SH TODO - should check constant1 is present -# From Xopt/xopt/resources/testing.py +# Adapted from Xopt/xopt/resources/testing.py def xtest_sim(H, persis_info, sim_specs, _): """ Simple sim function that takes x1, x2, constant1 from H and returns y1, c1. diff --git a/libensemble/tests/regression_tests/test_xopt_EI_xopt_sim.py b/libensemble/tests/regression_tests/test_xopt_EI_xopt_sim.py new file mode 100644 index 000000000..609edb81a --- /dev/null +++ b/libensemble/tests/regression_tests/test_xopt_EI_xopt_sim.py @@ -0,0 +1,96 @@ +""" +Tests libEnsemble with Xopt ExpectedImprovementGenerator and a gest-api form simulator. + +*****currently fixing nworkers to batch_size***** + +Execute via one of the following commands (e.g. 4 workers): + mpiexec -np 5 python test_xopt_EI.py + python test_xopt_EI.py -n 4 + +When running with the above commands, the number of concurrent evaluations of +the objective function will be 4 as the generator is on the manager. + +""" + +# Do not change these lines - they are parsed by run-tests.sh +# TESTSUITE_COMMS: mpi local +# TESTSUITE_NPROCS: 4 +# TESTSUITE_EXTRA: true + +import numpy as np +from gest_api.vocs import VOCS +from xopt.generators.bayesian.expected_improvement import ExpectedImprovementGenerator + +from libensemble import Ensemble +from libensemble.alloc_funcs.start_only_persistent import only_persistent_gens as alloc_f +from libensemble.specs import AllocSpecs, ExitCriteria, GenSpecs, LibeSpecs, SimSpecs + + +# SH TODO - should check constant1 is present +# From Xopt/xopt/resources/testing.py +def xtest_callable(input_dict: dict, a=0) -> dict: + """Single-objective callable test function""" + assert isinstance(input_dict, dict) + x1 = input_dict["x1"] + x2 = input_dict["x2"] + + assert "constant1" in input_dict + + y1 = x2 + c1 = x1 + return {"y1": y1, "c1": c1} + + +# Main block is necessary only when using local comms with spawn start method (default on macOS and Windows). +if __name__ == "__main__": + + n = 2 + batch_size = 4 + + libE_specs = LibeSpecs(gen_on_manager=True, nworkers=batch_size) + + vocs = VOCS( + variables={"x1": [0, 1.0], "x2": [0, 10.0]}, + objectives={"y1": "MINIMIZE"}, + constraints={"c1": ["GREATER_THAN", 0.5]}, + constants={"constant1": 1.0}, + ) + + gen = ExpectedImprovementGenerator(vocs=vocs) + + # Create 4 initial points and ingest them + initial_points = [ + {"x1": 0.2, "x2": 2.0, "constant1": 1.0, "y1": 2.0, "c1": 0.2}, + {"x1": 0.5, "x2": 5.0, "constant1": 1.0, "y1": 5.0, "c1": 0.5}, + {"x1": 0.7, "x2": 7.0, "constant1": 1.0, "y1": 7.0, "c1": 0.7}, + {"x1": 0.9, "x2": 9.0, "constant1": 1.0, "y1": 9.0, "c1": 0.9}, + ] + gen.ingest(initial_points) + + gen_specs = GenSpecs( + generator=gen, + batch_size=batch_size, + vocs=vocs, + ) + + sim_specs = SimSpecs( + simulator=xtest_callable, + vocs=vocs, + ) + + alloc_specs = AllocSpecs(alloc_f=alloc_f) + exit_criteria = ExitCriteria(sim_max=20) + + workflow = Ensemble( + libE_specs=libE_specs, + sim_specs=sim_specs, + alloc_specs=alloc_specs, + gen_specs=gen_specs, + exit_criteria=exit_criteria, + ) + + H, _, _ = workflow.run() + + # Perform the run + if workflow.is_manager: + print(f"Completed {len(H)} simulations") From 52d32a66b8ed7c259439b13f09b65c62e2cb7fe3 Mon Sep 17 00:00:00 2001 From: shudson Date: Fri, 21 Nov 2025 21:47:55 -0600 Subject: [PATCH 09/13] Fix naming --- libensemble/tests/regression_tests/test_xopt_EI_xopt_sim.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libensemble/tests/regression_tests/test_xopt_EI_xopt_sim.py b/libensemble/tests/regression_tests/test_xopt_EI_xopt_sim.py index 609edb81a..f0b2b0e5a 100644 --- a/libensemble/tests/regression_tests/test_xopt_EI_xopt_sim.py +++ b/libensemble/tests/regression_tests/test_xopt_EI_xopt_sim.py @@ -4,8 +4,8 @@ *****currently fixing nworkers to batch_size***** Execute via one of the following commands (e.g. 4 workers): - mpiexec -np 5 python test_xopt_EI.py - python test_xopt_EI.py -n 4 + mpiexec -np 5 python test_xopt_EI_xopt_sim.py + python test_xopt_EI_xopt_sim.py -n 4 When running with the above commands, the number of concurrent evaluations of the objective function will be 4 as the generator is on the manager. From 561ba54392233cd332b2c7c7b5fd4d824ec785f5 Mon Sep 17 00:00:00 2001 From: shudson Date: Tue, 25 Nov 2025 12:45:49 -0600 Subject: [PATCH 10/13] Fix test for array fields --- libensemble/tests/unit_tests/test_models.py | 22 ++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/libensemble/tests/unit_tests/test_models.py b/libensemble/tests/unit_tests/test_models.py index ac123aa97..a9044a53a 100644 --- a/libensemble/tests/unit_tests/test_models.py +++ b/libensemble/tests/unit_tests/test_models.py @@ -134,11 +134,19 @@ def test_vocs_to_sim_specs(): assert ss.inputs == ["x1", "x2", "c1"] assert len(ss.outputs) == 5 - output_dict = {name: dtype for name, dtype in ss.outputs} + output_dict = {} + for item in ss.outputs: + if len(item) == 2: + name, dtype = item + output_dict[name] = dtype + else: + name, dtype, shape = item + output_dict[name] = (dtype, shape) assert output_dict["o1"] == float and output_dict["o2"] == int and output_dict["o3"] == (float, (3,)) # Explicit values take precedence ss2 = SimSpecs(sim_f=norm_eval, vocs=vocs, inputs=["custom"], outputs=[("custom_out", int)]) + assert ss2.inputs == ["custom"] and ss2.outputs == [("custom_out", int)] @@ -167,10 +175,10 @@ def test_vocs_to_gen_specs(): if __name__ == "__main__": - test_sim_gen_alloc_exit_specs() - test_sim_gen_alloc_exit_specs_invalid() - test_libe_specs() - test_libe_specs_invalid() - test_ensemble_specs() + # test_sim_gen_alloc_exit_specs() + # test_sim_gen_alloc_exit_specs_invalid() + # test_libe_specs() + # test_libe_specs_invalid() + # test_ensemble_specs() test_vocs_to_sim_specs() - test_vocs_to_gen_specs() + # test_vocs_to_gen_specs() From 7f4a4ee707ae4daf741aea61e63fe9639cc0d83b Mon Sep 17 00:00:00 2001 From: shudson Date: Tue, 25 Nov 2025 12:46:55 -0600 Subject: [PATCH 11/13] Disable awkward array test --- libensemble/tests/unit_tests/test_asktell.py | 118 +++++++++---------- 1 file changed, 59 insertions(+), 59 deletions(-) diff --git a/libensemble/tests/unit_tests/test_asktell.py b/libensemble/tests/unit_tests/test_asktell.py index d8c90d741..45c286ab1 100644 --- a/libensemble/tests/unit_tests/test_asktell.py +++ b/libensemble/tests/unit_tests/test_asktell.py @@ -20,64 +20,64 @@ def _check_conversion(H, npp, mapping={}): raise TypeError(f"Unhandled or mismatched types in field {field}: {type(H[field])} vs {type(npp[field])}") -def test_awkward_list_dict(): - from libensemble.utils.misc import list_dicts_to_np - - # test list_dicts_to_np on a weirdly formatted dictionary - # Unfortunately, we're not really checking against some original - # libE-styled source of truth, like H. - - weird_list_dict = [ - { - "x0": "abcd", - "x1": "efgh", - "y": 56, - "z0": 1, - "z1": 2, - "z2": 3, - "z3": 4, - "z4": 5, - "z5": 6, - "z6": 7, - "z7": 8, - "z8": 9, - "z9": 10, - "z10": 11, - "a0": "B", - } - ] - - out_np = list_dicts_to_np(weird_list_dict) - - assert all([i in ("x", "y", "z", "a0") for i in out_np.dtype.names]) - - weird_list_dict = [ - { - "sim_id": 77, - "core": 89, - "edge": 10.1, - "beam": 76.5, - "energy": 12.34, - "local_pt": True, - "local_min": False, - }, - { - "sim_id": 10, - "core": 32.8, - "edge": 16.2, - "beam": 33.5, - "energy": 99.34, - "local_pt": False, - "local_min": False, - }, - ] - - # target dtype: [("sim_id", int), ("x, float, (3,)), ("f", float), ("local_pt", bool), ("local_min", bool)] - - mapping = {"x": ["core", "edge", "beam"], "f": ["energy"]} - out_np = list_dicts_to_np(weird_list_dict, mapping=mapping) - - assert all([i in ("sim_id", "x", "f", "local_pt", "local_min") for i in out_np.dtype.names]) +# def test_awkward_list_dict(): +# from libensemble.utils.misc import list_dicts_to_np + +# # test list_dicts_to_np on a weirdly formatted dictionary +# # Unfortunately, we're not really checking against some original +# # libE-styled source of truth, like H. + +# weird_list_dict = [ +# { +# "x0": "abcd", +# "x1": "efgh", +# "y": 56, +# "z0": 1, +# "z1": 2, +# "z2": 3, +# "z3": 4, +# "z4": 5, +# "z5": 6, +# "z6": 7, +# "z7": 8, +# "z8": 9, +# "z9": 10, +# "z10": 11, +# "a0": "B", +# } +# ] + +# out_np = list_dicts_to_np(weird_list_dict) + +# assert all([i in ("x", "y", "z", "a0") for i in out_np.dtype.names]) + +# weird_list_dict = [ +# { +# "sim_id": 77, +# "core": 89, +# "edge": 10.1, +# "beam": 76.5, +# "energy": 12.34, +# "local_pt": True, +# "local_min": False, +# }, +# { +# "sim_id": 10, +# "core": 32.8, +# "edge": 16.2, +# "beam": 33.5, +# "energy": 99.34, +# "local_pt": False, +# "local_min": False, +# }, +# ] + +# # target dtype: [("sim_id", int), ("x, float, (3,)), ("f", float), ("local_pt", bool), ("local_min", bool)] + +# mapping = {"x": ["core", "edge", "beam"], "f": ["energy"]} +# out_np = list_dicts_to_np(weird_list_dict, mapping=mapping) + +# assert all([i in ("sim_id", "x", "f", "local_pt", "local_min") for i in out_np.dtype.names]) def test_awkward_H(): @@ -149,7 +149,7 @@ def test_unmap_numpy_array_edge_cases(): if __name__ == "__main__": - test_awkward_list_dict() + # test_awkward_list_dict() test_awkward_H() test_unmap_numpy_array_basic() test_unmap_numpy_array_single_dimension() From 3de1de580b833514bb2dec85df22d266362401c2 Mon Sep 17 00:00:00 2001 From: shudson Date: Tue, 25 Nov 2025 12:48:17 -0600 Subject: [PATCH 12/13] Give output assert to xopt tests --- libensemble/tests/regression_tests/test_xopt_EI.py | 2 ++ libensemble/tests/regression_tests/test_xopt_EI_xopt_sim.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/libensemble/tests/regression_tests/test_xopt_EI.py b/libensemble/tests/regression_tests/test_xopt_EI.py index b31fd6323..43fa52ef7 100644 --- a/libensemble/tests/regression_tests/test_xopt_EI.py +++ b/libensemble/tests/regression_tests/test_xopt_EI.py @@ -100,3 +100,5 @@ def xtest_sim(H, persis_info, sim_specs, _): # Perform the run if workflow.is_manager: print(f"Completed {len(H)} simulations") + assert np.array_equal(H['y1'], H['x2']) + assert np.array_equal(H['c1'], H['x1']) diff --git a/libensemble/tests/regression_tests/test_xopt_EI_xopt_sim.py b/libensemble/tests/regression_tests/test_xopt_EI_xopt_sim.py index f0b2b0e5a..e91fdcd1d 100644 --- a/libensemble/tests/regression_tests/test_xopt_EI_xopt_sim.py +++ b/libensemble/tests/regression_tests/test_xopt_EI_xopt_sim.py @@ -94,3 +94,5 @@ def xtest_callable(input_dict: dict, a=0) -> dict: # Perform the run if workflow.is_manager: print(f"Completed {len(H)} simulations") + assert np.array_equal(H['y1'], H['x2']) + assert np.array_equal(H['c1'], H['x1']) From 3b21fe0e850817acaaa9623b65985ae5366b5d7d Mon Sep 17 00:00:00 2001 From: shudson Date: Tue, 25 Nov 2025 12:55:38 -0600 Subject: [PATCH 13/13] Fix formatting --- libensemble/gen_classes/external/sampling.py | 5 +--- libensemble/libE.py | 6 ++-- libensemble/sim_funcs/gest_api_wrapper.py | 29 +++++++++---------- libensemble/specs.py | 11 +++---- .../test_asktell_sampling_external_gen.py | 2 ++ .../tests/regression_tests/test_xopt_EI.py | 4 +-- .../regression_tests/test_xopt_EI_xopt_sim.py | 4 +-- 7 files changed, 30 insertions(+), 31 deletions(-) diff --git a/libensemble/gen_classes/external/sampling.py b/libensemble/gen_classes/external/sampling.py index 36b1fe4a2..3ddb72cb9 100644 --- a/libensemble/gen_classes/external/sampling.py +++ b/libensemble/gen_classes/external/sampling.py @@ -56,10 +56,7 @@ def suggest(self, n_trials): 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 - ])} + trial = {key: np.array([self.rng.uniform(bounds[0], bounds[1]) for bounds in var.domain])} output.append(trial) return output diff --git a/libensemble/libE.py b/libensemble/libE.py index 2fdc14727..601d8d009 100644 --- a/libensemble/libE.py +++ b/libensemble/libE.py @@ -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) diff --git a/libensemble/sim_funcs/gest_api_wrapper.py b/libensemble/sim_funcs/gest_api_wrapper.py index c87cdcac1..6ce066b49 100644 --- a/libensemble/sim_funcs/gest_api_wrapper.py +++ b/libensemble/sim_funcs/gest_api_wrapper.py @@ -14,10 +14,10 @@ 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 + + Converts between libEnsemble's numpy structured array format and gest-api's dictionary format for individual points. - + Parameters ---------- H : numpy structured array @@ -30,30 +30,30 @@ def gest_api_sim(H, persis_info, sim_specs, libE_info): - "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 = [] @@ -63,25 +63,24 @@ def get_vocs_fields(vocs, attr_names): 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 + return H_o, persis_info diff --git a/libensemble/specs.py b/libensemble/specs.py index f0db52d36..8ee981e81 100644 --- a/libensemble/specs.py +++ b/libensemble/specs.py @@ -103,8 +103,9 @@ def set_fields_from_vocs(self): # 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 @@ -112,7 +113,7 @@ def set_fields_from_vocs(self): 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 @@ -120,7 +121,7 @@ def set_fields_from_vocs(self): 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) out_fields.append(_convert_dtype_to_output_tuple(name, dtype)) @@ -217,7 +218,7 @@ 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 @@ -225,7 +226,7 @@ def set_fields_from_vocs(self): 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) out_fields.append(_convert_dtype_to_output_tuple(name, dtype)) diff --git a/libensemble/tests/functionality_tests/test_asktell_sampling_external_gen.py b/libensemble/tests/functionality_tests/test_asktell_sampling_external_gen.py index 84fec8ea8..afc2da8b5 100644 --- a/libensemble/tests/functionality_tests/test_asktell_sampling_external_gen.py +++ b/libensemble/tests/functionality_tests/test_asktell_sampling_external_gen.py @@ -17,10 +17,12 @@ import numpy as np from gest_api.vocs import VOCS + # from gest_api.vocs import ContinuousVariable # Import libEnsemble items for this test from libensemble.alloc_funcs.start_only_persistent import only_persistent_gens as alloc_f + # from libensemble.gen_classes.external.sampling import UniformSampleArray from libensemble.gen_classes.external.sampling import UniformSample from libensemble import Ensemble diff --git a/libensemble/tests/regression_tests/test_xopt_EI.py b/libensemble/tests/regression_tests/test_xopt_EI.py index 43fa52ef7..6937b3bd2 100644 --- a/libensemble/tests/regression_tests/test_xopt_EI.py +++ b/libensemble/tests/regression_tests/test_xopt_EI.py @@ -100,5 +100,5 @@ def xtest_sim(H, persis_info, sim_specs, _): # Perform the run if workflow.is_manager: print(f"Completed {len(H)} simulations") - assert np.array_equal(H['y1'], H['x2']) - assert np.array_equal(H['c1'], H['x1']) + assert np.array_equal(H["y1"], H["x2"]) + assert np.array_equal(H["c1"], H["x1"]) diff --git a/libensemble/tests/regression_tests/test_xopt_EI_xopt_sim.py b/libensemble/tests/regression_tests/test_xopt_EI_xopt_sim.py index e91fdcd1d..f6ecb6e86 100644 --- a/libensemble/tests/regression_tests/test_xopt_EI_xopt_sim.py +++ b/libensemble/tests/regression_tests/test_xopt_EI_xopt_sim.py @@ -94,5 +94,5 @@ def xtest_callable(input_dict: dict, a=0) -> dict: # Perform the run if workflow.is_manager: print(f"Completed {len(H)} simulations") - assert np.array_equal(H['y1'], H['x2']) - assert np.array_equal(H['c1'], H['x1']) + assert np.array_equal(H["y1"], H["x2"]) + assert np.array_equal(H["c1"], H["x1"])