From 4ce8d2c7372b46528169437a8a2d6279bfb4367a Mon Sep 17 00:00:00 2001 From: Ryan Roussel Date: Thu, 4 Jun 2026 12:57:59 -0400 Subject: [PATCH 01/25] initial commit --- pyproject.toml | 1 + src/blop/__init__.py | 4 + src/blop/tests/xopt/test_agent.py | 63 ++++++ src/blop/tests/xopt/test_mapping.py | 62 ++++++ src/blop/tests/xopt/test_optimizer.py | 63 ++++++ src/blop/xopt/__init__.py | 5 + src/blop/xopt/agent.py | 187 ++++++++++++++++++ src/blop/xopt/mapping.py | 156 +++++++++++++++ src/blop/xopt/optimizer.py | 263 ++++++++++++++++++++++++++ 9 files changed, 804 insertions(+) create mode 100644 src/blop/tests/xopt/test_agent.py create mode 100644 src/blop/tests/xopt/test_mapping.py create mode 100644 src/blop/tests/xopt/test_optimizer.py create mode 100644 src/blop/xopt/__init__.py create mode 100644 src/blop/xopt/agent.py create mode 100644 src/blop/xopt/mapping.py create mode 100644 src/blop/xopt/optimizer.py diff --git a/pyproject.toml b/pyproject.toml index 90ac1139..b90a2330 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ maintainers = [ requires-python = ">=3.10" dependencies = [ "ax-platform>=1.1.0,<1.3", + "xopt", "bluesky>=1.14.2", "bluesky-queueserver-api>=0.0.12", "torch", diff --git a/src/blop/__init__.py b/src/blop/__init__.py index c2d138e6..9454c3bf 100644 --- a/src/blop/__init__.py +++ b/src/blop/__init__.py @@ -1,5 +1,6 @@ from .ax import DOF, Agent, ChoiceDOF, DOFConstraint, Objective, OutcomeConstraint, RangeDOF, ScalarizedObjective from .plans import acquire_baseline, default_acquire, optimize, optimize_step, sample_suggestions +from .xopt import XoptAgent, XoptOptimizer, build_vocs try: from ._version import __version__ @@ -21,4 +22,7 @@ "optimize", "optimize_step", "sample_suggestions", + "XoptAgent", + "XoptOptimizer", + "build_vocs", ] diff --git a/src/blop/tests/xopt/test_agent.py b/src/blop/tests/xopt/test_agent.py new file mode 100644 index 00000000..f7bd2252 --- /dev/null +++ b/src/blop/tests/xopt/test_agent.py @@ -0,0 +1,63 @@ +from unittest.mock import MagicMock + +import pytest +from bluesky.run_engine import RunEngine + +xopt = pytest.importorskip("xopt") + +from xopt.generators.random import RandomGenerator + +from blop.ax.dof import RangeDOF +from blop.ax.objective import Objective +from blop.tests.conftest import MovableSignal, ReadableSignal +from blop.xopt.agent import XoptAgent + + +@pytest.fixture(scope="function") +def RE(): + return RunEngine({}) + + +def test_xopt_agent_init_and_suggest(): + movable = MovableSignal(name="x") + readable = ReadableSignal(name="det") + dof = RangeDOF(actuator=movable, bounds=(0.0, 1.0), parameter_type="float") + objective = Objective(name="score", minimize=True) + + evaluation_function = MagicMock(return_value=[{"_id": 0, "score": 0.0}]) + agent = XoptAgent( + sensors=[readable], + dofs=[dof], + objectives=[objective], + evaluation_function=evaluation_function, + generator=RandomGenerator, + ) + + suggestions = agent.suggest(1) + assert len(suggestions) == 1 + assert "_id" in suggestions[0] + assert "x" in suggestions[0] + + +def test_xopt_agent_optimize_runs(RE): + movable = MovableSignal(name="x") + readable = ReadableSignal(name="det") + dof = RangeDOF(actuator=movable, bounds=(0.0, 1.0), parameter_type="float") + objective = Objective(name="score", minimize=True) + + def evaluate(uid, suggestions): + return [{"_id": suggestion["_id"], "score": float(suggestion["x"])} for suggestion in suggestions] + + agent = XoptAgent( + sensors=[readable], + dofs=[dof], + objectives=[objective], + evaluation_function=evaluate, + generator=RandomGenerator, + ) + + RE(agent.optimize(iterations=2, n_points=1)) + + assert agent.optimizer.generator.data is not None + assert len(agent.optimizer.generator.data) == 2 + assert len(agent.get_best_points()) >= 1 diff --git a/src/blop/tests/xopt/test_mapping.py b/src/blop/tests/xopt/test_mapping.py new file mode 100644 index 00000000..4d3cc057 --- /dev/null +++ b/src/blop/tests/xopt/test_mapping.py @@ -0,0 +1,62 @@ +import pytest + +xopt = pytest.importorskip("xopt") + +from blop.ax.dof import DOFConstraint, ChoiceDOF, RangeDOF +from blop.ax.objective import Objective, OutcomeConstraint, ScalarizedObjective +from blop.tests.conftest import ReadableSignal +from blop.xopt.mapping import build_vocs + + +def test_build_vocs_maps_basic_objects(): + dof_x = RangeDOF(name="x", bounds=(0.0, 10.0), parameter_type="float") + dof_mode = ChoiceDOF(name="mode", values=[0, 1], parameter_type="int") + objective = Objective(name="score", minimize=True) + outcome_constraint = OutcomeConstraint("s <= 2.5", s=objective) + + vocs = build_vocs( + dofs=[dof_x, dof_mode], + objectives=[objective], + outcome_constraints=[outcome_constraint], + sensors=[ReadableSignal(name="detector")], + ) + + assert vocs.variables["x"].domain == [0.0, 10.0] + assert vocs.variables["mode"].domain == [0.0, 1.0] + assert "score" in vocs.objectives + assert "score" in vocs.constraints + assert "detector" in vocs.observables + + +def test_build_vocs_applies_single_variable_dof_constraint(): + dof_x = RangeDOF(name="x", bounds=(0.0, 10.0), parameter_type="float") + objective = Objective(name="score", minimize=True) + dof_constraint = DOFConstraint("x >= 3.0", x=dof_x) + + vocs = build_vocs( + dofs=[dof_x], + objectives=[objective], + dof_constraints=[dof_constraint], + ) + + assert vocs.variables["x"].domain == [3.0, 10.0] + + +def test_build_vocs_rejects_scalarized_objective_mapping(): + with pytest.raises(ValueError): + build_vocs( + dofs=[RangeDOF(name="x", bounds=(0.0, 1.0), parameter_type="float")], + objectives=ScalarizedObjective("a + b", minimize=True, a="oa", b="ob"), + ) + + +def test_build_vocs_rejects_multivariable_dof_constraint(): + x = RangeDOF(name="x", bounds=(0.0, 10.0), parameter_type="float") + y = RangeDOF(name="y", bounds=(0.0, 10.0), parameter_type="float") + + with pytest.raises(ValueError): + build_vocs( + dofs=[x, y], + objectives=[Objective(name="score", minimize=True)], + dof_constraints=[DOFConstraint("x + y <= 1", x=x, y=y)], + ) diff --git a/src/blop/tests/xopt/test_optimizer.py b/src/blop/tests/xopt/test_optimizer.py new file mode 100644 index 00000000..92d78381 --- /dev/null +++ b/src/blop/tests/xopt/test_optimizer.py @@ -0,0 +1,63 @@ +import pytest + +xopt = pytest.importorskip("xopt") + +from xopt.generators.random import RandomGenerator +from xopt.vocs import VOCS + +from blop.xopt.optimizer import XoptOptimizer + + +def test_xopt_optimizer_suggest_and_ingest(): + vocs = VOCS(variables={"x": [0.0, 1.0]}, objectives={"y": "MINIMIZE"}) + optimizer = XoptOptimizer(generator=RandomGenerator, vocs=vocs) + + suggestions = optimizer.suggest(2) + assert len(suggestions) == 2 + assert all("_id" in suggestion for suggestion in suggestions) + + outcomes = [{"_id": suggestion["_id"], "y": float(i)} for i, suggestion in enumerate(suggestions)] + optimizer.ingest(outcomes) + + assert optimizer.generator.data is not None + assert len(optimizer.generator.data) == 2 + + +def test_xopt_optimizer_get_best_points_single_objective_minimize(): + vocs = VOCS(variables={"x": [0.0, 1.0]}, objectives={"y": "MINIMIZE"}) + optimizer = XoptOptimizer(generator=RandomGenerator, vocs=vocs) + + optimizer.ingest([ + {"x": 0.1, "y": 5.0}, + {"x": 0.2, "y": 1.0}, + {"x": 0.3, "y": 3.0}, + ]) + + best_points = optimizer.get_best_points() + assert len(best_points) == 1 + _, params, outcomes = best_points[0] + assert params["x"] == 0.2 + assert outcomes["y"] == 1.0 + + +def test_xopt_optimizer_checkpoint_roundtrip(tmp_path): + vocs = VOCS(variables={"x": [0.0, 1.0]}, objectives={"y": "MINIMIZE"}) + checkpoint_path = tmp_path / "xopt_optimizer.pkl" + optimizer = XoptOptimizer(generator=RandomGenerator, vocs=vocs, checkpoint_path=str(checkpoint_path)) + + suggestions = optimizer.suggest(1) + optimizer.ingest([{"_id": suggestions[0]["_id"], "y": 0.5}]) + optimizer.checkpoint() + + recovered = XoptOptimizer.from_checkpoint(str(checkpoint_path)) + assert recovered.generator.data is not None + assert len(recovered.generator.data) == 1 + + +def test_xopt_optimizer_applies_fixed_parameters(): + vocs = VOCS(variables={"x": [0.0, 1.0], "z": [0.0, 2.0]}, objectives={"y": "MINIMIZE"}) + optimizer = XoptOptimizer(generator=RandomGenerator, vocs=vocs) + optimizer.fixed_parameters = {"z": 1.25} + + suggestions = optimizer.suggest(3) + assert all(suggestion["z"] == 1.25 for suggestion in suggestions) diff --git a/src/blop/xopt/__init__.py b/src/blop/xopt/__init__.py new file mode 100644 index 00000000..a9837b1f --- /dev/null +++ b/src/blop/xopt/__init__.py @@ -0,0 +1,5 @@ +from .agent import XoptAgent +from .mapping import build_vocs +from .optimizer import XoptOptimizer + +__all__ = ["XoptAgent", "XoptOptimizer", "build_vocs"] diff --git a/src/blop/xopt/agent.py b/src/blop/xopt/agent.py new file mode 100644 index 00000000..7d5bc47d --- /dev/null +++ b/src/blop/xopt/agent.py @@ -0,0 +1,187 @@ +import logging +from collections.abc import Mapping, Sequence +from typing import Any, cast + +import bluesky.preprocessors as bpp +from bluesky.callbacks import CallbackBase +from bluesky.utils import MsgGenerator + +from ..callbacks.logger import OptimizationLogger +from ..callbacks.router import OptimizationCallbackRouter +from ..plan_stubs import navigate_to_best +from ..plans import acquire_baseline, optimize, sample_suggestions +from ..protocols import AcquisitionPlan, Actuator, EvaluationFunction, OptimizationProblem, Sensor +from ..utils import InferredReadable +from ..ax.dof import DOF, DOFConstraint +from ..ax.objective import Objective, OutcomeConstraint, ScalarizedObjective +from .mapping import build_vocs +from .optimizer import XoptOptimizer + +logger = logging.getLogger(__name__) + + +class XoptAgent: + """Synchronous blop agent that wraps an arbitrary Xopt generator.""" + + def __init__( + self, + sensors: Sequence[Sensor], + dofs: Sequence[DOF], + objectives: Sequence[Objective] | ScalarizedObjective, + evaluation_function: EvaluationFunction, + *, + generator: Any, + generator_kwargs: dict[str, Any] | None = None, + acquisition_plan: AcquisitionPlan | None = None, + dof_constraints: Sequence[DOFConstraint] | None = None, + outcome_constraints: Sequence[OutcomeConstraint] | None = None, + checkpoint_path: str | None = None, + ): + if any(isinstance(dof.actuator, str) for dof in dofs): + dof_actuator_strs = [dof.actuator for dof in dofs if isinstance(dof.actuator, str)] + raise ValueError( + f"DOFs with actuators must be `Actuator` instances, not strings. Got strings for: {dof_actuator_strs}" + ) + + vocs = build_vocs( + dofs=dofs, + objectives=objectives, + sensors=sensors, + dof_constraints=dof_constraints, + outcome_constraints=outcome_constraints, + ) + + self._sensors = sensors + self._actuators: Sequence[Actuator] = [cast(Actuator, dof.actuator) for dof in dofs if dof.actuator is not None] + self._evaluation_function = evaluation_function + self._acquisition_plan = acquisition_plan + self._optimizer = XoptOptimizer( + generator=generator, + vocs=vocs, + generator_kwargs=generator_kwargs, + checkpoint_path=checkpoint_path, + ) + self._readable_cache: dict[str, InferredReadable] = {} + self._callbacks: list[CallbackBase] = [OptimizationLogger()] + self._callback_router = OptimizationCallbackRouter(self._callbacks) + + @classmethod + def from_checkpoint( + cls, + checkpoint_path: str, + actuators: Sequence[Actuator], + sensors: Sequence[Sensor], + evaluation_function: EvaluationFunction, + acquisition_plan: AcquisitionPlan | None = None, + ) -> "XoptAgent": + instance = object.__new__(cls) + instance._optimizer = XoptOptimizer.from_checkpoint(checkpoint_path) + instance._actuators = actuators + instance._sensors = sensors + instance._evaluation_function = evaluation_function + instance._acquisition_plan = acquisition_plan + instance._readable_cache = {} + instance._callbacks = [OptimizationLogger()] + instance._callback_router = OptimizationCallbackRouter(instance._callbacks) + return instance + + @property + def checkpoint_path(self) -> str | None: + return self._optimizer.checkpoint_path + + @property + def optimizer(self) -> XoptOptimizer: + """Return the underlying Xopt-backed optimizer adapter.""" + return self._optimizer + + @property + def fixed_dofs(self) -> dict[str, Any] | None: + return self._optimizer.fixed_parameters + + @fixed_dofs.setter + def fixed_dofs(self, fixed_dofs: dict[DOF, Any] | None) -> None: + if not fixed_dofs: + self._optimizer.fixed_parameters = None + return + + self._optimizer.fixed_parameters = {dof.parameter_name: value for dof, value in fixed_dofs.items()} + + def suggest(self, num_points: int = 1) -> list[dict]: + return self._optimizer.suggest(num_points) + + def ingest(self, points: list[dict]) -> None: + self._optimizer.ingest(points) + + def get_best_points(self): + return self._optimizer.get_best_points() + + def checkpoint(self) -> None: + self._optimizer.checkpoint() + + @property + def callbacks(self) -> list[CallbackBase]: + return self._callbacks + + def subscribe(self, callback: CallbackBase) -> None: + if callback in self._callbacks: + raise ValueError(f"Callback {callback!r} is already subscribed.") + self._callbacks.append(callback) + + def unsubscribe(self, callback: CallbackBase) -> None: + self._callbacks.remove(callback) + + @property + def sensors(self) -> Sequence[Sensor]: + return self._sensors + + @property + def actuators(self) -> Sequence[Actuator]: + return self._actuators + + @property + def evaluation_function(self) -> EvaluationFunction: + return self._evaluation_function + + @property + def acquisition_plan(self) -> AcquisitionPlan | None: + return self._acquisition_plan + + def to_optimization_problem(self) -> OptimizationProblem: + return OptimizationProblem( + optimizer=self._optimizer, + actuators=self.actuators, + sensors=self.sensors, + evaluation_function=self.evaluation_function, + acquisition_plan=self.acquisition_plan, + ) + + def acquire_baseline(self, parameterization: dict[str, Any] | None = None) -> MsgGenerator[None]: + yield from acquire_baseline(self.to_optimization_problem(), parameterization=parameterization) + + def optimize(self, iterations: int = 1, n_points: int = 1) -> MsgGenerator[None]: + optimize_plan = optimize( + self.to_optimization_problem(), iterations=iterations, n_points=n_points, readable_cache=self._readable_cache + ) + if self._callbacks: + optimize_plan = bpp.subs_wrapper(optimize_plan, self._callback_router) + + yield from optimize_plan + + def sample_suggestions(self, suggestions: list[dict]) -> MsgGenerator[tuple[str, list[dict], list[dict]]]: + sample_suggestions_plan = sample_suggestions( + self.to_optimization_problem(), suggestions=suggestions, readable_cache=self._readable_cache + ) + if self._callbacks: + sample_suggestions_plan = bpp.subs_wrapper(sample_suggestions_plan, self._callback_router) + + return (yield from sample_suggestions_plan) + + def navigate_to_best(self, parameterization: Mapping | None = None) -> MsgGenerator[None]: + optimization_problem = self.to_optimization_problem() + return ( + yield from navigate_to_best( + optimization_problem.actuators, + optimization_problem.optimizer, + parameterization, + ) + ) diff --git a/src/blop/xopt/mapping.py b/src/blop/xopt/mapping.py new file mode 100644 index 00000000..426f6111 --- /dev/null +++ b/src/blop/xopt/mapping.py @@ -0,0 +1,156 @@ +import re +from collections.abc import Sequence +from typing import Any + +from xopt import VOCS + +from ..ax.dof import ChoiceDOF, DOF, DOFConstraint, RangeDOF +from ..ax.objective import Objective, OutcomeConstraint, ScalarizedObjective +from ..protocols import Sensor + + +_INEQUALITY_RE = re.compile(r"^\s*(?P.+?)\s*(?P<=|>=|<|>)\s*(?P.+?)\s*$") +_SYMBOL_RE = re.compile(r"^[A-Za-z_]\w*$") + + +def _sensor_name(sensor: Sensor | str) -> str: + return sensor if isinstance(sensor, str) else sensor.name + + +def _parse_single_symbol_inequality(expression: str) -> tuple[str, str, float] | None: + match = _INEQUALITY_RE.match(expression) + if match is None: + return None + + left = match.group("left").strip() + op = match.group("op") + right = match.group("right").strip() + + if _SYMBOL_RE.match(left): + try: + return left, op, float(right) + except ValueError: + return None + + if _SYMBOL_RE.match(right): + try: + value = float(left) + except ValueError: + return None + + flip_op = {"<=": ">=", ">=": "<=", "<": ">", ">": "<"}[op] + return right, flip_op, value + + return None + + +def _apply_dof_constraints( + variables: dict[str, list[float] | list[float | int | str | bool]], dof_constraints: Sequence[DOFConstraint] +) -> None: + for constraint in dof_constraints: + parsed = _parse_single_symbol_inequality(constraint.ax_constraint) + if parsed is None: + raise ValueError( + "Xopt mapping currently supports only single-variable DOF constraints, " + f"got: {constraint.ax_constraint!r}." + ) + + name, op, value = parsed + if name not in variables: + raise ValueError(f"Unknown variable {name!r} in DOF constraint {constraint.ax_constraint!r}.") + + current = variables[name] + if not (isinstance(current, list) and len(current) == 2 and all(isinstance(v, (int, float)) for v in current)): + raise ValueError( + f"DOF constraint {constraint.ax_constraint!r} targets non-range variable {name!r}; " + "only RangeDOF constraints are supported." + ) + + lower, upper = float(current[0]), float(current[1]) + if op in ("<=", "<"): + upper = min(upper, value) + else: + lower = max(lower, value) + + if lower > upper: + raise ValueError( + f"DOF constraint {constraint.ax_constraint!r} produces invalid bounds for {name!r}: [{lower}, {upper}]" + ) + + variables[name] = [lower, upper] + + +def _outcome_constraints_to_vocs_constraints(outcome_constraints: Sequence[OutcomeConstraint]) -> dict[str, list[Any]]: + constraints: dict[str, list[Any]] = {} + for constraint in outcome_constraints: + parsed = _parse_single_symbol_inequality(constraint.ax_constraint) + if parsed is None: + raise ValueError( + "Xopt mapping currently supports only single-metric outcome constraints, " + f"got: {constraint.ax_constraint!r}." + ) + + metric_name, op, value = parsed + if op in ("<=", "<"): + constraints[metric_name] = ["LESS_THAN", value] + else: + constraints[metric_name] = ["GREATER_THAN", value] + + return constraints + + +def _objectives_to_vocs_objectives(objectives: Sequence[Objective] | ScalarizedObjective) -> dict[str, str]: + if isinstance(objectives, ScalarizedObjective): + raise ValueError( + "ScalarizedObjective cannot be auto-mapped to Xopt VOCS objectives. " + "Provide an explicit scalarized metric from your evaluation function and use Objective." + ) + + if not objectives: + raise ValueError("At least one objective is required to build VOCS.") + + return {objective.name: ("MINIMIZE" if objective.minimize else "MAXIMIZE") for objective in objectives} + + +def build_vocs( + *, + dofs: Sequence[DOF], + objectives: Sequence[Objective] | ScalarizedObjective, + sensors: Sequence[Sensor] | None = None, + dof_constraints: Sequence[DOFConstraint] | None = None, + outcome_constraints: Sequence[OutcomeConstraint] | None = None, +) -> VOCS: + """Build an Xopt VOCS object from blop domain objects.""" + variables: dict[str, list[float] | list[float | int | str | bool]] = {} + + for dof in dofs: + if isinstance(dof, RangeDOF): + variables[dof.parameter_name] = [float(dof.bounds[0]), float(dof.bounds[1])] + elif isinstance(dof, ChoiceDOF): + if any(not isinstance(value, (int, float)) for value in dof.values): + raise ValueError( + "Xopt VOCS currently supports numeric variables only. " + f"ChoiceDOF {dof.parameter_name!r} has non-numeric values: {dof.values!r}" + ) + variables[dof.parameter_name] = [float(value) for value in dof.values] + else: + raise TypeError(f"Unsupported DOF type for Xopt mapping: {type(dof).__name__}") + + if dof_constraints: + _apply_dof_constraints(variables, dof_constraints) + + vocs_objectives = _objectives_to_vocs_objectives(objectives) + vocs_constraints = _outcome_constraints_to_vocs_constraints(outcome_constraints or []) + + observables: list[str] = [] + if sensors: + reserved_names = set(vocs_objectives) | set(vocs_constraints) + observables = [name for name in (_sensor_name(sensor) for sensor in sensors) if name not in reserved_names] + + return VOCS( + variables=variables, + objectives=vocs_objectives, + constraints=vocs_constraints, + constants={}, + observables=observables, + ) diff --git a/src/blop/xopt/optimizer.py b/src/blop/xopt/optimizer.py new file mode 100644 index 00000000..576dc525 --- /dev/null +++ b/src/blop/xopt/optimizer.py @@ -0,0 +1,263 @@ +import pickle +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +import pandas as pd +from xopt import VOCS +from xopt.generator import Generator + +from ..protocols import CanRegisterSuggestions, Checkpointable, ID_KEY, Optimizer, TrialFaultAware + + +def _objective_minimize_flag(objective: Any) -> bool: + if isinstance(objective, str): + return objective.strip().upper() == "MINIMIZE" + + objective_name = objective.__class__.__name__.lower() + if "minimize" in objective_name: + return True + if "maximize" in objective_name: + return False + + return True + + +def _constraint_satisfied(value: float, op: str, threshold: float) -> bool: + if op == "LESS_THAN": + return value <= threshold + if op == "GREATER_THAN": + return value >= threshold + raise ValueError(f"Unsupported VOCS constraint operator: {op!r}") + + +def _constraint_to_pair(constraint: Any) -> tuple[str, float]: + if isinstance(constraint, (list, tuple)) and len(constraint) == 2: + return str(constraint[0]).upper(), float(constraint[1]) + + name = constraint.__class__.__name__.lower() + if hasattr(constraint, "value"): + value = float(constraint.value) + if "lessthan" in name: + return "LESS_THAN", value + if "greaterthan" in name: + return "GREATER_THAN", value + + raise ValueError(f"Unsupported VOCS constraint representation: {constraint!r}") + + +class XoptOptimizer(Optimizer, Checkpointable, CanRegisterSuggestions, TrialFaultAware): + """Adapter that exposes an arbitrary Xopt generator through blop's Optimizer protocol.""" + + def __init__( + self, + generator: Generator | type[Generator], + *, + vocs: VOCS | None = None, + generator_kwargs: dict[str, Any] | None = None, + checkpoint_path: str | None = None, + ): + generator_kwargs = generator_kwargs or {} + + if isinstance(generator, type): + if vocs is None and "vocs" not in generator_kwargs: + raise ValueError("vocs must be provided when initializing XoptOptimizer with a generator class.") + if "vocs" not in generator_kwargs: + self._generator = generator(vocs=vocs, **generator_kwargs) + else: + self._generator = generator(**generator_kwargs) + else: + self._generator = generator + if vocs is not None and self._generator.vocs != vocs: + raise ValueError("Provided vocs does not match generator.vocs.") + + self._checkpoint_path = checkpoint_path + self._fixed_parameters: dict[str, Any] | None = None + self._next_id = 0 + self._params_by_id: dict[int | str, dict[str, Any]] = {} + self._seed_state_from_existing_data() + + @classmethod + def from_checkpoint(cls, checkpoint_path: str) -> "XoptOptimizer": + path = Path(checkpoint_path) + with path.open("rb") as stream: + payload = pickle.load(stream) + + instance = object.__new__(cls) + instance._generator = payload["generator"] + instance._checkpoint_path = str(path) + instance._fixed_parameters = payload.get("fixed_parameters") + instance._next_id = payload.get("next_id", 0) + instance._params_by_id = payload.get("params_by_id", {}) + instance._seed_state_from_existing_data() + return instance + + @property + def checkpoint_path(self) -> str | None: + return self._checkpoint_path + + @property + def generator(self) -> Generator: + """Return the underlying Xopt generator instance.""" + return self._generator + + @property + def vocs(self) -> VOCS: + return self._generator.vocs + + @property + def fixed_parameters(self) -> dict[str, Any] | None: + return self._fixed_parameters + + @fixed_parameters.setter + def fixed_parameters(self, fixed_parameters: dict[str, Any] | None) -> None: + if not fixed_parameters: + self._fixed_parameters = None + return + + unknown_names = set(fixed_parameters) - set(self.vocs.variable_names) + if unknown_names: + raise KeyError(f"Unknown fixed parameter(s): {sorted(unknown_names)}") + + self._fixed_parameters = dict(fixed_parameters) + + def _seed_state_from_existing_data(self) -> None: + data = self._generator.data + if data is None or len(data) == 0: + return + + for _, row in data.iterrows(): + if ID_KEY in row and pd.notna(row[ID_KEY]): + trial_id = row[ID_KEY] + else: + trial_id = self._next_id + self._next_id += 1 + + if isinstance(trial_id, float) and trial_id.is_integer(): + trial_id = int(trial_id) + + self._params_by_id[trial_id] = {name: row[name] for name in self.vocs.variable_names if name in row} + if isinstance(trial_id, int): + self._next_id = max(self._next_id, trial_id + 1) + + def suggest(self, num_points: int | None = None) -> list[dict]: + if num_points is None: + num_points = 1 + + suggestions = self._generator.generate(num_points) + if self._fixed_parameters: + suggestions = [{**suggestion, **self._fixed_parameters} for suggestion in suggestions] + return self.register_suggestions(suggestions) + + def register_suggestions(self, suggestions: list[dict]) -> list[dict]: + registered: list[dict] = [] + for suggestion in suggestions: + trial_id = self._next_id + self._next_id += 1 + + params = {name: suggestion[name] for name in self.vocs.variable_names if name in suggestion} + self._params_by_id[trial_id] = params + registered.append({ID_KEY: trial_id, **suggestion}) + + return registered + + def ingest(self, points: list[dict]) -> None: + rows: list[dict[str, Any]] = [] + + for point in points: + trial_id = point.get(ID_KEY) + if trial_id is None: + trial_id = self._next_id + self._next_id += 1 + + point_parameters = {name: point[name] for name in self.vocs.variable_names if name in point} + if trial_id in self._params_by_id: + parameters = {**self._params_by_id[trial_id], **point_parameters} + else: + parameters = point_parameters + + self._params_by_id[trial_id] = parameters + outcomes = {k: v for k, v in point.items() if k not in set(self.vocs.variable_names) | {ID_KEY}} + rows.append({ID_KEY: trial_id, **parameters, **outcomes}) + + new_data = pd.DataFrame(rows) + self._generator.add_data(new_data) + + def register_failures(self, suggestions: list[dict]) -> None: + for suggestion in suggestions: + trial_id = suggestion.get(ID_KEY) + if trial_id in self._params_by_id: + self._params_by_id.pop(trial_id) + + def _feasible_mask(self, data: pd.DataFrame) -> pd.Series: + if not self.vocs.constraints: + return pd.Series([True] * len(data), index=data.index) + + mask = pd.Series([True] * len(data), index=data.index) + for constraint_name, constraint in self.vocs.constraints.items(): + if constraint_name not in data: + mask &= False + continue + + op, value = _constraint_to_pair(constraint) + mask &= data[constraint_name].astype(float).apply(lambda x: _constraint_satisfied(x, op, value)) + + return mask + + def _objective_names(self) -> list[str]: + return list(self.vocs.objectives.keys()) if self.vocs.objectives else [] + + def _output_names(self) -> list[str]: + names = self._objective_names() + if self.vocs.constraints: + names.extend(self.vocs.constraints.keys()) + if getattr(self.vocs, "observables", None): + names.extend(self.vocs.observables) + return names + + def get_best_points(self) -> list[tuple[int | str, Mapping, Mapping]]: + data = self._generator.data + if data is None or len(data) == 0: + return [] + + candidates = data[self._feasible_mask(data)] + if len(candidates) == 0: + candidates = data + + objective_names = self._objective_names() + if len(objective_names) == 1 and objective_names[0] in candidates: + objective_name = objective_names[0] + objective_spec = self.vocs.objectives[objective_name] + minimize = _objective_minimize_flag(objective_spec) + objective_values = candidates[objective_name].astype(float) + best_index = objective_values.idxmin() if minimize else objective_values.idxmax() + selected = candidates.loc[[best_index]] + else: + selected = candidates + + output_names = self._output_names() + results: list[tuple[int | str, Mapping, Mapping]] = [] + for _, row in selected.iterrows(): + trial_id = row[ID_KEY] if ID_KEY in row else _ + if isinstance(trial_id, float) and trial_id.is_integer(): + trial_id = int(trial_id) + + parameterization = {name: row[name] for name in self.vocs.variable_names if name in row} + outcomes = {name: row[name] for name in output_names if name in row} + results.append((trial_id, parameterization, outcomes)) + + return results + + def checkpoint(self) -> None: + if not self._checkpoint_path: + raise ValueError("Checkpoint path is not set. Please set a checkpoint path when initializing the optimizer.") + + payload = { + "generator": self._generator, + "fixed_parameters": self._fixed_parameters, + "next_id": self._next_id, + "params_by_id": self._params_by_id, + } + path = Path(self._checkpoint_path) + with path.open("wb") as stream: + pickle.dump(payload, stream) From 5560ac4b6f60ec6c9ee4cc27d69dfd73f747cb86 Mon Sep 17 00:00:00 2001 From: Ryan Roussel Date: Thu, 4 Jun 2026 13:09:58 -0400 Subject: [PATCH 02/25] add tests --- src/blop/tests/xopt/test_agent.py | 37 +++++++++++++++++++++++++++ src/blop/tests/xopt/test_optimizer.py | 29 +++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/src/blop/tests/xopt/test_agent.py b/src/blop/tests/xopt/test_agent.py index f7bd2252..ecd461fc 100644 --- a/src/blop/tests/xopt/test_agent.py +++ b/src/blop/tests/xopt/test_agent.py @@ -5,6 +5,7 @@ xopt = pytest.importorskip("xopt") +from xopt.generators.bayesian import ExpectedImprovementGenerator from xopt.generators.random import RandomGenerator from blop.ax.dof import RangeDOF @@ -61,3 +62,39 @@ def evaluate(uid, suggestions): assert agent.optimizer.generator.data is not None assert len(agent.optimizer.generator.data) == 2 assert len(agent.get_best_points()) >= 1 + + +def test_xopt_agent_expected_improvement_simple_minimization(RE): + movable = MovableSignal(name="x") + dof = RangeDOF(actuator=movable, bounds=(0.0, 1.0), parameter_type="float") + objective = Objective(name="score", minimize=True) + + def evaluate(uid, suggestions): + return [ + {"_id": suggestion["_id"], "score": (float(suggestion["x"]) - 0.25) ** 2} + for suggestion in suggestions + ] + + agent = XoptAgent( + sensors=[], + dofs=[dof], + objectives=[objective], + evaluation_function=evaluate, + generator=ExpectedImprovementGenerator, + ) + + # Seed EI with initial measurements before optimization iterations. + agent.ingest([ + {"x": 0.0, "score": (0.0 - 0.25) ** 2}, + {"x": 0.5, "score": (0.5 - 0.25) ** 2}, + {"x": 1.0, "score": (1.0 - 0.25) ** 2}, + ]) + + RE(agent.optimize(iterations=3, n_points=1)) + + assert agent.optimizer.generator.data is not None + assert len(agent.optimizer.generator.data) == 6 + best_points = agent.get_best_points() + assert len(best_points) == 1 + _, _, outcomes = best_points[0] + assert outcomes["score"] <= 0.0625 diff --git a/src/blop/tests/xopt/test_optimizer.py b/src/blop/tests/xopt/test_optimizer.py index 92d78381..4da2c0bd 100644 --- a/src/blop/tests/xopt/test_optimizer.py +++ b/src/blop/tests/xopt/test_optimizer.py @@ -2,6 +2,7 @@ xopt = pytest.importorskip("xopt") +from xopt.generators.bayesian import ExpectedImprovementGenerator from xopt.generators.random import RandomGenerator from xopt.vocs import VOCS @@ -61,3 +62,31 @@ def test_xopt_optimizer_applies_fixed_parameters(): suggestions = optimizer.suggest(3) assert all(suggestion["z"] == 1.25 for suggestion in suggestions) + + +def test_xopt_expected_improvement_runs_simple_minimization(): + vocs = VOCS(variables={"x": [0.0, 1.0]}, objectives={"y": "MINIMIZE"}) + optimizer = XoptOptimizer( + generator=ExpectedImprovementGenerator, + vocs=vocs, + ) + + # Seed EI with initial evaluations for model training. + optimizer.ingest([ + {"x": 0.0, "y": (0.0 - 0.25) ** 2}, + {"x": 0.5, "y": (0.5 - 0.25) ** 2}, + {"x": 1.0, "y": (1.0 - 0.25) ** 2}, + ]) + + for _ in range(3): + suggestion = optimizer.suggest(1)[0] + x_val = float(suggestion["x"]) + optimizer.ingest([{"_id": suggestion["_id"], "y": (x_val - 0.25) ** 2}]) + + assert optimizer.generator.data is not None + assert len(optimizer.generator.data) == 6 + + best_points = optimizer.get_best_points() + assert len(best_points) == 1 + _, _, outcomes = best_points[0] + assert outcomes["y"] <= 0.0625 From 07d06a0b125a85380c95059a1ed8579f5fa7180c Mon Sep 17 00:00:00 2001 From: Ryan Roussel Date: Thu, 4 Jun 2026 13:58:09 -0400 Subject: [PATCH 03/25] Update test_optimizer.py --- src/blop/tests/xopt/test_optimizer.py | 170 +++++++++++++++++++++++--- 1 file changed, 151 insertions(+), 19 deletions(-) diff --git a/src/blop/tests/xopt/test_optimizer.py b/src/blop/tests/xopt/test_optimizer.py index 4da2c0bd..7706a83e 100644 --- a/src/blop/tests/xopt/test_optimizer.py +++ b/src/blop/tests/xopt/test_optimizer.py @@ -1,3 +1,4 @@ +import numpy as np import pytest xopt = pytest.importorskip("xopt") @@ -9,36 +10,103 @@ from blop.xopt.optimizer import XoptOptimizer -def test_xopt_optimizer_suggest_and_ingest(): - vocs = VOCS(variables={"x": [0.0, 1.0]}, objectives={"y": "MINIMIZE"}) +def test_xopt_optimizer_init(): + vocs = VOCS( + variables={"x1": [-5.0, 5.0], "x2": [-5.0, 5.0], "x3": [0.0, 5.0]}, + objectives={"y1": "MAXIMIZE", "y2": "MINIMIZE"}, + constraints={"y1": ["GREATER_THAN", 0.0], "y2": ["LESS_THAN", 0.0]}, + ) + + optimizer = XoptOptimizer(generator=RandomGenerator, vocs=vocs) + assert optimizer.generator is not None + assert set(optimizer.vocs.variable_names) == {"x1", "x2", "x3"} + + +def test_xopt_fixed_parameters(): + vocs = VOCS(variables={"x1": [-5.0, 5.0], "x2": [-5.0, 5.0], "x3": [0.0, 5.0]}, objectives={"y1": "MINIMIZE"}) + optimizer = XoptOptimizer(generator=RandomGenerator, vocs=vocs) + + with pytest.raises(KeyError): + optimizer.fixed_parameters = {"x4": 3} + + optimizer.fixed_parameters = {"x3": 3} + assert optimizer.fixed_parameters == {"x3": 3} + + +def test_xopt_optimizer_suggest_ids_and_keys(): + vocs = VOCS(variables={"x1": [-5.0, 5.0], "x2": [-5.0, 5.0], "x3": [0.0, 5.0]}, objectives={"y1": "MINIMIZE"}) optimizer = XoptOptimizer(generator=RandomGenerator, vocs=vocs) - suggestions = optimizer.suggest(2) + suggestions = optimizer.suggest(num_points=2) assert len(suggestions) == 2 - assert all("_id" in suggestion for suggestion in suggestions) + for i, suggestion in enumerate(suggestions): + assert suggestion["_id"] == i + assert "x1" in suggestion + assert "x2" in suggestion + assert "x3" in suggestion + + +def test_xopt_optimizer_ingest_multiple_columns(): + vocs = VOCS( + variables={"x1": [-5.0, 5.0], "x2": [-5.0, 5.0], "x3": [0.0, 5.0]}, + objectives={"y1": "MAXIMIZE", "y2": "MINIMIZE"}, + ) + optimizer = XoptOptimizer(generator=RandomGenerator, vocs=vocs) + + optimizer.ingest( + [ + {"x1": 0.0, "x2": 0.0, "x3": 0.0, "y1": 1.0, "y2": 2.0}, + {"x1": 0.1, "x2": 0.2, "x3": 1.0, "y1": 3.0, "y2": 4.0}, + ] + ) + + data = optimizer.generator.data + assert data is not None + assert len(data) == 2 + assert np.allclose(data["x1"].to_numpy(dtype=float), [0.0, 0.1]) + assert np.allclose(data["x2"].to_numpy(dtype=float), [0.0, 0.2]) + assert np.allclose(data["x3"].to_numpy(dtype=float), [0.0, 1.0]) + assert np.allclose(data["y1"].to_numpy(dtype=float), [1.0, 3.0]) + assert np.allclose(data["y2"].to_numpy(dtype=float), [2.0, 4.0]) + + +def test_xopt_optimizer_ingest_baseline_id(): + vocs = VOCS(variables={"x1": [-5.0, 5.0]}, objectives={"y1": "MINIMIZE"}) + optimizer = XoptOptimizer(generator=RandomGenerator, vocs=vocs) + + optimizer.ingest([{"x1": 0.0, "y1": 1.0, "_id": "baseline"}]) + data = optimizer.generator.data + assert data is not None + assert len(data) == 1 + assert data.iloc[0]["_id"] == "baseline" + + +def test_xopt_optimizer_suggest_ingest(): + vocs = VOCS(variables={"x1": [-5.0, 5.0], "x2": [-5.0, 5.0]}, objectives={"y1": "MINIMIZE", "y2": "MINIMIZE"}) + optimizer = XoptOptimizer(generator=RandomGenerator, vocs=vocs) - outcomes = [{"_id": suggestion["_id"], "y": float(i)} for i, suggestion in enumerate(suggestions)] + suggestions = optimizer.suggest(num_points=2) + outcomes = [ + {"_id": suggestions[0]["_id"], "y1": 1.0, "y2": 2.0}, + {"_id": suggestions[1]["_id"], "y1": 3.0, "y2": 4.0}, + ] optimizer.ingest(outcomes) - assert optimizer.generator.data is not None - assert len(optimizer.generator.data) == 2 + data = optimizer.generator.data + assert data is not None + assert len(data) == 2 + assert np.allclose(data["y1"].to_numpy(dtype=float), [1.0, 3.0]) + assert np.allclose(data["y2"].to_numpy(dtype=float), [2.0, 4.0]) -def test_xopt_optimizer_get_best_points_single_objective_minimize(): - vocs = VOCS(variables={"x": [0.0, 1.0]}, objectives={"y": "MINIMIZE"}) +def test_xopt_optimizer_register_failures(): + vocs = VOCS(variables={"x1": [-5.0, 5.0], "x2": [-5.0, 5.0]}, objectives={"y1": "MINIMIZE"}) optimizer = XoptOptimizer(generator=RandomGenerator, vocs=vocs) - optimizer.ingest([ - {"x": 0.1, "y": 5.0}, - {"x": 0.2, "y": 1.0}, - {"x": 0.3, "y": 3.0}, - ]) + suggestions = optimizer.suggest(num_points=5) + optimizer.register_failures(suggestions) - best_points = optimizer.get_best_points() - assert len(best_points) == 1 - _, params, outcomes = best_points[0] - assert params["x"] == 0.2 - assert outcomes["y"] == 1.0 + assert all(suggestion["_id"] not in optimizer._params_by_id for suggestion in suggestions) def test_xopt_optimizer_checkpoint_roundtrip(tmp_path): @@ -53,6 +121,15 @@ def test_xopt_optimizer_checkpoint_roundtrip(tmp_path): recovered = XoptOptimizer.from_checkpoint(str(checkpoint_path)) assert recovered.generator.data is not None assert len(recovered.generator.data) == 1 + assert recovered.checkpoint_path == str(checkpoint_path) + + +def test_xopt_optimizer_checkpoint_no_path(): + vocs = VOCS(variables={"x1": [-5.0, 5.0]}, objectives={"y1": "MINIMIZE"}) + optimizer = XoptOptimizer(generator=RandomGenerator, vocs=vocs) + + with pytest.raises(ValueError): + optimizer.checkpoint() def test_xopt_optimizer_applies_fixed_parameters(): @@ -64,6 +141,61 @@ def test_xopt_optimizer_applies_fixed_parameters(): assert all(suggestion["z"] == 1.25 for suggestion in suggestions) +def test_xopt_optimizer_get_best_points_single_objective_minimize(): + vocs = VOCS(variables={"x": [0.0, 1.0]}, objectives={"y": "MINIMIZE"}) + optimizer = XoptOptimizer(generator=RandomGenerator, vocs=vocs) + + optimizer.ingest([ + {"x": 0.1, "y": 5.0}, + {"x": 0.2, "y": 1.0}, + {"x": 0.3, "y": 3.0}, + ]) + + best_points = optimizer.get_best_points() + assert len(best_points) == 1 + _, params, outcomes = best_points[0] + assert params["x"] == 0.2 + assert outcomes["y"] == 1.0 + + +def test_xopt_optimizer_get_best_points_single_objective_maximize(): + vocs = VOCS(variables={"x": [0.0, 1.0]}, objectives={"y": "MAXIMIZE"}) + optimizer = XoptOptimizer(generator=RandomGenerator, vocs=vocs) + + optimizer.ingest([ + {"x": 0.1, "y": 5.0}, + {"x": 0.2, "y": 1.0}, + {"x": 0.3, "y": 3.0}, + ]) + + best_points = optimizer.get_best_points() + assert len(best_points) == 1 + _, params, outcomes = best_points[0] + assert params["x"] == 0.1 + assert outcomes["y"] == 5.0 + + +def test_xopt_optimizer_get_best_points_multi_objective(): + vocs = VOCS(variables={"x": [0.0, 10.0]}, objectives={"y1": "MAXIMIZE", "y2": "MAXIMIZE"}) + optimizer = XoptOptimizer(generator=RandomGenerator, vocs=vocs) + + optimizer.ingest( + [ + {"x": 1.0, "y1": 10.0, "y2": 1.0}, + {"x": 5.0, "y1": 1.0, "y2": 10.0}, + {"x": 3.0, "y1": 2.0, "y2": 2.0}, + ] + ) + + best_points = optimizer.get_best_points() + assert len(best_points) == 3 + for trial_id, params, metrics in best_points: + assert isinstance(trial_id, (int, float, str)) + assert "x" in params + assert "y1" in metrics + assert "y2" in metrics + + def test_xopt_expected_improvement_runs_simple_minimization(): vocs = VOCS(variables={"x": [0.0, 1.0]}, objectives={"y": "MINIMIZE"}) optimizer = XoptOptimizer( From 4535a86d30aeda0f6a6f3f304c5d189c506777e4 Mon Sep 17 00:00:00 2001 From: Ryan Roussel Date: Thu, 4 Jun 2026 16:12:29 -0400 Subject: [PATCH 04/25] linting --- src/blop/tests/xopt/test_agent.py | 17 ++++++------- src/blop/tests/xopt/test_mapping.py | 2 +- src/blop/tests/xopt/test_optimizer.py | 36 ++++++++++++++++----------- src/blop/xopt/agent.py | 4 +-- src/blop/xopt/mapping.py | 9 +++---- src/blop/xopt/optimizer.py | 2 +- 6 files changed, 36 insertions(+), 34 deletions(-) diff --git a/src/blop/tests/xopt/test_agent.py b/src/blop/tests/xopt/test_agent.py index ecd461fc..3989786f 100644 --- a/src/blop/tests/xopt/test_agent.py +++ b/src/blop/tests/xopt/test_agent.py @@ -70,10 +70,7 @@ def test_xopt_agent_expected_improvement_simple_minimization(RE): objective = Objective(name="score", minimize=True) def evaluate(uid, suggestions): - return [ - {"_id": suggestion["_id"], "score": (float(suggestion["x"]) - 0.25) ** 2} - for suggestion in suggestions - ] + return [{"_id": suggestion["_id"], "score": (float(suggestion["x"]) - 0.25) ** 2} for suggestion in suggestions] agent = XoptAgent( sensors=[], @@ -84,11 +81,13 @@ def evaluate(uid, suggestions): ) # Seed EI with initial measurements before optimization iterations. - agent.ingest([ - {"x": 0.0, "score": (0.0 - 0.25) ** 2}, - {"x": 0.5, "score": (0.5 - 0.25) ** 2}, - {"x": 1.0, "score": (1.0 - 0.25) ** 2}, - ]) + agent.ingest( + [ + {"x": 0.0, "score": (0.0 - 0.25) ** 2}, + {"x": 0.5, "score": (0.5 - 0.25) ** 2}, + {"x": 1.0, "score": (1.0 - 0.25) ** 2}, + ] + ) RE(agent.optimize(iterations=3, n_points=1)) diff --git a/src/blop/tests/xopt/test_mapping.py b/src/blop/tests/xopt/test_mapping.py index 4d3cc057..a18946de 100644 --- a/src/blop/tests/xopt/test_mapping.py +++ b/src/blop/tests/xopt/test_mapping.py @@ -2,7 +2,7 @@ xopt = pytest.importorskip("xopt") -from blop.ax.dof import DOFConstraint, ChoiceDOF, RangeDOF +from blop.ax.dof import ChoiceDOF, DOFConstraint, RangeDOF from blop.ax.objective import Objective, OutcomeConstraint, ScalarizedObjective from blop.tests.conftest import ReadableSignal from blop.xopt.mapping import build_vocs diff --git a/src/blop/tests/xopt/test_optimizer.py b/src/blop/tests/xopt/test_optimizer.py index 7706a83e..7817104b 100644 --- a/src/blop/tests/xopt/test_optimizer.py +++ b/src/blop/tests/xopt/test_optimizer.py @@ -145,11 +145,13 @@ def test_xopt_optimizer_get_best_points_single_objective_minimize(): vocs = VOCS(variables={"x": [0.0, 1.0]}, objectives={"y": "MINIMIZE"}) optimizer = XoptOptimizer(generator=RandomGenerator, vocs=vocs) - optimizer.ingest([ - {"x": 0.1, "y": 5.0}, - {"x": 0.2, "y": 1.0}, - {"x": 0.3, "y": 3.0}, - ]) + optimizer.ingest( + [ + {"x": 0.1, "y": 5.0}, + {"x": 0.2, "y": 1.0}, + {"x": 0.3, "y": 3.0}, + ] + ) best_points = optimizer.get_best_points() assert len(best_points) == 1 @@ -162,11 +164,13 @@ def test_xopt_optimizer_get_best_points_single_objective_maximize(): vocs = VOCS(variables={"x": [0.0, 1.0]}, objectives={"y": "MAXIMIZE"}) optimizer = XoptOptimizer(generator=RandomGenerator, vocs=vocs) - optimizer.ingest([ - {"x": 0.1, "y": 5.0}, - {"x": 0.2, "y": 1.0}, - {"x": 0.3, "y": 3.0}, - ]) + optimizer.ingest( + [ + {"x": 0.1, "y": 5.0}, + {"x": 0.2, "y": 1.0}, + {"x": 0.3, "y": 3.0}, + ] + ) best_points = optimizer.get_best_points() assert len(best_points) == 1 @@ -204,11 +208,13 @@ def test_xopt_expected_improvement_runs_simple_minimization(): ) # Seed EI with initial evaluations for model training. - optimizer.ingest([ - {"x": 0.0, "y": (0.0 - 0.25) ** 2}, - {"x": 0.5, "y": (0.5 - 0.25) ** 2}, - {"x": 1.0, "y": (1.0 - 0.25) ** 2}, - ]) + optimizer.ingest( + [ + {"x": 0.0, "y": (0.0 - 0.25) ** 2}, + {"x": 0.5, "y": (0.5 - 0.25) ** 2}, + {"x": 1.0, "y": (1.0 - 0.25) ** 2}, + ] + ) for _ in range(3): suggestion = optimizer.suggest(1)[0] diff --git a/src/blop/xopt/agent.py b/src/blop/xopt/agent.py index 7d5bc47d..a62b96e8 100644 --- a/src/blop/xopt/agent.py +++ b/src/blop/xopt/agent.py @@ -6,14 +6,14 @@ from bluesky.callbacks import CallbackBase from bluesky.utils import MsgGenerator +from ..ax.dof import DOF, DOFConstraint +from ..ax.objective import Objective, OutcomeConstraint, ScalarizedObjective from ..callbacks.logger import OptimizationLogger from ..callbacks.router import OptimizationCallbackRouter from ..plan_stubs import navigate_to_best from ..plans import acquire_baseline, optimize, sample_suggestions from ..protocols import AcquisitionPlan, Actuator, EvaluationFunction, OptimizationProblem, Sensor from ..utils import InferredReadable -from ..ax.dof import DOF, DOFConstraint -from ..ax.objective import Objective, OutcomeConstraint, ScalarizedObjective from .mapping import build_vocs from .optimizer import XoptOptimizer diff --git a/src/blop/xopt/mapping.py b/src/blop/xopt/mapping.py index 426f6111..efe79236 100644 --- a/src/blop/xopt/mapping.py +++ b/src/blop/xopt/mapping.py @@ -4,11 +4,10 @@ from xopt import VOCS -from ..ax.dof import ChoiceDOF, DOF, DOFConstraint, RangeDOF +from ..ax.dof import DOF, ChoiceDOF, DOFConstraint, RangeDOF from ..ax.objective import Objective, OutcomeConstraint, ScalarizedObjective from ..protocols import Sensor - _INEQUALITY_RE = re.compile(r"^\s*(?P.+?)\s*(?P<=|>=|<|>)\s*(?P.+?)\s*$") _SYMBOL_RE = re.compile(r"^[A-Za-z_]\w*$") @@ -51,8 +50,7 @@ def _apply_dof_constraints( parsed = _parse_single_symbol_inequality(constraint.ax_constraint) if parsed is None: raise ValueError( - "Xopt mapping currently supports only single-variable DOF constraints, " - f"got: {constraint.ax_constraint!r}." + f"Xopt mapping currently supports only single-variable DOF constraints, got: {constraint.ax_constraint!r}." ) name, op, value = parsed @@ -86,8 +84,7 @@ def _outcome_constraints_to_vocs_constraints(outcome_constraints: Sequence[Outco parsed = _parse_single_symbol_inequality(constraint.ax_constraint) if parsed is None: raise ValueError( - "Xopt mapping currently supports only single-metric outcome constraints, " - f"got: {constraint.ax_constraint!r}." + f"Xopt mapping currently supports only single-metric outcome constraints, got: {constraint.ax_constraint!r}." ) metric_name, op, value = parsed diff --git a/src/blop/xopt/optimizer.py b/src/blop/xopt/optimizer.py index 576dc525..8335e06e 100644 --- a/src/blop/xopt/optimizer.py +++ b/src/blop/xopt/optimizer.py @@ -7,7 +7,7 @@ from xopt import VOCS from xopt.generator import Generator -from ..protocols import CanRegisterSuggestions, Checkpointable, ID_KEY, Optimizer, TrialFaultAware +from ..protocols import ID_KEY, CanRegisterSuggestions, Checkpointable, Optimizer, TrialFaultAware def _objective_minimize_flag(objective: Any) -> bool: From 217258abf57a09b33831f2b26fc7f12093587d28 Mon Sep 17 00:00:00 2001 From: Ryan Roussel Date: Thu, 4 Jun 2026 16:33:41 -0400 Subject: [PATCH 05/25] linting --- src/blop/tests/xopt/test_agent.py | 2 -- src/blop/tests/xopt/test_mapping.py | 2 -- src/blop/tests/xopt/test_optimizer.py | 2 -- src/blop/xopt/optimizer.py | 4 ++-- 4 files changed, 2 insertions(+), 8 deletions(-) diff --git a/src/blop/tests/xopt/test_agent.py b/src/blop/tests/xopt/test_agent.py index 3989786f..64c42766 100644 --- a/src/blop/tests/xopt/test_agent.py +++ b/src/blop/tests/xopt/test_agent.py @@ -3,8 +3,6 @@ import pytest from bluesky.run_engine import RunEngine -xopt = pytest.importorskip("xopt") - from xopt.generators.bayesian import ExpectedImprovementGenerator from xopt.generators.random import RandomGenerator diff --git a/src/blop/tests/xopt/test_mapping.py b/src/blop/tests/xopt/test_mapping.py index a18946de..26e9c2f0 100644 --- a/src/blop/tests/xopt/test_mapping.py +++ b/src/blop/tests/xopt/test_mapping.py @@ -1,7 +1,5 @@ import pytest -xopt = pytest.importorskip("xopt") - from blop.ax.dof import ChoiceDOF, DOFConstraint, RangeDOF from blop.ax.objective import Objective, OutcomeConstraint, ScalarizedObjective from blop.tests.conftest import ReadableSignal diff --git a/src/blop/tests/xopt/test_optimizer.py b/src/blop/tests/xopt/test_optimizer.py index 7817104b..f6997e22 100644 --- a/src/blop/tests/xopt/test_optimizer.py +++ b/src/blop/tests/xopt/test_optimizer.py @@ -1,8 +1,6 @@ import numpy as np import pytest -xopt = pytest.importorskip("xopt") - from xopt.generators.bayesian import ExpectedImprovementGenerator from xopt.generators.random import RandomGenerator from xopt.vocs import VOCS diff --git a/src/blop/xopt/optimizer.py b/src/blop/xopt/optimizer.py index 8335e06e..478f9f05 100644 --- a/src/blop/xopt/optimizer.py +++ b/src/blop/xopt/optimizer.py @@ -199,8 +199,8 @@ def _feasible_mask(self, data: pd.DataFrame) -> pd.Series: mask &= False continue - op, value = _constraint_to_pair(constraint) - mask &= data[constraint_name].astype(float).apply(lambda x: _constraint_satisfied(x, op, value)) + op, threshold = _constraint_to_pair(constraint) + mask &= data[constraint_name].astype(float).apply(lambda x: _constraint_satisfied(x, op, threshold)) return mask From 67910a88d7cf719975220d521cbababbc245c7d1 Mon Sep 17 00:00:00 2001 From: Ryan Roussel Date: Thu, 4 Jun 2026 19:52:31 -0400 Subject: [PATCH 06/25] inline comments --- src/blop/xopt/agent.py | 16 ++++++++++++++++ src/blop/xopt/mapping.py | 15 +++++++++++++++ src/blop/xopt/optimizer.py | 30 ++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+) diff --git a/src/blop/xopt/agent.py b/src/blop/xopt/agent.py index a62b96e8..2dd0466a 100644 --- a/src/blop/xopt/agent.py +++ b/src/blop/xopt/agent.py @@ -37,12 +37,14 @@ def __init__( outcome_constraints: Sequence[OutcomeConstraint] | None = None, checkpoint_path: str | None = None, ): + # Keep behavior parity with Ax Agent: local agents expect real actuator objects, not names. if any(isinstance(dof.actuator, str) for dof in dofs): dof_actuator_strs = [dof.actuator for dof in dofs if isinstance(dof.actuator, str)] raise ValueError( f"DOFs with actuators must be `Actuator` instances, not strings. Got strings for: {dof_actuator_strs}" ) + # Build VOCS from blop objects and initialize runtime dependencies. vocs = build_vocs( dofs=dofs, objectives=objectives, @@ -51,6 +53,7 @@ def __init__( outcome_constraints=outcome_constraints, ) + # Cache acquisition and optimizer state needed by optimize/sample plans. self._sensors = sensors self._actuators: Sequence[Actuator] = [cast(Actuator, dof.actuator) for dof in dofs if dof.actuator is not None] self._evaluation_function = evaluation_function @@ -74,6 +77,7 @@ def from_checkpoint( evaluation_function: EvaluationFunction, acquisition_plan: AcquisitionPlan | None = None, ) -> "XoptAgent": + # Rehydrate optimizer state while restoring runtime-only dependencies explicitly. instance = object.__new__(cls) instance._optimizer = XoptOptimizer.from_checkpoint(checkpoint_path) instance._actuators = actuators @@ -100,6 +104,7 @@ def fixed_dofs(self) -> dict[str, Any] | None: @fixed_dofs.setter def fixed_dofs(self, fixed_dofs: dict[DOF, Any] | None) -> None: + # Convert DOF objects to parameter-name keyed fixed-parameter mapping. if not fixed_dofs: self._optimizer.fixed_parameters = None return @@ -107,15 +112,19 @@ def fixed_dofs(self, fixed_dofs: dict[DOF, Any] | None) -> None: self._optimizer.fixed_parameters = {dof.parameter_name: value for dof, value in fixed_dofs.items()} def suggest(self, num_points: int = 1) -> list[dict]: + # Delegate candidate generation to the optimizer adapter. return self._optimizer.suggest(num_points) def ingest(self, points: list[dict]) -> None: + # Delegate outcome ingestion and model-state updates to optimizer adapter. self._optimizer.ingest(points) def get_best_points(self): + # Return optimizer-derived best point(s) for current objective configuration. return self._optimizer.get_best_points() def checkpoint(self) -> None: + # Persist optimizer state to configured checkpoint artifact. self._optimizer.checkpoint() @property @@ -123,11 +132,13 @@ def callbacks(self) -> list[CallbackBase]: return self._callbacks def subscribe(self, callback: CallbackBase) -> None: + # Register callback for optimize/sample run documents. if callback in self._callbacks: raise ValueError(f"Callback {callback!r} is already subscribed.") self._callbacks.append(callback) def unsubscribe(self, callback: CallbackBase) -> None: + # Remove callback from active optimization subscriptions. self._callbacks.remove(callback) @property @@ -147,6 +158,7 @@ def acquisition_plan(self) -> AcquisitionPlan | None: return self._acquisition_plan def to_optimization_problem(self) -> OptimizationProblem: + # Package runtime components into immutable protocol object used by plans. return OptimizationProblem( optimizer=self._optimizer, actuators=self.actuators, @@ -156,9 +168,11 @@ def to_optimization_problem(self) -> OptimizationProblem: ) def acquire_baseline(self, parameterization: dict[str, Any] | None = None) -> MsgGenerator[None]: + # Reuse standard baseline acquisition plan against this agent's optimization context. yield from acquire_baseline(self.to_optimization_problem(), parameterization=parameterization) def optimize(self, iterations: int = 1, n_points: int = 1) -> MsgGenerator[None]: + # Build plan from shared optimize loop and attach callback routing when enabled. optimize_plan = optimize( self.to_optimization_problem(), iterations=iterations, n_points=n_points, readable_cache=self._readable_cache ) @@ -168,6 +182,7 @@ def optimize(self, iterations: int = 1, n_points: int = 1) -> MsgGenerator[None] yield from optimize_plan def sample_suggestions(self, suggestions: list[dict]) -> MsgGenerator[tuple[str, list[dict], list[dict]]]: + # Evaluate caller-provided suggestions through shared sampling plan. sample_suggestions_plan = sample_suggestions( self.to_optimization_problem(), suggestions=suggestions, readable_cache=self._readable_cache ) @@ -177,6 +192,7 @@ def sample_suggestions(self, suggestions: list[dict]) -> MsgGenerator[tuple[str, return (yield from sample_suggestions_plan) def navigate_to_best(self, parameterization: Mapping | None = None) -> MsgGenerator[None]: + # Move actuators to an explicit or optimizer-derived best parameterization. optimization_problem = self.to_optimization_problem() return ( yield from navigate_to_best( diff --git a/src/blop/xopt/mapping.py b/src/blop/xopt/mapping.py index efe79236..908f6213 100644 --- a/src/blop/xopt/mapping.py +++ b/src/blop/xopt/mapping.py @@ -17,6 +17,7 @@ def _sensor_name(sensor: Sensor | str) -> str: def _parse_single_symbol_inequality(expression: str) -> tuple[str, str, float] | None: + # Accept expressions of the form "name <= value" or "value <= name". match = _INEQUALITY_RE.match(expression) if match is None: return None @@ -26,12 +27,14 @@ def _parse_single_symbol_inequality(expression: str) -> tuple[str, str, float] | right = match.group("right").strip() if _SYMBOL_RE.match(left): + # Canonical case: metric/variable is on the left side. try: return left, op, float(right) except ValueError: return None if _SYMBOL_RE.match(right): + # Reversed case: variable is on the right side, so flip inequality direction. try: value = float(left) except ValueError: @@ -46,6 +49,7 @@ def _parse_single_symbol_inequality(expression: str) -> tuple[str, str, float] | def _apply_dof_constraints( variables: dict[str, list[float] | list[float | int | str | bool]], dof_constraints: Sequence[DOFConstraint] ) -> None: + # Tighten the variable domains in-place using simple scalar inequality constraints. for constraint in dof_constraints: parsed = _parse_single_symbol_inequality(constraint.ax_constraint) if parsed is None: @@ -58,6 +62,7 @@ def _apply_dof_constraints( raise ValueError(f"Unknown variable {name!r} in DOF constraint {constraint.ax_constraint!r}.") current = variables[name] + # Xopt currently receives range constraints only for numeric RangeDOF mappings. if not (isinstance(current, list) and len(current) == 2 and all(isinstance(v, (int, float)) for v in current)): raise ValueError( f"DOF constraint {constraint.ax_constraint!r} targets non-range variable {name!r}; " @@ -65,6 +70,7 @@ def _apply_dof_constraints( ) lower, upper = float(current[0]), float(current[1]) + # Intersect existing bounds with the new constraint. if op in ("<=", "<"): upper = min(upper, value) else: @@ -79,6 +85,7 @@ def _apply_dof_constraints( def _outcome_constraints_to_vocs_constraints(outcome_constraints: Sequence[OutcomeConstraint]) -> dict[str, list[Any]]: + # Convert blop constraint expressions into VOCS-style [operator, threshold] constraints. constraints: dict[str, list[Any]] = {} for constraint in outcome_constraints: parsed = _parse_single_symbol_inequality(constraint.ax_constraint) @@ -88,6 +95,7 @@ def _outcome_constraints_to_vocs_constraints(outcome_constraints: Sequence[Outco ) metric_name, op, value = parsed + # Map inequality direction to Xopt constraint operator semantics. if op in ("<=", "<"): constraints[metric_name] = ["LESS_THAN", value] else: @@ -97,6 +105,7 @@ def _outcome_constraints_to_vocs_constraints(outcome_constraints: Sequence[Outco def _objectives_to_vocs_objectives(objectives: Sequence[Objective] | ScalarizedObjective) -> dict[str, str]: + # ScalarizedObjective is intentionally rejected until a stable translation is defined. if isinstance(objectives, ScalarizedObjective): raise ValueError( "ScalarizedObjective cannot be auto-mapped to Xopt VOCS objectives. " @@ -118,12 +127,14 @@ def build_vocs( outcome_constraints: Sequence[OutcomeConstraint] | None = None, ) -> VOCS: """Build an Xopt VOCS object from blop domain objects.""" + # First build raw variable definitions from blop DOFs. variables: dict[str, list[float] | list[float | int | str | bool]] = {} for dof in dofs: if isinstance(dof, RangeDOF): variables[dof.parameter_name] = [float(dof.bounds[0]), float(dof.bounds[1])] elif isinstance(dof, ChoiceDOF): + # Keep scope explicit: only numeric choices map cleanly to current VOCS variable model. if any(not isinstance(value, (int, float)) for value in dof.values): raise ValueError( "Xopt VOCS currently supports numeric variables only. " @@ -133,17 +144,21 @@ def build_vocs( else: raise TypeError(f"Unsupported DOF type for Xopt mapping: {type(dof).__name__}") + # Apply optional search-space constraints after variables are materialized. if dof_constraints: _apply_dof_constraints(variables, dof_constraints) + # Translate objective and outcome-constraint metadata. vocs_objectives = _objectives_to_vocs_objectives(objectives) vocs_constraints = _outcome_constraints_to_vocs_constraints(outcome_constraints or []) + # Use sensor names as observables except when already used as optimization outputs. observables: list[str] = [] if sensors: reserved_names = set(vocs_objectives) | set(vocs_constraints) observables = [name for name in (_sensor_name(sensor) for sensor in sensors) if name not in reserved_names] + # Build a canonical VOCS object consumed by Xopt generators. return VOCS( variables=variables, objectives=vocs_objectives, diff --git a/src/blop/xopt/optimizer.py b/src/blop/xopt/optimizer.py index 478f9f05..1f374cee 100644 --- a/src/blop/xopt/optimizer.py +++ b/src/blop/xopt/optimizer.py @@ -11,9 +11,11 @@ def _objective_minimize_flag(objective: Any) -> bool: + # Handle string objective specs first (common VOCS representation). if isinstance(objective, str): return objective.strip().upper() == "MINIMIZE" + # Fall back to class-name inspection for typed objective objects. objective_name = objective.__class__.__name__.lower() if "minimize" in objective_name: return True @@ -24,6 +26,7 @@ def _objective_minimize_flag(objective: Any) -> bool: def _constraint_satisfied(value: float, op: str, threshold: float) -> bool: + # Evaluate one normalized constraint against a single numeric value. if op == "LESS_THAN": return value <= threshold if op == "GREATER_THAN": @@ -32,9 +35,11 @@ def _constraint_satisfied(value: float, op: str, threshold: float) -> bool: def _constraint_to_pair(constraint: Any) -> tuple[str, float]: + # Convert common VOCS list/tuple form into a normalized operator/value pair. if isinstance(constraint, (list, tuple)) and len(constraint) == 2: return str(constraint[0]).upper(), float(constraint[1]) + # Support typed constraint objects from gest_api.vocs by class-name convention. name = constraint.__class__.__name__.lower() if hasattr(constraint, "value"): value = float(constraint.value) @@ -59,6 +64,7 @@ def __init__( ): generator_kwargs = generator_kwargs or {} + # Accept either an already-instantiated generator or a generator class. if isinstance(generator, type): if vocs is None and "vocs" not in generator_kwargs: raise ValueError("vocs must be provided when initializing XoptOptimizer with a generator class.") @@ -71,6 +77,7 @@ def __init__( if vocs is not None and self._generator.vocs != vocs: raise ValueError("Provided vocs does not match generator.vocs.") + # Internal state tracks IDs, pending/known parameterizations, and checkpoint metadata. self._checkpoint_path = checkpoint_path self._fixed_parameters: dict[str, Any] | None = None self._next_id = 0 @@ -79,6 +86,7 @@ def __init__( @classmethod def from_checkpoint(cls, checkpoint_path: str) -> "XoptOptimizer": + # Restore all persistent adapter state from pickle payload. path = Path(checkpoint_path) with path.open("rb") as stream: payload = pickle.load(stream) @@ -122,11 +130,13 @@ def fixed_parameters(self, fixed_parameters: dict[str, Any] | None) -> None: self._fixed_parameters = dict(fixed_parameters) def _seed_state_from_existing_data(self) -> None: + # Recover known trial IDs/parameters from existing generator data when available. data = self._generator.data if data is None or len(data) == 0: return for _, row in data.iterrows(): + # Reuse stored IDs when present, otherwise allocate synthetic IDs. if ID_KEY in row and pd.notna(row[ID_KEY]): trial_id = row[ID_KEY] else: @@ -141,15 +151,18 @@ def _seed_state_from_existing_data(self) -> None: self._next_id = max(self._next_id, trial_id + 1) def suggest(self, num_points: int | None = None) -> list[dict]: + # Default to single-point suggestion when caller does not specify cardinality. if num_points is None: num_points = 1 + # Delegate candidate generation to Xopt and optionally enforce fixed variables. suggestions = self._generator.generate(num_points) if self._fixed_parameters: suggestions = [{**suggestion, **self._fixed_parameters} for suggestion in suggestions] return self.register_suggestions(suggestions) def register_suggestions(self, suggestions: list[dict]) -> list[dict]: + # Attach stable blop trial IDs and cache suggested parameterizations by ID. registered: list[dict] = [] for suggestion in suggestions: trial_id = self._next_id @@ -162,14 +175,17 @@ def register_suggestions(self, suggestions: list[dict]) -> list[dict]: return registered def ingest(self, points: list[dict]) -> None: + # Convert outcome payloads to DataFrame rows expected by Xopt generator.add_data(). rows: list[dict[str, Any]] = [] for point in points: + # Preserve provided IDs when available, else allocate a new one. trial_id = point.get(ID_KEY) if trial_id is None: trial_id = self._next_id self._next_id += 1 + # Merge known suggested parameters with any explicit parameters in incoming point. point_parameters = {name: point[name] for name in self.vocs.variable_names if name in point} if trial_id in self._params_by_id: parameters = {**self._params_by_id[trial_id], **point_parameters} @@ -177,24 +193,29 @@ def ingest(self, points: list[dict]) -> None: parameters = point_parameters self._params_by_id[trial_id] = parameters + # Everything not in variables and not _id is treated as measured output. outcomes = {k: v for k, v in point.items() if k not in set(self.vocs.variable_names) | {ID_KEY}} rows.append({ID_KEY: trial_id, **parameters, **outcomes}) + # Persist all new observations into the underlying generator state. new_data = pd.DataFrame(rows) self._generator.add_data(new_data) def register_failures(self, suggestions: list[dict]) -> None: + # Remove failed suggestions from pending parameter cache. for suggestion in suggestions: trial_id = suggestion.get(ID_KEY) if trial_id in self._params_by_id: self._params_by_id.pop(trial_id) def _feasible_mask(self, data: pd.DataFrame) -> pd.Series: + # Compute row-wise feasibility mask from VOCS constraints. if not self.vocs.constraints: return pd.Series([True] * len(data), index=data.index) mask = pd.Series([True] * len(data), index=data.index) for constraint_name, constraint in self.vocs.constraints.items(): + # Missing constraint columns imply non-feasible rows. if constraint_name not in data: mask &= False continue @@ -205,9 +226,11 @@ def _feasible_mask(self, data: pd.DataFrame) -> pd.Series: return mask def _objective_names(self) -> list[str]: + # Preserve VOCS-defined objective ordering where available. return list(self.vocs.objectives.keys()) if self.vocs.objectives else [] def _output_names(self) -> list[str]: + # Outputs include objectives, constraints, and optional observables. names = self._objective_names() if self.vocs.constraints: names.extend(self.vocs.constraints.keys()) @@ -216,15 +239,18 @@ def _output_names(self) -> list[str]: return names def get_best_points(self) -> list[tuple[int | str, Mapping, Mapping]]: + # Return no points when no data has been ingested. data = self._generator.data if data is None or len(data) == 0: return [] + # Prefer feasible points first; if none exist, fall back to all data. candidates = data[self._feasible_mask(data)] if len(candidates) == 0: candidates = data objective_names = self._objective_names() + # For single-objective problems, return the single extremum according to direction. if len(objective_names) == 1 and objective_names[0] in candidates: objective_name = objective_names[0] objective_spec = self.vocs.objectives[objective_name] @@ -233,11 +259,13 @@ def get_best_points(self) -> list[tuple[int | str, Mapping, Mapping]]: best_index = objective_values.idxmin() if minimize else objective_values.idxmax() selected = candidates.loc[[best_index]] else: + # For multi-objective and objective-less modes, return the available candidate set. selected = candidates output_names = self._output_names() results: list[tuple[int | str, Mapping, Mapping]] = [] for _, row in selected.iterrows(): + # Normalize IDs and split into parameter and outcome mappings. trial_id = row[ID_KEY] if ID_KEY in row else _ if isinstance(trial_id, float) and trial_id.is_integer(): trial_id = int(trial_id) @@ -249,9 +277,11 @@ def get_best_points(self) -> list[tuple[int | str, Mapping, Mapping]]: return results def checkpoint(self) -> None: + # Enforce explicit checkpoint path configuration before writing state. if not self._checkpoint_path: raise ValueError("Checkpoint path is not set. Please set a checkpoint path when initializing the optimizer.") + # Persist generator and adapter bookkeeping to a single pickle artifact. payload = { "generator": self._generator, "fixed_parameters": self._fixed_parameters, From 8ddcff0d621c935c3124c99ff88f2df92242b4d3 Mon Sep 17 00:00:00 2001 From: Ryan Roussel Date: Fri, 5 Jun 2026 10:50:16 -0400 Subject: [PATCH 07/25] refactor code to only define an XoptOptimizer class --- src/blop/__init__.py | 4 +- src/blop/tests/xopt/test_agent.py | 97 ------------ src/blop/tests/xopt/test_mapping.py | 60 -------- src/blop/tests/xopt/test_optimizer.py | 121 ++++++++++----- src/blop/xopt/__init__.py | 4 +- src/blop/xopt/agent.py | 203 -------------------------- src/blop/xopt/mapping.py | 168 --------------------- src/blop/xopt/optimizer.py | 20 +-- 8 files changed, 86 insertions(+), 591 deletions(-) delete mode 100644 src/blop/tests/xopt/test_agent.py delete mode 100644 src/blop/tests/xopt/test_mapping.py delete mode 100644 src/blop/xopt/agent.py delete mode 100644 src/blop/xopt/mapping.py diff --git a/src/blop/__init__.py b/src/blop/__init__.py index 9454c3bf..4327d6df 100644 --- a/src/blop/__init__.py +++ b/src/blop/__init__.py @@ -1,6 +1,6 @@ from .ax import DOF, Agent, ChoiceDOF, DOFConstraint, Objective, OutcomeConstraint, RangeDOF, ScalarizedObjective from .plans import acquire_baseline, default_acquire, optimize, optimize_step, sample_suggestions -from .xopt import XoptAgent, XoptOptimizer, build_vocs +from .xopt import XoptOptimizer try: from ._version import __version__ @@ -22,7 +22,5 @@ "optimize", "optimize_step", "sample_suggestions", - "XoptAgent", "XoptOptimizer", - "build_vocs", ] diff --git a/src/blop/tests/xopt/test_agent.py b/src/blop/tests/xopt/test_agent.py deleted file mode 100644 index 64c42766..00000000 --- a/src/blop/tests/xopt/test_agent.py +++ /dev/null @@ -1,97 +0,0 @@ -from unittest.mock import MagicMock - -import pytest -from bluesky.run_engine import RunEngine - -from xopt.generators.bayesian import ExpectedImprovementGenerator -from xopt.generators.random import RandomGenerator - -from blop.ax.dof import RangeDOF -from blop.ax.objective import Objective -from blop.tests.conftest import MovableSignal, ReadableSignal -from blop.xopt.agent import XoptAgent - - -@pytest.fixture(scope="function") -def RE(): - return RunEngine({}) - - -def test_xopt_agent_init_and_suggest(): - movable = MovableSignal(name="x") - readable = ReadableSignal(name="det") - dof = RangeDOF(actuator=movable, bounds=(0.0, 1.0), parameter_type="float") - objective = Objective(name="score", minimize=True) - - evaluation_function = MagicMock(return_value=[{"_id": 0, "score": 0.0}]) - agent = XoptAgent( - sensors=[readable], - dofs=[dof], - objectives=[objective], - evaluation_function=evaluation_function, - generator=RandomGenerator, - ) - - suggestions = agent.suggest(1) - assert len(suggestions) == 1 - assert "_id" in suggestions[0] - assert "x" in suggestions[0] - - -def test_xopt_agent_optimize_runs(RE): - movable = MovableSignal(name="x") - readable = ReadableSignal(name="det") - dof = RangeDOF(actuator=movable, bounds=(0.0, 1.0), parameter_type="float") - objective = Objective(name="score", minimize=True) - - def evaluate(uid, suggestions): - return [{"_id": suggestion["_id"], "score": float(suggestion["x"])} for suggestion in suggestions] - - agent = XoptAgent( - sensors=[readable], - dofs=[dof], - objectives=[objective], - evaluation_function=evaluate, - generator=RandomGenerator, - ) - - RE(agent.optimize(iterations=2, n_points=1)) - - assert agent.optimizer.generator.data is not None - assert len(agent.optimizer.generator.data) == 2 - assert len(agent.get_best_points()) >= 1 - - -def test_xopt_agent_expected_improvement_simple_minimization(RE): - movable = MovableSignal(name="x") - dof = RangeDOF(actuator=movable, bounds=(0.0, 1.0), parameter_type="float") - objective = Objective(name="score", minimize=True) - - def evaluate(uid, suggestions): - return [{"_id": suggestion["_id"], "score": (float(suggestion["x"]) - 0.25) ** 2} for suggestion in suggestions] - - agent = XoptAgent( - sensors=[], - dofs=[dof], - objectives=[objective], - evaluation_function=evaluate, - generator=ExpectedImprovementGenerator, - ) - - # Seed EI with initial measurements before optimization iterations. - agent.ingest( - [ - {"x": 0.0, "score": (0.0 - 0.25) ** 2}, - {"x": 0.5, "score": (0.5 - 0.25) ** 2}, - {"x": 1.0, "score": (1.0 - 0.25) ** 2}, - ] - ) - - RE(agent.optimize(iterations=3, n_points=1)) - - assert agent.optimizer.generator.data is not None - assert len(agent.optimizer.generator.data) == 6 - best_points = agent.get_best_points() - assert len(best_points) == 1 - _, _, outcomes = best_points[0] - assert outcomes["score"] <= 0.0625 diff --git a/src/blop/tests/xopt/test_mapping.py b/src/blop/tests/xopt/test_mapping.py deleted file mode 100644 index 26e9c2f0..00000000 --- a/src/blop/tests/xopt/test_mapping.py +++ /dev/null @@ -1,60 +0,0 @@ -import pytest - -from blop.ax.dof import ChoiceDOF, DOFConstraint, RangeDOF -from blop.ax.objective import Objective, OutcomeConstraint, ScalarizedObjective -from blop.tests.conftest import ReadableSignal -from blop.xopt.mapping import build_vocs - - -def test_build_vocs_maps_basic_objects(): - dof_x = RangeDOF(name="x", bounds=(0.0, 10.0), parameter_type="float") - dof_mode = ChoiceDOF(name="mode", values=[0, 1], parameter_type="int") - objective = Objective(name="score", minimize=True) - outcome_constraint = OutcomeConstraint("s <= 2.5", s=objective) - - vocs = build_vocs( - dofs=[dof_x, dof_mode], - objectives=[objective], - outcome_constraints=[outcome_constraint], - sensors=[ReadableSignal(name="detector")], - ) - - assert vocs.variables["x"].domain == [0.0, 10.0] - assert vocs.variables["mode"].domain == [0.0, 1.0] - assert "score" in vocs.objectives - assert "score" in vocs.constraints - assert "detector" in vocs.observables - - -def test_build_vocs_applies_single_variable_dof_constraint(): - dof_x = RangeDOF(name="x", bounds=(0.0, 10.0), parameter_type="float") - objective = Objective(name="score", minimize=True) - dof_constraint = DOFConstraint("x >= 3.0", x=dof_x) - - vocs = build_vocs( - dofs=[dof_x], - objectives=[objective], - dof_constraints=[dof_constraint], - ) - - assert vocs.variables["x"].domain == [3.0, 10.0] - - -def test_build_vocs_rejects_scalarized_objective_mapping(): - with pytest.raises(ValueError): - build_vocs( - dofs=[RangeDOF(name="x", bounds=(0.0, 1.0), parameter_type="float")], - objectives=ScalarizedObjective("a + b", minimize=True, a="oa", b="ob"), - ) - - -def test_build_vocs_rejects_multivariable_dof_constraint(): - x = RangeDOF(name="x", bounds=(0.0, 10.0), parameter_type="float") - y = RangeDOF(name="y", bounds=(0.0, 10.0), parameter_type="float") - - with pytest.raises(ValueError): - build_vocs( - dofs=[x, y], - objectives=[Objective(name="score", minimize=True)], - dof_constraints=[DOFConstraint("x + y <= 1", x=x, y=y)], - ) diff --git a/src/blop/tests/xopt/test_optimizer.py b/src/blop/tests/xopt/test_optimizer.py index f6997e22..1036a2fd 100644 --- a/src/blop/tests/xopt/test_optimizer.py +++ b/src/blop/tests/xopt/test_optimizer.py @@ -1,6 +1,7 @@ +from collections.abc import Callable + import numpy as np import pytest - from xopt.generators.bayesian import ExpectedImprovementGenerator from xopt.generators.random import RandomGenerator from xopt.vocs import VOCS @@ -8,21 +9,47 @@ from blop.xopt.optimizer import XoptOptimizer -def test_xopt_optimizer_init(): - vocs = VOCS( - variables={"x1": [-5.0, 5.0], "x2": [-5.0, 5.0], "x3": [0.0, 5.0]}, - objectives={"y1": "MAXIMIZE", "y2": "MINIMIZE"}, - constraints={"y1": ["GREATER_THAN", 0.0], "y2": ["LESS_THAN", 0.0]}, +def _random_optimizer(vocs: VOCS, *, checkpoint_path: str | None = None) -> XoptOptimizer: + return XoptOptimizer( + generator=RandomGenerator(vocs=vocs), + checkpoint_path=checkpoint_path, + ) + + +def _bo_optimizer(vocs: VOCS, *, checkpoint_path: str | None = None) -> XoptOptimizer: + return XoptOptimizer( + generator=ExpectedImprovementGenerator(vocs=vocs), + checkpoint_path=checkpoint_path, ) - optimizer = XoptOptimizer(generator=RandomGenerator, vocs=vocs) + +@pytest.fixture(params=[_random_optimizer, _bo_optimizer], ids=["random", "bo"]) +def optimizer_factory(request: pytest.FixtureRequest) -> Callable[[VOCS], XoptOptimizer]: + return request.param + + +def test_xopt_optimizer_init(optimizer_factory: Callable[[VOCS], XoptOptimizer]): + if optimizer_factory is _bo_optimizer: + vocs = VOCS( + variables={"x1": [-5.0, 5.0], "x2": [-5.0, 5.0], "x3": [0.0, 5.0]}, + objectives={"y1": "MINIMIZE"}, + constraints={"y1": ["LESS_THAN", 10.0]}, + ) + else: + vocs = VOCS( + variables={"x1": [-5.0, 5.0], "x2": [-5.0, 5.0], "x3": [0.0, 5.0]}, + objectives={"y1": "MAXIMIZE", "y2": "MINIMIZE"}, + constraints={"y1": ["GREATER_THAN", 0.0], "y2": ["LESS_THAN", 0.0]}, + ) + + optimizer = optimizer_factory(vocs) assert optimizer.generator is not None assert set(optimizer.vocs.variable_names) == {"x1", "x2", "x3"} -def test_xopt_fixed_parameters(): +def test_xopt_fixed_parameters(optimizer_factory: Callable[[VOCS], XoptOptimizer]): vocs = VOCS(variables={"x1": [-5.0, 5.0], "x2": [-5.0, 5.0], "x3": [0.0, 5.0]}, objectives={"y1": "MINIMIZE"}) - optimizer = XoptOptimizer(generator=RandomGenerator, vocs=vocs) + optimizer = optimizer_factory(vocs) with pytest.raises(KeyError): optimizer.fixed_parameters = {"x4": 3} @@ -33,7 +60,7 @@ def test_xopt_fixed_parameters(): def test_xopt_optimizer_suggest_ids_and_keys(): vocs = VOCS(variables={"x1": [-5.0, 5.0], "x2": [-5.0, 5.0], "x3": [0.0, 5.0]}, objectives={"y1": "MINIMIZE"}) - optimizer = XoptOptimizer(generator=RandomGenerator, vocs=vocs) + optimizer = _random_optimizer(vocs) suggestions = optimizer.suggest(num_points=2) assert len(suggestions) == 2 @@ -44,19 +71,33 @@ def test_xopt_optimizer_suggest_ids_and_keys(): assert "x3" in suggestion -def test_xopt_optimizer_ingest_multiple_columns(): - vocs = VOCS( - variables={"x1": [-5.0, 5.0], "x2": [-5.0, 5.0], "x3": [0.0, 5.0]}, - objectives={"y1": "MAXIMIZE", "y2": "MINIMIZE"}, - ) - optimizer = XoptOptimizer(generator=RandomGenerator, vocs=vocs) - - optimizer.ingest( - [ - {"x1": 0.0, "x2": 0.0, "x3": 0.0, "y1": 1.0, "y2": 2.0}, - {"x1": 0.1, "x2": 0.2, "x3": 1.0, "y1": 3.0, "y2": 4.0}, - ] - ) +def test_xopt_optimizer_ingest_multiple_columns(optimizer_factory: Callable[[VOCS], XoptOptimizer]): + if optimizer_factory is _bo_optimizer: + vocs = VOCS( + variables={"x1": [-5.0, 5.0], "x2": [-5.0, 5.0], "x3": [0.0, 5.0]}, + objectives={"y1": "MINIMIZE"}, + ) + else: + vocs = VOCS( + variables={"x1": [-5.0, 5.0], "x2": [-5.0, 5.0], "x3": [0.0, 5.0]}, + objectives={"y1": "MAXIMIZE", "y2": "MINIMIZE"}, + ) + optimizer = optimizer_factory(vocs) + + if optimizer_factory is _bo_optimizer: + optimizer.ingest( + [ + {"x1": 0.0, "x2": 0.0, "x3": 0.0, "y1": 1.0}, + {"x1": 0.1, "x2": 0.2, "x3": 1.0, "y1": 3.0}, + ] + ) + else: + optimizer.ingest( + [ + {"x1": 0.0, "x2": 0.0, "x3": 0.0, "y1": 1.0, "y2": 2.0}, + {"x1": 0.1, "x2": 0.2, "x3": 1.0, "y1": 3.0, "y2": 4.0}, + ] + ) data = optimizer.generator.data assert data is not None @@ -65,12 +106,15 @@ def test_xopt_optimizer_ingest_multiple_columns(): assert np.allclose(data["x2"].to_numpy(dtype=float), [0.0, 0.2]) assert np.allclose(data["x3"].to_numpy(dtype=float), [0.0, 1.0]) assert np.allclose(data["y1"].to_numpy(dtype=float), [1.0, 3.0]) - assert np.allclose(data["y2"].to_numpy(dtype=float), [2.0, 4.0]) + if optimizer_factory is _bo_optimizer: + assert "y2" not in data.columns + else: + assert np.allclose(data["y2"].to_numpy(dtype=float), [2.0, 4.0]) -def test_xopt_optimizer_ingest_baseline_id(): +def test_xopt_optimizer_ingest_baseline_id(optimizer_factory: Callable[[VOCS], XoptOptimizer]): vocs = VOCS(variables={"x1": [-5.0, 5.0]}, objectives={"y1": "MINIMIZE"}) - optimizer = XoptOptimizer(generator=RandomGenerator, vocs=vocs) + optimizer = optimizer_factory(vocs) optimizer.ingest([{"x1": 0.0, "y1": 1.0, "_id": "baseline"}]) data = optimizer.generator.data @@ -81,7 +125,7 @@ def test_xopt_optimizer_ingest_baseline_id(): def test_xopt_optimizer_suggest_ingest(): vocs = VOCS(variables={"x1": [-5.0, 5.0], "x2": [-5.0, 5.0]}, objectives={"y1": "MINIMIZE", "y2": "MINIMIZE"}) - optimizer = XoptOptimizer(generator=RandomGenerator, vocs=vocs) + optimizer = _random_optimizer(vocs) suggestions = optimizer.suggest(num_points=2) outcomes = [ @@ -99,7 +143,7 @@ def test_xopt_optimizer_suggest_ingest(): def test_xopt_optimizer_register_failures(): vocs = VOCS(variables={"x1": [-5.0, 5.0], "x2": [-5.0, 5.0]}, objectives={"y1": "MINIMIZE"}) - optimizer = XoptOptimizer(generator=RandomGenerator, vocs=vocs) + optimizer = _random_optimizer(vocs) suggestions = optimizer.suggest(num_points=5) optimizer.register_failures(suggestions) @@ -110,7 +154,7 @@ def test_xopt_optimizer_register_failures(): def test_xopt_optimizer_checkpoint_roundtrip(tmp_path): vocs = VOCS(variables={"x": [0.0, 1.0]}, objectives={"y": "MINIMIZE"}) checkpoint_path = tmp_path / "xopt_optimizer.pkl" - optimizer = XoptOptimizer(generator=RandomGenerator, vocs=vocs, checkpoint_path=str(checkpoint_path)) + optimizer = _random_optimizer(vocs, checkpoint_path=str(checkpoint_path)) suggestions = optimizer.suggest(1) optimizer.ingest([{"_id": suggestions[0]["_id"], "y": 0.5}]) @@ -124,7 +168,7 @@ def test_xopt_optimizer_checkpoint_roundtrip(tmp_path): def test_xopt_optimizer_checkpoint_no_path(): vocs = VOCS(variables={"x1": [-5.0, 5.0]}, objectives={"y1": "MINIMIZE"}) - optimizer = XoptOptimizer(generator=RandomGenerator, vocs=vocs) + optimizer = _random_optimizer(vocs) with pytest.raises(ValueError): optimizer.checkpoint() @@ -132,16 +176,16 @@ def test_xopt_optimizer_checkpoint_no_path(): def test_xopt_optimizer_applies_fixed_parameters(): vocs = VOCS(variables={"x": [0.0, 1.0], "z": [0.0, 2.0]}, objectives={"y": "MINIMIZE"}) - optimizer = XoptOptimizer(generator=RandomGenerator, vocs=vocs) + optimizer = _random_optimizer(vocs) optimizer.fixed_parameters = {"z": 1.25} suggestions = optimizer.suggest(3) assert all(suggestion["z"] == 1.25 for suggestion in suggestions) -def test_xopt_optimizer_get_best_points_single_objective_minimize(): +def test_xopt_optimizer_get_best_points_single_objective_minimize(optimizer_factory: Callable[[VOCS], XoptOptimizer]): vocs = VOCS(variables={"x": [0.0, 1.0]}, objectives={"y": "MINIMIZE"}) - optimizer = XoptOptimizer(generator=RandomGenerator, vocs=vocs) + optimizer = optimizer_factory(vocs) optimizer.ingest( [ @@ -158,9 +202,9 @@ def test_xopt_optimizer_get_best_points_single_objective_minimize(): assert outcomes["y"] == 1.0 -def test_xopt_optimizer_get_best_points_single_objective_maximize(): +def test_xopt_optimizer_get_best_points_single_objective_maximize(optimizer_factory: Callable[[VOCS], XoptOptimizer]): vocs = VOCS(variables={"x": [0.0, 1.0]}, objectives={"y": "MAXIMIZE"}) - optimizer = XoptOptimizer(generator=RandomGenerator, vocs=vocs) + optimizer = optimizer_factory(vocs) optimizer.ingest( [ @@ -179,7 +223,7 @@ def test_xopt_optimizer_get_best_points_single_objective_maximize(): def test_xopt_optimizer_get_best_points_multi_objective(): vocs = VOCS(variables={"x": [0.0, 10.0]}, objectives={"y1": "MAXIMIZE", "y2": "MAXIMIZE"}) - optimizer = XoptOptimizer(generator=RandomGenerator, vocs=vocs) + optimizer = _random_optimizer(vocs) optimizer.ingest( [ @@ -200,10 +244,7 @@ def test_xopt_optimizer_get_best_points_multi_objective(): def test_xopt_expected_improvement_runs_simple_minimization(): vocs = VOCS(variables={"x": [0.0, 1.0]}, objectives={"y": "MINIMIZE"}) - optimizer = XoptOptimizer( - generator=ExpectedImprovementGenerator, - vocs=vocs, - ) + optimizer = XoptOptimizer(generator=ExpectedImprovementGenerator(vocs=vocs)) # Seed EI with initial evaluations for model training. optimizer.ingest( diff --git a/src/blop/xopt/__init__.py b/src/blop/xopt/__init__.py index a9837b1f..959745c3 100644 --- a/src/blop/xopt/__init__.py +++ b/src/blop/xopt/__init__.py @@ -1,5 +1,3 @@ -from .agent import XoptAgent -from .mapping import build_vocs from .optimizer import XoptOptimizer -__all__ = ["XoptAgent", "XoptOptimizer", "build_vocs"] +__all__ = ["XoptOptimizer"] diff --git a/src/blop/xopt/agent.py b/src/blop/xopt/agent.py deleted file mode 100644 index 2dd0466a..00000000 --- a/src/blop/xopt/agent.py +++ /dev/null @@ -1,203 +0,0 @@ -import logging -from collections.abc import Mapping, Sequence -from typing import Any, cast - -import bluesky.preprocessors as bpp -from bluesky.callbacks import CallbackBase -from bluesky.utils import MsgGenerator - -from ..ax.dof import DOF, DOFConstraint -from ..ax.objective import Objective, OutcomeConstraint, ScalarizedObjective -from ..callbacks.logger import OptimizationLogger -from ..callbacks.router import OptimizationCallbackRouter -from ..plan_stubs import navigate_to_best -from ..plans import acquire_baseline, optimize, sample_suggestions -from ..protocols import AcquisitionPlan, Actuator, EvaluationFunction, OptimizationProblem, Sensor -from ..utils import InferredReadable -from .mapping import build_vocs -from .optimizer import XoptOptimizer - -logger = logging.getLogger(__name__) - - -class XoptAgent: - """Synchronous blop agent that wraps an arbitrary Xopt generator.""" - - def __init__( - self, - sensors: Sequence[Sensor], - dofs: Sequence[DOF], - objectives: Sequence[Objective] | ScalarizedObjective, - evaluation_function: EvaluationFunction, - *, - generator: Any, - generator_kwargs: dict[str, Any] | None = None, - acquisition_plan: AcquisitionPlan | None = None, - dof_constraints: Sequence[DOFConstraint] | None = None, - outcome_constraints: Sequence[OutcomeConstraint] | None = None, - checkpoint_path: str | None = None, - ): - # Keep behavior parity with Ax Agent: local agents expect real actuator objects, not names. - if any(isinstance(dof.actuator, str) for dof in dofs): - dof_actuator_strs = [dof.actuator for dof in dofs if isinstance(dof.actuator, str)] - raise ValueError( - f"DOFs with actuators must be `Actuator` instances, not strings. Got strings for: {dof_actuator_strs}" - ) - - # Build VOCS from blop objects and initialize runtime dependencies. - vocs = build_vocs( - dofs=dofs, - objectives=objectives, - sensors=sensors, - dof_constraints=dof_constraints, - outcome_constraints=outcome_constraints, - ) - - # Cache acquisition and optimizer state needed by optimize/sample plans. - self._sensors = sensors - self._actuators: Sequence[Actuator] = [cast(Actuator, dof.actuator) for dof in dofs if dof.actuator is not None] - self._evaluation_function = evaluation_function - self._acquisition_plan = acquisition_plan - self._optimizer = XoptOptimizer( - generator=generator, - vocs=vocs, - generator_kwargs=generator_kwargs, - checkpoint_path=checkpoint_path, - ) - self._readable_cache: dict[str, InferredReadable] = {} - self._callbacks: list[CallbackBase] = [OptimizationLogger()] - self._callback_router = OptimizationCallbackRouter(self._callbacks) - - @classmethod - def from_checkpoint( - cls, - checkpoint_path: str, - actuators: Sequence[Actuator], - sensors: Sequence[Sensor], - evaluation_function: EvaluationFunction, - acquisition_plan: AcquisitionPlan | None = None, - ) -> "XoptAgent": - # Rehydrate optimizer state while restoring runtime-only dependencies explicitly. - instance = object.__new__(cls) - instance._optimizer = XoptOptimizer.from_checkpoint(checkpoint_path) - instance._actuators = actuators - instance._sensors = sensors - instance._evaluation_function = evaluation_function - instance._acquisition_plan = acquisition_plan - instance._readable_cache = {} - instance._callbacks = [OptimizationLogger()] - instance._callback_router = OptimizationCallbackRouter(instance._callbacks) - return instance - - @property - def checkpoint_path(self) -> str | None: - return self._optimizer.checkpoint_path - - @property - def optimizer(self) -> XoptOptimizer: - """Return the underlying Xopt-backed optimizer adapter.""" - return self._optimizer - - @property - def fixed_dofs(self) -> dict[str, Any] | None: - return self._optimizer.fixed_parameters - - @fixed_dofs.setter - def fixed_dofs(self, fixed_dofs: dict[DOF, Any] | None) -> None: - # Convert DOF objects to parameter-name keyed fixed-parameter mapping. - if not fixed_dofs: - self._optimizer.fixed_parameters = None - return - - self._optimizer.fixed_parameters = {dof.parameter_name: value for dof, value in fixed_dofs.items()} - - def suggest(self, num_points: int = 1) -> list[dict]: - # Delegate candidate generation to the optimizer adapter. - return self._optimizer.suggest(num_points) - - def ingest(self, points: list[dict]) -> None: - # Delegate outcome ingestion and model-state updates to optimizer adapter. - self._optimizer.ingest(points) - - def get_best_points(self): - # Return optimizer-derived best point(s) for current objective configuration. - return self._optimizer.get_best_points() - - def checkpoint(self) -> None: - # Persist optimizer state to configured checkpoint artifact. - self._optimizer.checkpoint() - - @property - def callbacks(self) -> list[CallbackBase]: - return self._callbacks - - def subscribe(self, callback: CallbackBase) -> None: - # Register callback for optimize/sample run documents. - if callback in self._callbacks: - raise ValueError(f"Callback {callback!r} is already subscribed.") - self._callbacks.append(callback) - - def unsubscribe(self, callback: CallbackBase) -> None: - # Remove callback from active optimization subscriptions. - self._callbacks.remove(callback) - - @property - def sensors(self) -> Sequence[Sensor]: - return self._sensors - - @property - def actuators(self) -> Sequence[Actuator]: - return self._actuators - - @property - def evaluation_function(self) -> EvaluationFunction: - return self._evaluation_function - - @property - def acquisition_plan(self) -> AcquisitionPlan | None: - return self._acquisition_plan - - def to_optimization_problem(self) -> OptimizationProblem: - # Package runtime components into immutable protocol object used by plans. - return OptimizationProblem( - optimizer=self._optimizer, - actuators=self.actuators, - sensors=self.sensors, - evaluation_function=self.evaluation_function, - acquisition_plan=self.acquisition_plan, - ) - - def acquire_baseline(self, parameterization: dict[str, Any] | None = None) -> MsgGenerator[None]: - # Reuse standard baseline acquisition plan against this agent's optimization context. - yield from acquire_baseline(self.to_optimization_problem(), parameterization=parameterization) - - def optimize(self, iterations: int = 1, n_points: int = 1) -> MsgGenerator[None]: - # Build plan from shared optimize loop and attach callback routing when enabled. - optimize_plan = optimize( - self.to_optimization_problem(), iterations=iterations, n_points=n_points, readable_cache=self._readable_cache - ) - if self._callbacks: - optimize_plan = bpp.subs_wrapper(optimize_plan, self._callback_router) - - yield from optimize_plan - - def sample_suggestions(self, suggestions: list[dict]) -> MsgGenerator[tuple[str, list[dict], list[dict]]]: - # Evaluate caller-provided suggestions through shared sampling plan. - sample_suggestions_plan = sample_suggestions( - self.to_optimization_problem(), suggestions=suggestions, readable_cache=self._readable_cache - ) - if self._callbacks: - sample_suggestions_plan = bpp.subs_wrapper(sample_suggestions_plan, self._callback_router) - - return (yield from sample_suggestions_plan) - - def navigate_to_best(self, parameterization: Mapping | None = None) -> MsgGenerator[None]: - # Move actuators to an explicit or optimizer-derived best parameterization. - optimization_problem = self.to_optimization_problem() - return ( - yield from navigate_to_best( - optimization_problem.actuators, - optimization_problem.optimizer, - parameterization, - ) - ) diff --git a/src/blop/xopt/mapping.py b/src/blop/xopt/mapping.py deleted file mode 100644 index 908f6213..00000000 --- a/src/blop/xopt/mapping.py +++ /dev/null @@ -1,168 +0,0 @@ -import re -from collections.abc import Sequence -from typing import Any - -from xopt import VOCS - -from ..ax.dof import DOF, ChoiceDOF, DOFConstraint, RangeDOF -from ..ax.objective import Objective, OutcomeConstraint, ScalarizedObjective -from ..protocols import Sensor - -_INEQUALITY_RE = re.compile(r"^\s*(?P.+?)\s*(?P<=|>=|<|>)\s*(?P.+?)\s*$") -_SYMBOL_RE = re.compile(r"^[A-Za-z_]\w*$") - - -def _sensor_name(sensor: Sensor | str) -> str: - return sensor if isinstance(sensor, str) else sensor.name - - -def _parse_single_symbol_inequality(expression: str) -> tuple[str, str, float] | None: - # Accept expressions of the form "name <= value" or "value <= name". - match = _INEQUALITY_RE.match(expression) - if match is None: - return None - - left = match.group("left").strip() - op = match.group("op") - right = match.group("right").strip() - - if _SYMBOL_RE.match(left): - # Canonical case: metric/variable is on the left side. - try: - return left, op, float(right) - except ValueError: - return None - - if _SYMBOL_RE.match(right): - # Reversed case: variable is on the right side, so flip inequality direction. - try: - value = float(left) - except ValueError: - return None - - flip_op = {"<=": ">=", ">=": "<=", "<": ">", ">": "<"}[op] - return right, flip_op, value - - return None - - -def _apply_dof_constraints( - variables: dict[str, list[float] | list[float | int | str | bool]], dof_constraints: Sequence[DOFConstraint] -) -> None: - # Tighten the variable domains in-place using simple scalar inequality constraints. - for constraint in dof_constraints: - parsed = _parse_single_symbol_inequality(constraint.ax_constraint) - if parsed is None: - raise ValueError( - f"Xopt mapping currently supports only single-variable DOF constraints, got: {constraint.ax_constraint!r}." - ) - - name, op, value = parsed - if name not in variables: - raise ValueError(f"Unknown variable {name!r} in DOF constraint {constraint.ax_constraint!r}.") - - current = variables[name] - # Xopt currently receives range constraints only for numeric RangeDOF mappings. - if not (isinstance(current, list) and len(current) == 2 and all(isinstance(v, (int, float)) for v in current)): - raise ValueError( - f"DOF constraint {constraint.ax_constraint!r} targets non-range variable {name!r}; " - "only RangeDOF constraints are supported." - ) - - lower, upper = float(current[0]), float(current[1]) - # Intersect existing bounds with the new constraint. - if op in ("<=", "<"): - upper = min(upper, value) - else: - lower = max(lower, value) - - if lower > upper: - raise ValueError( - f"DOF constraint {constraint.ax_constraint!r} produces invalid bounds for {name!r}: [{lower}, {upper}]" - ) - - variables[name] = [lower, upper] - - -def _outcome_constraints_to_vocs_constraints(outcome_constraints: Sequence[OutcomeConstraint]) -> dict[str, list[Any]]: - # Convert blop constraint expressions into VOCS-style [operator, threshold] constraints. - constraints: dict[str, list[Any]] = {} - for constraint in outcome_constraints: - parsed = _parse_single_symbol_inequality(constraint.ax_constraint) - if parsed is None: - raise ValueError( - f"Xopt mapping currently supports only single-metric outcome constraints, got: {constraint.ax_constraint!r}." - ) - - metric_name, op, value = parsed - # Map inequality direction to Xopt constraint operator semantics. - if op in ("<=", "<"): - constraints[metric_name] = ["LESS_THAN", value] - else: - constraints[metric_name] = ["GREATER_THAN", value] - - return constraints - - -def _objectives_to_vocs_objectives(objectives: Sequence[Objective] | ScalarizedObjective) -> dict[str, str]: - # ScalarizedObjective is intentionally rejected until a stable translation is defined. - if isinstance(objectives, ScalarizedObjective): - raise ValueError( - "ScalarizedObjective cannot be auto-mapped to Xopt VOCS objectives. " - "Provide an explicit scalarized metric from your evaluation function and use Objective." - ) - - if not objectives: - raise ValueError("At least one objective is required to build VOCS.") - - return {objective.name: ("MINIMIZE" if objective.minimize else "MAXIMIZE") for objective in objectives} - - -def build_vocs( - *, - dofs: Sequence[DOF], - objectives: Sequence[Objective] | ScalarizedObjective, - sensors: Sequence[Sensor] | None = None, - dof_constraints: Sequence[DOFConstraint] | None = None, - outcome_constraints: Sequence[OutcomeConstraint] | None = None, -) -> VOCS: - """Build an Xopt VOCS object from blop domain objects.""" - # First build raw variable definitions from blop DOFs. - variables: dict[str, list[float] | list[float | int | str | bool]] = {} - - for dof in dofs: - if isinstance(dof, RangeDOF): - variables[dof.parameter_name] = [float(dof.bounds[0]), float(dof.bounds[1])] - elif isinstance(dof, ChoiceDOF): - # Keep scope explicit: only numeric choices map cleanly to current VOCS variable model. - if any(not isinstance(value, (int, float)) for value in dof.values): - raise ValueError( - "Xopt VOCS currently supports numeric variables only. " - f"ChoiceDOF {dof.parameter_name!r} has non-numeric values: {dof.values!r}" - ) - variables[dof.parameter_name] = [float(value) for value in dof.values] - else: - raise TypeError(f"Unsupported DOF type for Xopt mapping: {type(dof).__name__}") - - # Apply optional search-space constraints after variables are materialized. - if dof_constraints: - _apply_dof_constraints(variables, dof_constraints) - - # Translate objective and outcome-constraint metadata. - vocs_objectives = _objectives_to_vocs_objectives(objectives) - vocs_constraints = _outcome_constraints_to_vocs_constraints(outcome_constraints or []) - - # Use sensor names as observables except when already used as optimization outputs. - observables: list[str] = [] - if sensors: - reserved_names = set(vocs_objectives) | set(vocs_constraints) - observables = [name for name in (_sensor_name(sensor) for sensor in sensors) if name not in reserved_names] - - # Build a canonical VOCS object consumed by Xopt generators. - return VOCS( - variables=variables, - objectives=vocs_objectives, - constraints=vocs_constraints, - constants={}, - observables=observables, - ) diff --git a/src/blop/xopt/optimizer.py b/src/blop/xopt/optimizer.py index 1f374cee..59996442 100644 --- a/src/blop/xopt/optimizer.py +++ b/src/blop/xopt/optimizer.py @@ -56,26 +56,12 @@ class XoptOptimizer(Optimizer, Checkpointable, CanRegisterSuggestions, TrialFaul def __init__( self, - generator: Generator | type[Generator], + generator: Generator, *, - vocs: VOCS | None = None, - generator_kwargs: dict[str, Any] | None = None, checkpoint_path: str | None = None, ): - generator_kwargs = generator_kwargs or {} - - # Accept either an already-instantiated generator or a generator class. - if isinstance(generator, type): - if vocs is None and "vocs" not in generator_kwargs: - raise ValueError("vocs must be provided when initializing XoptOptimizer with a generator class.") - if "vocs" not in generator_kwargs: - self._generator = generator(vocs=vocs, **generator_kwargs) - else: - self._generator = generator(**generator_kwargs) - else: - self._generator = generator - if vocs is not None and self._generator.vocs != vocs: - raise ValueError("Provided vocs does not match generator.vocs.") + # Keep API simple: caller provides a fully configured Xopt generator instance. + self._generator = generator # Internal state tracks IDs, pending/known parameterizations, and checkpoint metadata. self._checkpoint_path = checkpoint_path From 623bc88703105d1e529a3c4f03b67fa5e25b3e90 Mon Sep 17 00:00:00 2001 From: Ryan Roussel Date: Fri, 5 Jun 2026 10:59:38 -0400 Subject: [PATCH 08/25] linting --- src/blop/xopt/optimizer.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/blop/xopt/optimizer.py b/src/blop/xopt/optimizer.py index 59996442..cfcaed0f 100644 --- a/src/blop/xopt/optimizer.py +++ b/src/blop/xopt/optimizer.py @@ -207,7 +207,11 @@ def _feasible_mask(self, data: pd.DataFrame) -> pd.Series: continue op, threshold = _constraint_to_pair(constraint) - mask &= data[constraint_name].astype(float).apply(lambda x: _constraint_satisfied(x, op, threshold)) + mask &= ( + data[constraint_name] + .astype(float) + .apply(lambda x, op=op, threshold=threshold: _constraint_satisfied(x, op, threshold)) + ) return mask From f4c284973b41e94a1fa4c646fbfa7cabcb888924 Mon Sep 17 00:00:00 2001 From: Ryan Roussel Date: Fri, 5 Jun 2026 11:15:43 -0400 Subject: [PATCH 09/25] solving pre-commit issues --- src/blop/xopt/optimizer.py | 47 +++++++++++++++++++++++--------------- 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/src/blop/xopt/optimizer.py b/src/blop/xopt/optimizer.py index cfcaed0f..2a26721c 100644 --- a/src/blop/xopt/optimizer.py +++ b/src/blop/xopt/optimizer.py @@ -41,8 +41,9 @@ def _constraint_to_pair(constraint: Any) -> tuple[str, float]: # Support typed constraint objects from gest_api.vocs by class-name convention. name = constraint.__class__.__name__.lower() - if hasattr(constraint, "value"): - value = float(constraint.value) + value_attr = getattr(constraint, "value", None) + if value_attr is not None and not callable(value_attr): + value = float(value_attr) if "lessthan" in name: return "LESS_THAN", value if "greaterthan" in name: @@ -51,6 +52,18 @@ def _constraint_to_pair(constraint: Any) -> tuple[str, float]: raise ValueError(f"Unsupported VOCS constraint representation: {constraint!r}") +def _normalize_trial_id(value: Any) -> int | str: + if isinstance(value, int): + return value + if isinstance(value, float) and value.is_integer(): + return int(value) + return str(value) + + +def _is_missing_scalar(value: Any) -> bool: + return value is None or (isinstance(value, float) and pd.isna(value)) + + class XoptOptimizer(Optimizer, Checkpointable, CanRegisterSuggestions, TrialFaultAware): """Adapter that exposes an arbitrary Xopt generator through blop's Optimizer protocol.""" @@ -118,20 +131,17 @@ def fixed_parameters(self, fixed_parameters: dict[str, Any] | None) -> None: def _seed_state_from_existing_data(self) -> None: # Recover known trial IDs/parameters from existing generator data when available. data = self._generator.data - if data is None or len(data) == 0: + if not isinstance(data, pd.DataFrame) or data.empty: return for _, row in data.iterrows(): # Reuse stored IDs when present, otherwise allocate synthetic IDs. - if ID_KEY in row and pd.notna(row[ID_KEY]): - trial_id = row[ID_KEY] + if ID_KEY in row and not _is_missing_scalar(row[ID_KEY]): + trial_id = _normalize_trial_id(row[ID_KEY]) else: trial_id = self._next_id self._next_id += 1 - if isinstance(trial_id, float) and trial_id.is_integer(): - trial_id = int(trial_id) - self._params_by_id[trial_id] = {name: row[name] for name in self.vocs.variable_names if name in row} if isinstance(trial_id, int): self._next_id = max(self._next_id, trial_id + 1) @@ -166,10 +176,12 @@ def ingest(self, points: list[dict]) -> None: for point in points: # Preserve provided IDs when available, else allocate a new one. - trial_id = point.get(ID_KEY) - if trial_id is None: - trial_id = self._next_id + raw_trial_id = point.get(ID_KEY) + if raw_trial_id is None: + trial_id: int | str = self._next_id self._next_id += 1 + else: + trial_id = _normalize_trial_id(raw_trial_id) # Merge known suggested parameters with any explicit parameters in incoming point. point_parameters = {name: point[name] for name in self.vocs.variable_names if name in point} @@ -196,11 +208,12 @@ def register_failures(self, suggestions: list[dict]) -> None: def _feasible_mask(self, data: pd.DataFrame) -> pd.Series: # Compute row-wise feasibility mask from VOCS constraints. - if not self.vocs.constraints: + constraints = self.vocs.constraints + if not isinstance(constraints, Mapping) or len(constraints) == 0: return pd.Series([True] * len(data), index=data.index) mask = pd.Series([True] * len(data), index=data.index) - for constraint_name, constraint in self.vocs.constraints.items(): + for constraint_name, constraint in constraints.items(): # Missing constraint columns imply non-feasible rows. if constraint_name not in data: mask &= False @@ -231,7 +244,7 @@ def _output_names(self) -> list[str]: def get_best_points(self) -> list[tuple[int | str, Mapping, Mapping]]: # Return no points when no data has been ingested. data = self._generator.data - if data is None or len(data) == 0: + if not isinstance(data, pd.DataFrame) or data.empty: return [] # Prefer feasible points first; if none exist, fall back to all data. @@ -245,7 +258,7 @@ def get_best_points(self) -> list[tuple[int | str, Mapping, Mapping]]: objective_name = objective_names[0] objective_spec = self.vocs.objectives[objective_name] minimize = _objective_minimize_flag(objective_spec) - objective_values = candidates[objective_name].astype(float) + objective_values = pd.Series(candidates[objective_name], index=candidates.index, dtype=float) best_index = objective_values.idxmin() if minimize else objective_values.idxmax() selected = candidates.loc[[best_index]] else: @@ -256,9 +269,7 @@ def get_best_points(self) -> list[tuple[int | str, Mapping, Mapping]]: results: list[tuple[int | str, Mapping, Mapping]] = [] for _, row in selected.iterrows(): # Normalize IDs and split into parameter and outcome mappings. - trial_id = row[ID_KEY] if ID_KEY in row else _ - if isinstance(trial_id, float) and trial_id.is_integer(): - trial_id = int(trial_id) + trial_id = _normalize_trial_id(row[ID_KEY] if ID_KEY in row else _) parameterization = {name: row[name] for name in self.vocs.variable_names if name in row} outcomes = {name: row[name] for name in output_names if name in row} From 209b8b14ad575ed123b0fbf7cd0b1eae47e5b267 Mon Sep 17 00:00:00 2001 From: Ryan Roussel Date: Fri, 5 Jun 2026 11:35:07 -0400 Subject: [PATCH 10/25] change code to native xopt vocs methods --- src/blop/xopt/optimizer.py | 87 ++++++-------------------------------- 1 file changed, 14 insertions(+), 73 deletions(-) diff --git a/src/blop/xopt/optimizer.py b/src/blop/xopt/optimizer.py index 2a26721c..835af2f7 100644 --- a/src/blop/xopt/optimizer.py +++ b/src/blop/xopt/optimizer.py @@ -6,52 +6,11 @@ import pandas as pd from xopt import VOCS from xopt.generator import Generator +from xopt.vocs import get_feasibility_data, select_best from ..protocols import ID_KEY, CanRegisterSuggestions, Checkpointable, Optimizer, TrialFaultAware -def _objective_minimize_flag(objective: Any) -> bool: - # Handle string objective specs first (common VOCS representation). - if isinstance(objective, str): - return objective.strip().upper() == "MINIMIZE" - - # Fall back to class-name inspection for typed objective objects. - objective_name = objective.__class__.__name__.lower() - if "minimize" in objective_name: - return True - if "maximize" in objective_name: - return False - - return True - - -def _constraint_satisfied(value: float, op: str, threshold: float) -> bool: - # Evaluate one normalized constraint against a single numeric value. - if op == "LESS_THAN": - return value <= threshold - if op == "GREATER_THAN": - return value >= threshold - raise ValueError(f"Unsupported VOCS constraint operator: {op!r}") - - -def _constraint_to_pair(constraint: Any) -> tuple[str, float]: - # Convert common VOCS list/tuple form into a normalized operator/value pair. - if isinstance(constraint, (list, tuple)) and len(constraint) == 2: - return str(constraint[0]).upper(), float(constraint[1]) - - # Support typed constraint objects from gest_api.vocs by class-name convention. - name = constraint.__class__.__name__.lower() - value_attr = getattr(constraint, "value", None) - if value_attr is not None and not callable(value_attr): - value = float(value_attr) - if "lessthan" in name: - return "LESS_THAN", value - if "greaterthan" in name: - return "GREATER_THAN", value - - raise ValueError(f"Unsupported VOCS constraint representation: {constraint!r}") - - def _normalize_trial_id(value: Any) -> int | str: if isinstance(value, int): return value @@ -207,36 +166,21 @@ def register_failures(self, suggestions: list[dict]) -> None: self._params_by_id.pop(trial_id) def _feasible_mask(self, data: pd.DataFrame) -> pd.Series: - # Compute row-wise feasibility mask from VOCS constraints. + # Delegate feasibility computation to Xopt's native VOCS helper. constraints = self.vocs.constraints if not isinstance(constraints, Mapping) or len(constraints) == 0: return pd.Series([True] * len(data), index=data.index) - mask = pd.Series([True] * len(data), index=data.index) - for constraint_name, constraint in constraints.items(): - # Missing constraint columns imply non-feasible rows. - if constraint_name not in data: - mask &= False - continue - - op, threshold = _constraint_to_pair(constraint) - mask &= ( - data[constraint_name] - .astype(float) - .apply(lambda x, op=op, threshold=threshold: _constraint_satisfied(x, op, threshold)) - ) - - return mask - - def _objective_names(self) -> list[str]: - # Preserve VOCS-defined objective ordering where available. - return list(self.vocs.objectives.keys()) if self.vocs.objectives else [] + feasibility = get_feasibility_data(self.vocs, data) + if "feasible" not in feasibility: + return pd.Series([True] * len(data), index=data.index) + return pd.Series(feasibility["feasible"], index=data.index, dtype=bool) def _output_names(self) -> list[str]: # Outputs include objectives, constraints, and optional observables. - names = self._objective_names() + names = list(self.vocs.objective_names) if self.vocs.constraints: - names.extend(self.vocs.constraints.keys()) + names.extend(self.vocs.constraint_names) if getattr(self.vocs, "observables", None): names.extend(self.vocs.observables) return names @@ -247,19 +191,16 @@ def get_best_points(self) -> list[tuple[int | str, Mapping, Mapping]]: if not isinstance(data, pd.DataFrame) or data.empty: return [] - # Prefer feasible points first; if none exist, fall back to all data. - candidates = data[self._feasible_mask(data)] + # Best points are only defined over feasible observations. + candidates: pd.DataFrame = data.loc[self._feasible_mask(data)] if len(candidates) == 0: - candidates = data + return [] - objective_names = self._objective_names() + objective_names = list(self.vocs.objective_names) # For single-objective problems, return the single extremum according to direction. if len(objective_names) == 1 and objective_names[0] in candidates: - objective_name = objective_names[0] - objective_spec = self.vocs.objectives[objective_name] - minimize = _objective_minimize_flag(objective_spec) - objective_values = pd.Series(candidates[objective_name], index=candidates.index, dtype=float) - best_index = objective_values.idxmin() if minimize else objective_values.idxmax() + best_indices, _, _ = select_best(self.vocs, candidates, n=1) + best_index = best_indices[0] selected = candidates.loc[[best_index]] else: # For multi-objective and objective-less modes, return the available candidate set. From c4348a85b4ee9b388961476d66333d700ec07850 Mon Sep 17 00:00:00 2001 From: Ryan Roussel Date: Fri, 5 Jun 2026 14:11:47 -0400 Subject: [PATCH 11/25] remove python 3.10 --- .github/workflows/_testing.yml | 2 +- pixi.toml | 6 +----- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/.github/workflows/_testing.yml b/.github/workflows/_testing.yml index 0be313f0..21d69eed 100644 --- a/.github/workflows/_testing.yml +++ b/.github/workflows/_testing.yml @@ -10,7 +10,7 @@ jobs: strategy: matrix: host-os: ["ubuntu-latest"] - python-version: ["py310-cpu", "py311-cpu", "py312-cpu", "py313-cpu"] + python-version: ["py311-cpu", "py312-cpu", "py313-cpu"] fail-fast: false defaults: diff --git a/pixi.toml b/pixi.toml index 67129eab..d49a3a6c 100644 --- a/pixi.toml +++ b/pixi.toml @@ -9,7 +9,7 @@ platforms = ["linux-64", "osx-arm64"] version = "0.9.0" [dependencies] -python = ">=3.10.0,<3.14" +python = ">=3.11.0,<3.14" [feature.dev.dependencies] @@ -46,9 +46,6 @@ bluesky-tiled-plugins = "*" ophyd-async = "*" opencv-python = "*" -[feature.py310.dependencies] -python = "3.10.*" - [feature.py311.dependencies] python = "3.11.*" @@ -78,7 +75,6 @@ convert-tutorials-to-ipynb = "jupytext --to notebook docs/source/tutorials/*.md" dev = ["dev"] dev-cpu = ["dev-cpu"] docs = ["docs"] -py310-cpu = ["dev-cpu", "py310"] py311-cpu = ["dev-cpu", "py311"] py312-cpu = ["dev-cpu", "py312"] py313-cpu = ["dev-cpu", "py313"] diff --git a/pyproject.toml b/pyproject.toml index b90a2330..db2b6df5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ maintainers = [ { name = "Jennefer Maldonado", email = "jmaldonad@bnl.gov" }, { name = "Roman Chernikov", email = "rcherniko@bnl.gov" }, ] -requires-python = ">=3.10" +requires-python = ">=3.11" dependencies = [ "ax-platform>=1.1.0,<1.3", "xopt", From 75b0863156a89647d56556ffd536fe450e73b50b Mon Sep 17 00:00:00 2001 From: Ryan Roussel Date: Fri, 5 Jun 2026 14:23:42 -0400 Subject: [PATCH 12/25] linting --- src/blop/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/blop/utils.py b/src/blop/utils.py index ecf8c2bb..d696f70f 100644 --- a/src/blop/utils.py +++ b/src/blop/utils.py @@ -1,6 +1,6 @@ import time from collections.abc import Sequence -from enum import Enum +from enum import StrEnum from typing import Any import networkx as nx @@ -12,7 +12,7 @@ from .protocols import ID_KEY, OptimizationProblem -class Source(str, Enum): +class Source(StrEnum): """An enum that helps describe where the data key comes from.""" OUTCOME = "optimization-outcome" From 4188b57e5269784c79eb839b86298f51204b54d5 Mon Sep 17 00:00:00 2001 From: Ryan Roussel Date: Fri, 5 Jun 2026 14:50:44 -0400 Subject: [PATCH 13/25] Update test_optimizer.py --- src/blop/tests/xopt/test_optimizer.py | 44 +++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/blop/tests/xopt/test_optimizer.py b/src/blop/tests/xopt/test_optimizer.py index 1036a2fd..19772ad0 100644 --- a/src/blop/tests/xopt/test_optimizer.py +++ b/src/blop/tests/xopt/test_optimizer.py @@ -242,6 +242,50 @@ def test_xopt_optimizer_get_best_points_multi_objective(): assert "y2" in metrics +def test_xopt_optimizer_get_best_points_returns_empty_when_all_infeasible(): + vocs = VOCS( + variables={"x": [0.0, 1.0]}, + objectives={"y": "MINIMIZE"}, + constraints={"c": ["LESS_THAN", 0.0]}, + ) + optimizer = _random_optimizer(vocs) + + optimizer.ingest( + [ + {"x": 0.1, "y": 5.0, "c": 1.0}, + {"x": 0.2, "y": 1.0, "c": 2.0}, + {"x": 0.3, "y": 3.0, "c": 3.0}, + ] + ) + + best_points = optimizer.get_best_points() + assert best_points == [] + + +def test_xopt_optimizer_get_best_points_selects_best_feasible_only(): + vocs = VOCS( + variables={"x": [0.0, 1.0]}, + objectives={"y": "MINIMIZE"}, + constraints={"c": ["LESS_THAN", 0.5]}, + ) + optimizer = _random_optimizer(vocs) + + optimizer.ingest( + [ + {"x": 0.1, "y": 10.0, "c": 0.1}, + {"x": 0.2, "y": 1.0, "c": 0.9}, + {"x": 0.3, "y": 2.0, "c": 0.2}, + ] + ) + + best_points = optimizer.get_best_points() + assert len(best_points) == 1 + _, params, outcomes = best_points[0] + assert params["x"] == 0.3 + assert outcomes["y"] == 2.0 + assert outcomes["c"] == 0.2 + + def test_xopt_expected_improvement_runs_simple_minimization(): vocs = VOCS(variables={"x": [0.0, 1.0]}, objectives={"y": "MINIMIZE"}) optimizer = XoptOptimizer(generator=ExpectedImprovementGenerator(vocs=vocs)) From 884543f84eb9529b6b2c4aa0c880278f2b95e69e Mon Sep 17 00:00:00 2001 From: Ryan Roussel Date: Mon, 8 Jun 2026 10:46:48 -0500 Subject: [PATCH 14/25] add tests for coverage --- src/blop/tests/xopt/test_optimizer.py | 69 +++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/src/blop/tests/xopt/test_optimizer.py b/src/blop/tests/xopt/test_optimizer.py index 19772ad0..e8ef4f81 100644 --- a/src/blop/tests/xopt/test_optimizer.py +++ b/src/blop/tests/xopt/test_optimizer.py @@ -1,11 +1,13 @@ from collections.abc import Callable import numpy as np +import pandas as pd import pytest from xopt.generators.bayesian import ExpectedImprovementGenerator from xopt.generators.random import RandomGenerator from xopt.vocs import VOCS +from blop.xopt import optimizer as xopt_optimizer_module from blop.xopt.optimizer import XoptOptimizer @@ -57,6 +59,9 @@ def test_xopt_fixed_parameters(optimizer_factory: Callable[[VOCS], XoptOptimizer optimizer.fixed_parameters = {"x3": 3} assert optimizer.fixed_parameters == {"x3": 3} + optimizer.fixed_parameters = {} + assert optimizer.fixed_parameters is None + def test_xopt_optimizer_suggest_ids_and_keys(): vocs = VOCS(variables={"x1": [-5.0, 5.0], "x2": [-5.0, 5.0], "x3": [0.0, 5.0]}, objectives={"y1": "MINIMIZE"}) @@ -71,6 +76,15 @@ def test_xopt_optimizer_suggest_ids_and_keys(): assert "x3" in suggestion +def test_xopt_optimizer_suggest_defaults_to_single_point(): + vocs = VOCS(variables={"x": [0.0, 1.0]}, objectives={"y": "MINIMIZE"}) + optimizer = _random_optimizer(vocs) + + suggestions = optimizer.suggest() + assert len(suggestions) == 1 + assert suggestions[0]["_id"] == 0 + + def test_xopt_optimizer_ingest_multiple_columns(optimizer_factory: Callable[[VOCS], XoptOptimizer]): if optimizer_factory is _bo_optimizer: vocs = VOCS( @@ -123,6 +137,17 @@ def test_xopt_optimizer_ingest_baseline_id(optimizer_factory: Callable[[VOCS], X assert data.iloc[0]["_id"] == "baseline" +def test_xopt_optimizer_seeds_state_when_existing_data_lacks_id(): + vocs = VOCS(variables={"x": [0.0, 1.0]}, objectives={"y": "MINIMIZE"}) + generator = RandomGenerator(vocs=vocs) + generator.add_data(pd.DataFrame([{"x": 0.4, "y": 1.2}])) + + optimizer = XoptOptimizer(generator=generator) + + assert optimizer._params_by_id[0] == {"x": 0.4} + assert optimizer._next_id == 1 + + def test_xopt_optimizer_suggest_ingest(): vocs = VOCS(variables={"x1": [-5.0, 5.0], "x2": [-5.0, 5.0]}, objectives={"y1": "MINIMIZE", "y2": "MINIMIZE"}) optimizer = _random_optimizer(vocs) @@ -286,6 +311,50 @@ def test_xopt_optimizer_get_best_points_selects_best_feasible_only(): assert outcomes["c"] == 0.2 +def test_xopt_optimizer_get_best_points_empty_without_data(): + vocs = VOCS(variables={"x": [0.0, 1.0]}, objectives={"y": "MINIMIZE"}) + optimizer = _random_optimizer(vocs) + + assert optimizer.get_best_points() == [] + + +def test_xopt_optimizer_feasible_mask_defaults_true_when_missing_feasible(monkeypatch: pytest.MonkeyPatch): + vocs = VOCS( + variables={"x": [0.0, 1.0]}, + objectives={"y": "MINIMIZE"}, + constraints={"c": ["LESS_THAN", 1.0]}, + ) + optimizer = _random_optimizer(vocs) + data = pd.DataFrame([{"x": 0.1, "y": 1.0, "c": 0.1}, {"x": 0.2, "y": 2.0, "c": 0.2}]) + + monkeypatch.setattr(xopt_optimizer_module, "get_feasibility_data", lambda _vocs, _data: {"c": [True, False]}) + + mask = optimizer._feasible_mask(data) + assert mask.tolist() == [True, True] + + +def test_xopt_optimizer_get_best_points_includes_observables(): + vocs = VOCS( + variables={"x": [0.0, 1.0]}, + objectives={"y": "MINIMIZE"}, + observables=["obs"], + ) + optimizer = _random_optimizer(vocs) + + optimizer.ingest( + [ + {"x": 0.1, "y": 2.0, "obs": 10.0}, + {"x": 0.2, "y": 1.0, "obs": 20.0}, + ] + ) + + best_points = optimizer.get_best_points() + assert len(best_points) == 1 + _, _, outcomes = best_points[0] + assert outcomes["y"] == 1.0 + assert outcomes["obs"] == 20.0 + + def test_xopt_expected_improvement_runs_simple_minimization(): vocs = VOCS(variables={"x": [0.0, 1.0]}, objectives={"y": "MINIMIZE"}) optimizer = XoptOptimizer(generator=ExpectedImprovementGenerator(vocs=vocs)) From d7cd2e9aed32e35a7cb446e0fe9df1044a4758ef Mon Sep 17 00:00:00 2001 From: Ryan Roussel Date: Tue, 16 Jun 2026 13:14:11 -0500 Subject: [PATCH 15/25] Update pyproject.toml --- pyproject.toml | 20 ++++---------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c3fce922..b5620484 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,20 +21,7 @@ maintainers = [ { name = "Roman Chernikov", email = "rcherniko@bnl.gov" }, ] requires-python = ">=3.11" -dependencies = [ - "ax-platform>=1.1.0,<1.3", - "xopt", - "bluesky>=1.14.2", - "bluesky-queueserver-api>=0.0.12", - "torch", - "botorch>=0.16.0", - "gpytorch", - "scipy", - "networkx>=3", - "numpy", - "rich>=13", -] - +dependencies = ["bluesky>=1.14.2", "networkx>=3", "numpy", "rich>=13"] classifiers = [ "Development Status :: 4 - Beta", "License :: OSI Approved :: BSD License", @@ -49,7 +36,8 @@ dynamic = ["version"] [project.optional-dependencies] ax = ["ax-platform>=1.3.0,<1.4", "botorch>=0.18.0,<0.19.0", "gpytorch", "torch"] queueserver = ["bluesky-queueserver-api"] -all = ["blop[ax,queueserver]"] +xopt = ["xopt"] +all = ["blop[ax,queueserver,xopt]"] dev = [ "pytest", "pytest-cov", @@ -134,4 +122,4 @@ torch = [{ index = "pytorch-cpu", marker = "extra == 'cpu'" }] [[tool.uv.index]] name = "pytorch-cpu" url = "https://download.pytorch.org/whl/cpu" -explicit = true # Only use this index for torch-related packages +explicit = true # Only use this index for torch-related packages \ No newline at end of file From 6ccf0699bbeba8d1ae3730ef893550aa4eab5f40 Mon Sep 17 00:00:00 2001 From: Ryan Roussel Date: Tue, 16 Jun 2026 13:16:56 -0500 Subject: [PATCH 16/25] linting --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b5620484..0972acd4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,7 @@ dynamic = ["version"] [project.optional-dependencies] ax = ["ax-platform>=1.3.0,<1.4", "botorch>=0.18.0,<0.19.0", "gpytorch", "torch"] queueserver = ["bluesky-queueserver-api"] -xopt = ["xopt"] +xopt = ["xopt>3.0.0"] all = ["blop[ax,queueserver,xopt]"] dev = [ "pytest", @@ -122,4 +122,4 @@ torch = [{ index = "pytorch-cpu", marker = "extra == 'cpu'" }] [[tool.uv.index]] name = "pytorch-cpu" url = "https://download.pytorch.org/whl/cpu" -explicit = true # Only use this index for torch-related packages \ No newline at end of file +explicit = true # Only use this index for torch-related packages From 461dd969328b3cb2329c83be1b6af09e6ec49f99 Mon Sep 17 00:00:00 2001 From: Ryan Roussel Date: Thu, 2 Jul 2026 09:58:12 -0500 Subject: [PATCH 17/25] create RunEngine test and random initial point generation --- src/blop/tests/xopt/test_optimizer.py | 61 +++++++++++++++++++++++++++ src/blop/xopt/optimizer.py | 14 ++++-- 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/src/blop/tests/xopt/test_optimizer.py b/src/blop/tests/xopt/test_optimizer.py index e8ef4f81..bbc892c4 100644 --- a/src/blop/tests/xopt/test_optimizer.py +++ b/src/blop/tests/xopt/test_optimizer.py @@ -3,10 +3,14 @@ import numpy as np import pandas as pd import pytest +from bluesky.run_engine import RunEngine from xopt.generators.bayesian import ExpectedImprovementGenerator from xopt.generators.random import RandomGenerator from xopt.vocs import VOCS +from blop.plans import optimize +from blop.protocols import OptimizationProblem +from blop.tests.conftest import MovableSignal, ReadableSignal from blop.xopt import optimizer as xopt_optimizer_module from blop.xopt.optimizer import XoptOptimizer @@ -85,6 +89,22 @@ def test_xopt_optimizer_suggest_defaults_to_single_point(): assert suggestions[0]["_id"] == 0 +def test_xopt_optimizer_first_suggest_uses_vocs_random_inputs(monkeypatch: pytest.MonkeyPatch): + vocs = VOCS(variables={"x": [0.0, 1.0]}, objectives={"y": "MINIMIZE"}) + optimizer = _bo_optimizer(vocs) + + def _random_inputs(_vocs: VOCS, n: int | None = None, **_kwargs) -> list[dict]: + assert set(_vocs.variable_names) == {"x"} + assert n == 1 + return [{"x": 0.42}] + + monkeypatch.setattr(xopt_optimizer_module, "random_inputs", _random_inputs) + + suggestions = optimizer.suggest() + + assert suggestions == [{"_id": 0, "x": 0.42}] + + def test_xopt_optimizer_ingest_multiple_columns(optimizer_factory: Callable[[VOCS], XoptOptimizer]): if optimizer_factory is _bo_optimizer: vocs = VOCS( @@ -380,3 +400,44 @@ def test_xopt_expected_improvement_runs_simple_minimization(): assert len(best_points) == 1 _, _, outcomes = best_points[0] assert outcomes["y"] <= 0.0625 + + +def test_xopt_inside_run_engine(): + vocs = VOCS(variables={"x": [0.0, 1.0]}, objectives={"y": "MINIMIZE"}) + optimizer = XoptOptimizer(generator=ExpectedImprovementGenerator(vocs=vocs)) + + actuator = MovableSignal("x", initial_value=0.5) + sensor = ReadableSignal("y") + + def evaluation_function(_uid: str, suggestions: list[dict]) -> list[dict]: + return [ + { + "_id": suggestion["_id"], + "y": (float(suggestion["x"]) - 0.25) ** 2, + } + for suggestion in suggestions + ] + + optimization_problem = OptimizationProblem( + optimizer=optimizer, + actuators=[actuator], + sensors=[sensor], + evaluation_function=evaluation_function, + ) + + RE = RunEngine({}) + RE(optimize(optimization_problem, iterations=4, n_points=1)) + + data = optimizer.generator.data + assert data is not None + assert len(data) == 4 + assert "x" in data.columns + assert "y" in data.columns + + best_points = optimizer.get_best_points() + assert len(best_points) == 1 + _, params, outcomes = best_points[0] + assert 0.0 <= float(params["x"]) <= 1.0 + assert float(outcomes["y"]) >= 0.0 + + diff --git a/src/blop/xopt/optimizer.py b/src/blop/xopt/optimizer.py index 835af2f7..c6c7dd6a 100644 --- a/src/blop/xopt/optimizer.py +++ b/src/blop/xopt/optimizer.py @@ -6,7 +6,7 @@ import pandas as pd from xopt import VOCS from xopt.generator import Generator -from xopt.vocs import get_feasibility_data, select_best +from xopt.vocs import get_feasibility_data, random_inputs, select_best from ..protocols import ID_KEY, CanRegisterSuggestions, Checkpointable, Optimizer, TrialFaultAware @@ -110,8 +110,16 @@ def suggest(self, num_points: int | None = None) -> list[dict]: if num_points is None: num_points = 1 - # Delegate candidate generation to Xopt and optionally enforce fixed variables. - suggestions = self._generator.generate(num_points) + # Bootstrap first call with random VOCS inputs to avoid model-based generators requiring prior data. + data = self._generator.data + first_suggest_call = self._next_id == 0 and len(self._params_by_id) == 0 + has_data = isinstance(data, pd.DataFrame) and not data.empty + + if first_suggest_call and not has_data: + suggestions = random_inputs(self.vocs, n=num_points) + else: + suggestions = self._generator.generate(num_points) + if self._fixed_parameters: suggestions = [{**suggestion, **self._fixed_parameters} for suggestion in suggestions] return self.register_suggestions(suggestions) From d706e19691def1f79cdd768edf9da3e38191566a Mon Sep 17 00:00:00 2001 From: Ryan Roussel Date: Thu, 2 Jul 2026 10:02:11 -0500 Subject: [PATCH 18/25] remove fixed parameters in favor of VOCS constants --- src/blop/tests/xopt/test_optimizer.py | 23 ----------------------- src/blop/xopt/optimizer.py | 27 ++------------------------- 2 files changed, 2 insertions(+), 48 deletions(-) diff --git a/src/blop/tests/xopt/test_optimizer.py b/src/blop/tests/xopt/test_optimizer.py index bbc892c4..909cb748 100644 --- a/src/blop/tests/xopt/test_optimizer.py +++ b/src/blop/tests/xopt/test_optimizer.py @@ -53,20 +53,6 @@ def test_xopt_optimizer_init(optimizer_factory: Callable[[VOCS], XoptOptimizer]) assert set(optimizer.vocs.variable_names) == {"x1", "x2", "x3"} -def test_xopt_fixed_parameters(optimizer_factory: Callable[[VOCS], XoptOptimizer]): - vocs = VOCS(variables={"x1": [-5.0, 5.0], "x2": [-5.0, 5.0], "x3": [0.0, 5.0]}, objectives={"y1": "MINIMIZE"}) - optimizer = optimizer_factory(vocs) - - with pytest.raises(KeyError): - optimizer.fixed_parameters = {"x4": 3} - - optimizer.fixed_parameters = {"x3": 3} - assert optimizer.fixed_parameters == {"x3": 3} - - optimizer.fixed_parameters = {} - assert optimizer.fixed_parameters is None - - def test_xopt_optimizer_suggest_ids_and_keys(): vocs = VOCS(variables={"x1": [-5.0, 5.0], "x2": [-5.0, 5.0], "x3": [0.0, 5.0]}, objectives={"y1": "MINIMIZE"}) optimizer = _random_optimizer(vocs) @@ -219,15 +205,6 @@ def test_xopt_optimizer_checkpoint_no_path(): optimizer.checkpoint() -def test_xopt_optimizer_applies_fixed_parameters(): - vocs = VOCS(variables={"x": [0.0, 1.0], "z": [0.0, 2.0]}, objectives={"y": "MINIMIZE"}) - optimizer = _random_optimizer(vocs) - optimizer.fixed_parameters = {"z": 1.25} - - suggestions = optimizer.suggest(3) - assert all(suggestion["z"] == 1.25 for suggestion in suggestions) - - def test_xopt_optimizer_get_best_points_single_objective_minimize(optimizer_factory: Callable[[VOCS], XoptOptimizer]): vocs = VOCS(variables={"x": [0.0, 1.0]}, objectives={"y": "MINIMIZE"}) optimizer = optimizer_factory(vocs) diff --git a/src/blop/xopt/optimizer.py b/src/blop/xopt/optimizer.py index c6c7dd6a..c41a3876 100644 --- a/src/blop/xopt/optimizer.py +++ b/src/blop/xopt/optimizer.py @@ -37,7 +37,6 @@ def __init__( # Internal state tracks IDs, pending/known parameterizations, and checkpoint metadata. self._checkpoint_path = checkpoint_path - self._fixed_parameters: dict[str, Any] | None = None self._next_id = 0 self._params_by_id: dict[int | str, dict[str, Any]] = {} self._seed_state_from_existing_data() @@ -52,7 +51,6 @@ def from_checkpoint(cls, checkpoint_path: str) -> "XoptOptimizer": instance = object.__new__(cls) instance._generator = payload["generator"] instance._checkpoint_path = str(path) - instance._fixed_parameters = payload.get("fixed_parameters") instance._next_id = payload.get("next_id", 0) instance._params_by_id = payload.get("params_by_id", {}) instance._seed_state_from_existing_data() @@ -71,22 +69,6 @@ def generator(self) -> Generator: def vocs(self) -> VOCS: return self._generator.vocs - @property - def fixed_parameters(self) -> dict[str, Any] | None: - return self._fixed_parameters - - @fixed_parameters.setter - def fixed_parameters(self, fixed_parameters: dict[str, Any] | None) -> None: - if not fixed_parameters: - self._fixed_parameters = None - return - - unknown_names = set(fixed_parameters) - set(self.vocs.variable_names) - if unknown_names: - raise KeyError(f"Unknown fixed parameter(s): {sorted(unknown_names)}") - - self._fixed_parameters = dict(fixed_parameters) - def _seed_state_from_existing_data(self) -> None: # Recover known trial IDs/parameters from existing generator data when available. data = self._generator.data @@ -118,10 +100,7 @@ def suggest(self, num_points: int | None = None) -> list[dict]: if first_suggest_call and not has_data: suggestions = random_inputs(self.vocs, n=num_points) else: - suggestions = self._generator.generate(num_points) - - if self._fixed_parameters: - suggestions = [{**suggestion, **self._fixed_parameters} for suggestion in suggestions] + suggestions = self._generator.suggest(num_points) return self.register_suggestions(suggestions) def register_suggestions(self, suggestions: list[dict]) -> list[dict]: @@ -163,8 +142,7 @@ def ingest(self, points: list[dict]) -> None: rows.append({ID_KEY: trial_id, **parameters, **outcomes}) # Persist all new observations into the underlying generator state. - new_data = pd.DataFrame(rows) - self._generator.add_data(new_data) + self._generator.ingest(rows) def register_failures(self, suggestions: list[dict]) -> None: # Remove failed suggestions from pending parameter cache. @@ -234,7 +212,6 @@ def checkpoint(self) -> None: # Persist generator and adapter bookkeeping to a single pickle artifact. payload = { "generator": self._generator, - "fixed_parameters": self._fixed_parameters, "next_id": self._next_id, "params_by_id": self._params_by_id, } From 9b92a9101663badc7a187bdf0b29910daa44f743 Mon Sep 17 00:00:00 2001 From: Ryan Roussel Date: Thu, 2 Jul 2026 10:34:29 -0500 Subject: [PATCH 19/25] utilize pydantic serialization, fix ingest append/insert issues --- src/blop/tests/xopt/test_optimizer.py | 15 --- src/blop/xopt/optimizer.py | 127 ++++++++++++++++---------- 2 files changed, 80 insertions(+), 62 deletions(-) diff --git a/src/blop/tests/xopt/test_optimizer.py b/src/blop/tests/xopt/test_optimizer.py index 909cb748..0130ef4a 100644 --- a/src/blop/tests/xopt/test_optimizer.py +++ b/src/blop/tests/xopt/test_optimizer.py @@ -315,21 +315,6 @@ def test_xopt_optimizer_get_best_points_empty_without_data(): assert optimizer.get_best_points() == [] -def test_xopt_optimizer_feasible_mask_defaults_true_when_missing_feasible(monkeypatch: pytest.MonkeyPatch): - vocs = VOCS( - variables={"x": [0.0, 1.0]}, - objectives={"y": "MINIMIZE"}, - constraints={"c": ["LESS_THAN", 1.0]}, - ) - optimizer = _random_optimizer(vocs) - data = pd.DataFrame([{"x": 0.1, "y": 1.0, "c": 0.1}, {"x": 0.2, "y": 2.0, "c": 0.2}]) - - monkeypatch.setattr(xopt_optimizer_module, "get_feasibility_data", lambda _vocs, _data: {"c": [True, False]}) - - mask = optimizer._feasible_mask(data) - assert mask.tolist() == [True, True] - - def test_xopt_optimizer_get_best_points_includes_observables(): vocs = VOCS( variables={"x": [0.0, 1.0]}, diff --git a/src/blop/xopt/optimizer.py b/src/blop/xopt/optimizer.py index c41a3876..39bc86cf 100644 --- a/src/blop/xopt/optimizer.py +++ b/src/blop/xopt/optimizer.py @@ -1,3 +1,4 @@ +import json import pickle from collections.abc import Mapping from pathlib import Path @@ -6,7 +7,7 @@ import pandas as pd from xopt import VOCS from xopt.generator import Generator -from xopt.vocs import get_feasibility_data, random_inputs, select_best +from xopt.vocs import FeasibilityError, get_feasibility_data, random_inputs, select_best from ..protocols import ID_KEY, CanRegisterSuggestions, Checkpointable, Optimizer, TrialFaultAware @@ -43,13 +44,25 @@ def __init__( @classmethod def from_checkpoint(cls, checkpoint_path: str) -> "XoptOptimizer": - # Restore all persistent adapter state from pickle payload. + # Restore all persistent adapter state from JSON payload. path = Path(checkpoint_path) with path.open("rb") as stream: - payload = pickle.load(stream) + payload = json.load(stream) instance = object.__new__(cls) - instance._generator = payload["generator"] + + generator_class_name = payload["generator"]["class"] + generator_module_name = payload["generator"]["module"] + generator_state = payload["generator"]["state"] + generator_data = payload["generator"].get("data", None) + + # Dynamically import the generator class from its module. + generator_module = __import__(generator_module_name, fromlist=[generator_class_name]) + generator_class = getattr(generator_module, generator_class_name) + instance._generator = generator_class.model_validate(generator_state) + if generator_data is not None: + instance._generator.ingest(generator_data) + instance._checkpoint_path = str(path) instance._next_id = payload.get("next_id", 0) instance._params_by_id = payload.get("params_by_id", {}) @@ -114,11 +127,18 @@ def register_suggestions(self, suggestions: list[dict]) -> list[dict]: self._params_by_id[trial_id] = params registered.append({ID_KEY: trial_id, **suggestion}) + # add the registered suggestions to the generator's data + self._generator.ingest(registered) + return registered def ingest(self, points: list[dict]) -> None: - # Convert outcome payloads to DataFrame rows expected by Xopt generator.add_data(). - rows: list[dict[str, Any]] = [] + if not points: + return + + # Convert outcomes into trial rows, keeping only the latest entry per trial ID. + variable_names = set(self.vocs.variable_names) + rows_by_id: dict[int | str, dict[str, Any]] = {} for point in points: # Preserve provided IDs when available, else allocate a new one. @@ -131,18 +151,42 @@ def ingest(self, points: list[dict]) -> None: # Merge known suggested parameters with any explicit parameters in incoming point. point_parameters = {name: point[name] for name in self.vocs.variable_names if name in point} - if trial_id in self._params_by_id: - parameters = {**self._params_by_id[trial_id], **point_parameters} - else: - parameters = point_parameters + parameters = {**self._params_by_id.get(trial_id, {}), **point_parameters} self._params_by_id[trial_id] = parameters # Everything not in variables and not _id is treated as measured output. - outcomes = {k: v for k, v in point.items() if k not in set(self.vocs.variable_names) | {ID_KEY}} - rows.append({ID_KEY: trial_id, **parameters, **outcomes}) + outcomes = {k: v for k, v in point.items() if k not in variable_names | {ID_KEY}} + rows_by_id[trial_id] = {ID_KEY: trial_id, **parameters, **outcomes} + + rows = list(rows_by_id.values()) - # Persist all new observations into the underlying generator state. - self._generator.ingest(rows) + # Update existing trial rows in-place by ID; only append truly new IDs. + data = self._generator.data + if not (isinstance(data, pd.DataFrame) and not data.empty and ID_KEY in data.columns): + # Persist all observations when no existing data is present. + self._generator.ingest(rows) + return + + # Build a mapping from trial ID to row indices in the existing generator data. + index_by_id: dict[int | str, list] = {} + for index, raw_trial_id in data[ID_KEY].items(): + trial_id = _normalize_trial_id(raw_trial_id) + index_by_id.setdefault(trial_id, []).append(index) + + # Update existing rows in-place and collect new rows to append. + rows_to_append: list[dict[str, Any]] = [] + for trial_id, row in rows_by_id.items(): + indices = index_by_id.get(trial_id) + if indices is None: + rows_to_append.append(row) + continue + + for key, value in row.items(): + data.loc[indices, key] = value + + # Append any new rows to the generator's data after in-place updates. + if rows_to_append: + self._generator.ingest(rows_to_append) def register_failures(self, suggestions: list[dict]) -> None: # Remove failed suggestions from pending parameter cache. @@ -151,48 +195,27 @@ def register_failures(self, suggestions: list[dict]) -> None: if trial_id in self._params_by_id: self._params_by_id.pop(trial_id) - def _feasible_mask(self, data: pd.DataFrame) -> pd.Series: - # Delegate feasibility computation to Xopt's native VOCS helper. - constraints = self.vocs.constraints - if not isinstance(constraints, Mapping) or len(constraints) == 0: - return pd.Series([True] * len(data), index=data.index) - - feasibility = get_feasibility_data(self.vocs, data) - if "feasible" not in feasibility: - return pd.Series([True] * len(data), index=data.index) - return pd.Series(feasibility["feasible"], index=data.index, dtype=bool) - - def _output_names(self) -> list[str]: - # Outputs include objectives, constraints, and optional observables. - names = list(self.vocs.objective_names) - if self.vocs.constraints: - names.extend(self.vocs.constraint_names) - if getattr(self.vocs, "observables", None): - names.extend(self.vocs.observables) - return names - def get_best_points(self) -> list[tuple[int | str, Mapping, Mapping]]: # Return no points when no data has been ingested. data = self._generator.data if not isinstance(data, pd.DataFrame) or data.empty: return [] - # Best points are only defined over feasible observations. - candidates: pd.DataFrame = data.loc[self._feasible_mask(data)] - if len(candidates) == 0: - return [] - objective_names = list(self.vocs.objective_names) # For single-objective problems, return the single extremum according to direction. - if len(objective_names) == 1 and objective_names[0] in candidates: - best_indices, _, _ = select_best(self.vocs, candidates, n=1) - best_index = best_indices[0] - selected = candidates.loc[[best_index]] + if len(objective_names) == 1 and objective_names[0] in data: + try: + best_indices, _, _ = select_best(self.vocs, data, n=1) + best_index = best_indices[0] + selected = data.loc[[best_index]] + except FeasibilityError: + # If no feasible points exist, return an empty list. + return [] else: # For multi-objective and objective-less modes, return the available candidate set. - selected = candidates + selected = data - output_names = self._output_names() + output_names = self._generator.vocs.output_names results: list[tuple[int | str, Mapping, Mapping]] = [] for _, row in selected.iterrows(): # Normalize IDs and split into parameter and outcome mappings. @@ -205,16 +228,26 @@ def get_best_points(self) -> list[tuple[int | str, Mapping, Mapping]]: return results def checkpoint(self) -> None: + """ dump serialized state to json file at self._checkpoint_path """ # Enforce explicit checkpoint path configuration before writing state. if not self._checkpoint_path: raise ValueError("Checkpoint path is not set. Please set a checkpoint path when initializing the optimizer.") # Persist generator and adapter bookkeeping to a single pickle artifact. payload = { - "generator": self._generator, + "generator": { + "class": self._generator.__class__.__name__, + "module": self._generator.__class__.__module__, + "state": self._generator.model_dump(), + "data": self._generator.data.to_dict(orient="records") if isinstance(self._generator.data, pd.DataFrame) else self._generator.data + }, "next_id": self._next_id, "params_by_id": self._params_by_id, } + + # Write the payload to the configured checkpoint path. path = Path(self._checkpoint_path) with path.open("wb") as stream: - pickle.dump(payload, stream) + stream.write(json.dumps(payload, default=str).encode("utf-8")) + + From 071415f78e0a2a8fbb79913e12a05c015d91a826 Mon Sep 17 00:00:00 2001 From: Ryan Roussel Date: Thu, 2 Jul 2026 10:38:15 -0500 Subject: [PATCH 20/25] code simplification /modernization --- src/blop/xopt/optimizer.py | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/src/blop/xopt/optimizer.py b/src/blop/xopt/optimizer.py index 39bc86cf..bb1a228c 100644 --- a/src/blop/xopt/optimizer.py +++ b/src/blop/xopt/optimizer.py @@ -1,5 +1,5 @@ import json -import pickle +from importlib import import_module from collections.abc import Mapping from pathlib import Path from typing import Any @@ -7,7 +7,7 @@ import pandas as pd from xopt import VOCS from xopt.generator import Generator -from xopt.vocs import FeasibilityError, get_feasibility_data, random_inputs, select_best +from xopt.vocs import FeasibilityError, random_inputs, select_best from ..protocols import ID_KEY, CanRegisterSuggestions, Checkpointable, Optimizer, TrialFaultAware @@ -46,7 +46,7 @@ def __init__( def from_checkpoint(cls, checkpoint_path: str) -> "XoptOptimizer": # Restore all persistent adapter state from JSON payload. path = Path(checkpoint_path) - with path.open("rb") as stream: + with path.open("r", encoding="utf-8") as stream: payload = json.load(stream) instance = object.__new__(cls) @@ -54,10 +54,10 @@ def from_checkpoint(cls, checkpoint_path: str) -> "XoptOptimizer": generator_class_name = payload["generator"]["class"] generator_module_name = payload["generator"]["module"] generator_state = payload["generator"]["state"] - generator_data = payload["generator"].get("data", None) + generator_data = payload["generator"].get("data") # Dynamically import the generator class from its module. - generator_module = __import__(generator_module_name, fromlist=[generator_class_name]) + generator_module = import_module(generator_module_name) generator_class = getattr(generator_module, generator_class_name) instance._generator = generator_class.model_validate(generator_state) if generator_data is not None: @@ -107,7 +107,7 @@ def suggest(self, num_points: int | None = None) -> list[dict]: # Bootstrap first call with random VOCS inputs to avoid model-based generators requiring prior data. data = self._generator.data - first_suggest_call = self._next_id == 0 and len(self._params_by_id) == 0 + first_suggest_call = self._next_id == 0 and not self._params_by_id has_data = isinstance(data, pd.DataFrame) and not data.empty if first_suggest_call and not has_data: @@ -192,8 +192,8 @@ def register_failures(self, suggestions: list[dict]) -> None: # Remove failed suggestions from pending parameter cache. for suggestion in suggestions: trial_id = suggestion.get(ID_KEY) - if trial_id in self._params_by_id: - self._params_by_id.pop(trial_id) + if trial_id is not None: + self._params_by_id.pop(trial_id, None) def get_best_points(self) -> list[tuple[int | str, Mapping, Mapping]]: # Return no points when no data has been ingested. @@ -215,7 +215,7 @@ def get_best_points(self) -> list[tuple[int | str, Mapping, Mapping]]: # For multi-objective and objective-less modes, return the available candidate set. selected = data - output_names = self._generator.vocs.output_names + output_names = self.vocs.output_names results: list[tuple[int | str, Mapping, Mapping]] = [] for _, row in selected.iterrows(): # Normalize IDs and split into parameter and outcome mappings. @@ -228,18 +228,22 @@ def get_best_points(self) -> list[tuple[int | str, Mapping, Mapping]]: return results def checkpoint(self) -> None: - """ dump serialized state to json file at self._checkpoint_path """ + """Dump serialized optimizer state to the configured checkpoint JSON file.""" # Enforce explicit checkpoint path configuration before writing state. if not self._checkpoint_path: raise ValueError("Checkpoint path is not set. Please set a checkpoint path when initializing the optimizer.") - # Persist generator and adapter bookkeeping to a single pickle artifact. + # Persist generator and adapter bookkeeping to a single JSON artifact. payload = { "generator": { "class": self._generator.__class__.__name__, "module": self._generator.__class__.__module__, "state": self._generator.model_dump(), - "data": self._generator.data.to_dict(orient="records") if isinstance(self._generator.data, pd.DataFrame) else self._generator.data + "data": ( + self._generator.data.to_dict(orient="records") + if isinstance(self._generator.data, pd.DataFrame) + else self._generator.data + ), }, "next_id": self._next_id, "params_by_id": self._params_by_id, @@ -247,7 +251,7 @@ def checkpoint(self) -> None: # Write the payload to the configured checkpoint path. path = Path(self._checkpoint_path) - with path.open("wb") as stream: - stream.write(json.dumps(payload, default=str).encode("utf-8")) + with path.open("w", encoding="utf-8") as stream: + json.dump(payload, stream, default=str) From 3ed87b8edf6dbb9bf285b975bebabeb5016a8cc2 Mon Sep 17 00:00:00 2001 From: Ryan Roussel Date: Thu, 2 Jul 2026 10:41:52 -0500 Subject: [PATCH 21/25] additional code simplification --- src/blop/xopt/optimizer.py | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/src/blop/xopt/optimizer.py b/src/blop/xopt/optimizer.py index bb1a228c..8c4257c3 100644 --- a/src/blop/xopt/optimizer.py +++ b/src/blop/xopt/optimizer.py @@ -100,15 +100,18 @@ def _seed_state_from_existing_data(self) -> None: if isinstance(trial_id, int): self._next_id = max(self._next_id, trial_id + 1) + def _generator_has_data(self) -> bool: + data = self._generator.data + return isinstance(data, pd.DataFrame) and not data.empty + def suggest(self, num_points: int | None = None) -> list[dict]: # Default to single-point suggestion when caller does not specify cardinality. if num_points is None: num_points = 1 # Bootstrap first call with random VOCS inputs to avoid model-based generators requiring prior data. - data = self._generator.data first_suggest_call = self._next_id == 0 and not self._params_by_id - has_data = isinstance(data, pd.DataFrame) and not data.empty + has_data = self._generator_has_data() if first_suggest_call and not has_data: suggestions = random_inputs(self.vocs, n=num_points) @@ -137,7 +140,8 @@ def ingest(self, points: list[dict]) -> None: return # Convert outcomes into trial rows, keeping only the latest entry per trial ID. - variable_names = set(self.vocs.variable_names) + variable_names = list(self.vocs.variable_names) + variable_name_set = set(variable_names) rows_by_id: dict[int | str, dict[str, Any]] = {} for point in points: @@ -150,21 +154,19 @@ def ingest(self, points: list[dict]) -> None: trial_id = _normalize_trial_id(raw_trial_id) # Merge known suggested parameters with any explicit parameters in incoming point. - point_parameters = {name: point[name] for name in self.vocs.variable_names if name in point} + point_parameters = {name: point[name] for name in variable_names if name in point} parameters = {**self._params_by_id.get(trial_id, {}), **point_parameters} self._params_by_id[trial_id] = parameters # Everything not in variables and not _id is treated as measured output. - outcomes = {k: v for k, v in point.items() if k not in variable_names | {ID_KEY}} + outcomes = {k: v for k, v in point.items() if k not in variable_name_set | {ID_KEY}} rows_by_id[trial_id] = {ID_KEY: trial_id, **parameters, **outcomes} - rows = list(rows_by_id.values()) - # Update existing trial rows in-place by ID; only append truly new IDs. data = self._generator.data if not (isinstance(data, pd.DataFrame) and not data.empty and ID_KEY in data.columns): # Persist all observations when no existing data is present. - self._generator.ingest(rows) + self._generator.ingest(list(rows_by_id.values())) return # Build a mapping from trial ID to row indices in the existing generator data. @@ -197,8 +199,11 @@ def register_failures(self, suggestions: list[dict]) -> None: def get_best_points(self) -> list[tuple[int | str, Mapping, Mapping]]: # Return no points when no data has been ingested. + if not self._generator_has_data(): + return [] + data = self._generator.data - if not isinstance(data, pd.DataFrame) or data.empty: + if not isinstance(data, pd.DataFrame): return [] objective_names = list(self.vocs.objective_names) From 6796670c68e74aa005d18ab731aae04206874915 Mon Sep 17 00:00:00 2001 From: Ryan Roussel Date: Thu, 2 Jul 2026 10:43:00 -0500 Subject: [PATCH 22/25] linting --- src/blop/tests/xopt/test_optimizer.py | 2 -- src/blop/xopt/optimizer.py | 4 +--- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/src/blop/tests/xopt/test_optimizer.py b/src/blop/tests/xopt/test_optimizer.py index 0130ef4a..63433308 100644 --- a/src/blop/tests/xopt/test_optimizer.py +++ b/src/blop/tests/xopt/test_optimizer.py @@ -401,5 +401,3 @@ def evaluation_function(_uid: str, suggestions: list[dict]) -> list[dict]: _, params, outcomes = best_points[0] assert 0.0 <= float(params["x"]) <= 1.0 assert float(outcomes["y"]) >= 0.0 - - diff --git a/src/blop/xopt/optimizer.py b/src/blop/xopt/optimizer.py index 8c4257c3..2a0423db 100644 --- a/src/blop/xopt/optimizer.py +++ b/src/blop/xopt/optimizer.py @@ -1,6 +1,6 @@ import json -from importlib import import_module from collections.abc import Mapping +from importlib import import_module from pathlib import Path from typing import Any @@ -258,5 +258,3 @@ def checkpoint(self) -> None: path = Path(self._checkpoint_path) with path.open("w", encoding="utf-8") as stream: json.dump(payload, stream, default=str) - - From c353a7afc456c3741583f145aaf41012ab83a809 Mon Sep 17 00:00:00 2001 From: Ryan Roussel Date: Thu, 2 Jul 2026 12:53:22 -0500 Subject: [PATCH 23/25] Update xrt-kb-mirrors.md --- docs/source/tutorials/xrt-kb-mirrors.md | 71 +++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/docs/source/tutorials/xrt-kb-mirrors.md b/docs/source/tutorials/xrt-kb-mirrors.md index 02d94874..2c42b2ef 100644 --- a/docs/source/tutorials/xrt-kb-mirrors.md +++ b/docs/source/tutorials/xrt-kb-mirrors.md @@ -350,6 +350,77 @@ plt.title("Optimized KB Mirror Beam") plt.show() ``` +## Solving the Same Problem with XoptOptimizer + +Blop also supports Xopt through `XoptOptimizer`, which plugs directly into the +same `optimize` plan used throughout the package. This gives you a lower-level +optimization interface while reusing the same devices, acquisition flow, and +evaluation function from above. + +For this section, we keep the exact same objective and constraint: + +- Minimize `fwhm` +- Require `intensity >= 10000` + +```{code-cell} ipython3 +from xopt.generators.bayesian import ExpectedImprovementGenerator +from xopt.vocs import VOCS + +from blop.plans import optimize +from blop.protocols import OptimizationProblem +from blop.xopt.optimizer import XoptOptimizer +``` + +Define the Xopt search space (`VOCS`) from the same mirror bounds and outcome names used earlier: + +```{code-cell} ipython3 +xopt_vocs = VOCS( + variables={ + kbv.radius.name: list(VERTICAL_BOUNDS), + kbh.radius.name: list(HORIZONTAL_BOUNDS), + }, + objectives={"fwhm": "MINIMIZE"}, + constraints={"intensity": ["GREATER_THAN", 10000.0]}, +) + +xopt_optimizer = XoptOptimizer(generator=ExpectedImprovementGenerator(vocs=xopt_vocs)) + +xopt_problem = OptimizationProblem( + optimizer=xopt_optimizer, + actuators=[kbv.radius, kbh.radius], + sensors=[det], + evaluation_function=DetectorEvaluation(tiled_client), +) +``` + +Run an optimization loop. As with the Ax-based flow, this is executed via the Bluesky `RunEngine`. + +```{code-cell} ipython3 +RE(optimize(xopt_problem, iterations=10, n_points=1)) +``` + +Visualize the GP model learned by the Xopt generator: + +```{code-cell} ipython3 +fig, _ = xopt_optimizer.generator.visualize_model( + output_names=["fwhm"], + variable_names=[kbv.radius.name, kbh.radius.name], + show_feasibility=True, +) +plt.show() +``` + +Inspect the best point found by Xopt and the collected trial data: + +```{code-cell} ipython3 +xopt_best_points = xopt_optimizer.get_best_points() +xopt_best_points +``` + +```{code-cell} ipython3 +xopt_optimizer.generator.data.tail() +``` + ```{code-cell} ipython3 tiled_server.close() ``` From 815871ad82473d8cb8764b0c938b22690f37d075 Mon Sep 17 00:00:00 2001 From: Thomas Hopkins Date: Thu, 9 Jul 2026 14:25:32 -0400 Subject: [PATCH 24/25] Update src/blop/__init__.py --- src/blop/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/blop/__init__.py b/src/blop/__init__.py index 61203afe..c12011e3 100644 --- a/src/blop/__init__.py +++ b/src/blop/__init__.py @@ -1,5 +1,4 @@ from .plans import acquire_baseline, default_acquire, optimize, optimize_step, sample_suggestions -from .xopt import XoptOptimizer try: from ._version import __version__ From a788fb32c32a8de62eb9803da1159bd79a05f293 Mon Sep 17 00:00:00 2001 From: Thomas Hopkins Date: Thu, 9 Jul 2026 14:25:39 -0400 Subject: [PATCH 25/25] Update src/blop/__init__.py --- src/blop/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/blop/__init__.py b/src/blop/__init__.py index c12011e3..658a078f 100644 --- a/src/blop/__init__.py +++ b/src/blop/__init__.py @@ -12,5 +12,4 @@ "optimize", "optimize_step", "sample_suggestions", - "XoptOptimizer", ]