From d1e22a1e3c826ff3e4d00ab84c609fb8334d8c98 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Wed, 29 Apr 2026 17:19:59 -0400 Subject: [PATCH 01/43] eod --- src/blop/ax/dof.py | 7 +++ src/blop/gradient/Scipy.py | 96 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 src/blop/gradient/Scipy.py diff --git a/src/blop/ax/dof.py b/src/blop/ax/dof.py index 28b2a399..ada55ec1 100644 --- a/src/blop/ax/dof.py +++ b/src/blop/ax/dof.py @@ -8,6 +8,7 @@ from ax import ChoiceParameterConfig, RangeParameterConfig from ax.api.types import TParameterValue +from scipy.optimize import Bounds from ..protocols import Actuator @@ -124,6 +125,12 @@ def to_ax_parameter_config(self) -> RangeParameterConfig: scaling=self.scaling, ) + def to_scipy_bounds(self) -> Bounds: + """ + convert DOF to the Scipy equivalent Bounds + """ + return Bounds(lb=self.bounds[0], ub=self.bounds[1]) + @dataclass(frozen=True, kw_only=True, eq=False) class ChoiceDOF(DOF): diff --git a/src/blop/gradient/Scipy.py b/src/blop/gradient/Scipy.py new file mode 100644 index 00000000..62c5a537 --- /dev/null +++ b/src/blop/gradient/Scipy.py @@ -0,0 +1,96 @@ +from collections.abc import Sequence +from dataclasses import dataclass +from threading import Event, thread +from typing import Any, cast + +from scipy.optimize import Bounds, dual_annealing, minimize + +from blop.ax.dof import RangeDOF, DOFConstraint +from blop.ax.objective import OutcomeConstraint + +from ..protocols import AcquisitionPlan, Actuator, EvaluationFunction, OptimizationProblem, Optimizer, Sensor + + +@dataclass +class ScpCFG: + dof: Sequence[RangeDOF] + # dof_constraints: Sequence[DOFConstraint] | None = None + outcome_constraints: Sequence[OutcomeConstraint] | None = None + Optimizer: str = "Default" + max_iter: int | None = None + eps: float | None = None + + +class Scipy: + def __init__( + self, + sensors: Sequence[Sensor], + dofs: Sequence[RangeDOF], + evaluation_function: EvaluationFunction, + acquisition_plan: AcquisitionPlan | None = None, + # dof_constraints: Sequence[DOFConstraint] | None = None, + outcome_constraints: Sequence[OutcomeConstraint] | None = None, + checkpoint_path: str | None = None, + **kwargs: Any, + ): + 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._params = [] + self._bounds = + for dof in dofs: + self._params.append(dof.parameter_name) + self.bounds.append(Bounds(lb=param.bounds[0], ub=param.bounds[1])) + + @classmethod + def configure(cls, config: ScipyCFG): + self = cls() + self.cfg = config + return cls() + + def to_optimization_problem(self) -> OptimizationProblem: + ... + + def optimize(): + ... + + +class ScipyOptimizer(Optimizer): + + def __init__(self, ScpCFG): + ... + + def suggest(self, num_points: int | None = None) -> list[dict]: + """ + Returns a set of points in the input space, to be evaulated next. + + The "_id" key is optional and can be used to identify suggested trials for later evaluation + and ingestion. + + Parameters + ---------- + num_points : int | None, optional + The number of points to suggest. If not provided, will default to 1. + + Returns + ------- + list[dict] + A list of dictionaries, each containing a parameterization of a point to evaluate next. + Each dictionary must contain a unique "_id" key to identify each parameterization. + """ + ... + + def ingest(self, points: list[dict]) -> None: + """ + Ingest a set of points into the experiment. Either from previously suggested points or from an external source. + + The "_id" key is optional and can be used to identify points from previously suggested trials or to identify + the point as a "baseline" trial. + + Parameters + ---------- + points : list[dict] + A list of dictionaries, each containing the outcomes of each suggested parameterization. + """ + ... From 1daa8a848278641f54389280234e4145d79efec7 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 30 Apr 2026 16:20:44 -0400 Subject: [PATCH 02/43] [ENH] first "realization", onto sandbox --- src/blop/gradient/Scipy.py | 198 +++++++++++++++++++++++++++++++------ 1 file changed, 166 insertions(+), 32 deletions(-) diff --git a/src/blop/gradient/Scipy.py b/src/blop/gradient/Scipy.py index 62c5a537..4a6f7669 100644 --- a/src/blop/gradient/Scipy.py +++ b/src/blop/gradient/Scipy.py @@ -1,65 +1,189 @@ from collections.abc import Sequence from dataclasses import dataclass -from threading import Event, thread +from enum import Enum +from threading import Event, Thread from typing import Any, cast +import bluesky.preprocessors as bpp +from bluesky.callbacks import CallbackBase from scipy.optimize import Bounds, dual_annealing, minimize -from blop.ax.dof import RangeDOF, DOFConstraint -from blop.ax.objective import OutcomeConstraint +from blop.ax.dof import RangeDOF +from blop.callbacks.logger import OptimizationLogger +from blop.callbacks.router import OptimizationCallbackRouter +from blop.plans import optimize +from blop.utils import InferredReadable -from ..protocols import AcquisitionPlan, Actuator, EvaluationFunction, OptimizationProblem, Optimizer, Sensor +from ..protocols import ID_KEY, AcquisitionPlan, Actuator, EvaluationFunction, OptimizationProblem, Optimizer, Sensor + + +class SCP(str, Enum): + Default = "Default" + Dual_Annealing = "dual annealing" @dataclass -class ScpCFG: - dof: Sequence[RangeDOF] +class ScipyCFG: + dofs: Sequence[RangeDOF] # dof_constraints: Sequence[DOFConstraint] | None = None - outcome_constraints: Sequence[OutcomeConstraint] | None = None - Optimizer: str = "Default" + # outcome_constraints: Sequence[OutcomeConstraint] | None = None + optimizer: str = "Default" + initial: Sequence[float] | None = None max_iter: int | None = None eps: float | None = None class Scipy: + """ + A convenience interface associated with running optimizations with Scipy, providing similar syntax to the Ax Agent + (allowing drop in swapping as much as possible). Useful as a cover in for all the QOL provided by the Agent object. + """ + def __init__( self, sensors: Sequence[Sensor], - dofs: Sequence[RangeDOF], + config: ScipyCFG, evaluation_function: EvaluationFunction, acquisition_plan: AcquisitionPlan | None = None, - # dof_constraints: Sequence[DOFConstraint] | None = None, - outcome_constraints: Sequence[OutcomeConstraint] | None = None, - checkpoint_path: str | None = None, - **kwargs: Any, ): + + self._config = config self._sensors = sensors - self._actuators: Sequence[Actuator] = [cast(Actuator, dof.actuator) for dof in dofs if dof.actuator is not None] + self._actuators = [cast(Actuator, dof.actuator) for dof in config.dofs if dof.actuator is not None] self._evaluation_function = evaluation_function self._acquisition_plan = acquisition_plan - self._params = [] - self._bounds = - for dof in dofs: - self._params.append(dof.parameter_name) - self.bounds.append(Bounds(lb=param.bounds[0], ub=param.bounds[1])) + self._optimizer = ScipyOptimizer(self._config) + self._readable_cache: dict[str, InferredReadable] = {} + self._callbacks: list[CallbackBase] = [OptimizationLogger()] + self._callback_router = OptimizationCallbackRouter(self._callbacks) @classmethod - def configure(cls, config: ScipyCFG): - self = cls() - self.cfg = config - return cls() + def Agent( + cls, + sensors: Sequence[Sensor], + dofs: Sequence[RangeDOF], + evaluation_function: EvaluationFunction, + acquisition_plan: AcquisitionPlan | None = None, + optimizer: SCP | str = SCP.Default, + # dof_constraints: Sequence[DOFConstraint] | None = None, #implemented in future iterations? make to match ax? + # outcome_constraints: Sequence[OutcomeConstraint] | None = None, + **kwargs: Any, + ): + + if optimizer not in SCP: + raise ValueError(f"optimizer {optimizer} not in supported optimizers:{list(SCP)}") + + config = ScipyCFG(dofs=dofs, optimizer=optimizer, max_iter=kwargs.get("max_iter", None), eps=kwargs.get("eps", None)) + return cls(sensors, config, evaluation_function, acquisition_plan) + + @property + def sensors(self) -> Sequence[Sensor]: + """The sensors used for data acquisition.""" + return self._sensors + + @property + def actuators(self) -> Sequence[Actuator]: + """The actuators that control the degrees of freedom.""" + return self._actuators + + @property + def evaluation_function(self) -> EvaluationFunction: + """The function used to evaluate acquired data and produce outcomes.""" + return self._evaluation_function + + @property + def acquisition_plan(self) -> AcquisitionPlan | None: + """The acquisition plan for acquiring data, or ``None`` if using the default.""" + return self._acquisition_plan def to_optimization_problem(self) -> OptimizationProblem: - ... + """ + Construct an optimization problem from the Scipy Base class - def optimize(): - ... + Creates an immutable :class:`blop.protocols.OptimizationProblem` that + encapsulates all components needed for optimization. This is typically + used internally by optimization plans. + + Returns + ------- + OptimizationProblem + An immutable optimization problem that can be deployed via Bluesky. + + See Also + -------- + blop.protocols.OptimizationProblem : The optimization problem dataclass. + blop.plans.optimize : Uses the optimization problem to run optimization. + """ + return OptimizationProblem( + optimizer=self._optimizer, + actuators=self._actuators, + sensors=self._sensors, + evaluation_function=self._evaluation_function, + acquisition_plan=self._acquisition_plan, + ) + + def optimize(self): + optimize_plan = optimize(self.to_optimization_problem(), readable_cache=self._readable_cache) + + if self._callbacks: + optimize_plan = bpp.subs_wrapper( + optimize_plan, + self._callback_router, + ) + + yield from optimize_plan class ScipyOptimizer(Optimizer): + """ + An optimizer object to supply an interactive interface for the scipy optimizers, with some caveats. + """ - def __init__(self, ScpCFG): - ... + def __init__(self, config: ScipyCFG): + self._semaphore = Event() + self._params = [] + self._bounds = [] + self._increment = 0 + self._y = None + self.final = None + self.force_resiliance = False + midp = [] + + for dof in config.dofs: + self._params.append(dof.parameter_name) + self._bounds.append(Bounds(lb=dof.bounds[0], ub=dof.bounds[1])) + midp.append(0.5 * dof.bounds[0] + 0.5 * dof.bounds[1]) + + if config.initial is not None: + self._x = config.initial + else: + self._x = midp + + if config.optimizer in (SCP.Default): + + def cost(x): + self._x = x + self._semaphore.clear() + self._semaphore.wait() + if self._y is None: + raise ValueError("return value is not present") + return self._y + + kw = {} + if config.eps is not None: + kw["eps"] = config.eps + if config.max_iter is not None: + kw["max_iter"] = config.max_iter + + def mini_worker(): + self.final = minimize(fun=cost, x0=self._x, args=kw) + + # self.t = Thread(target=minimize, args=(cost, self._x), kwargs=kw, name="optimizer") + self.t = Thread(target=mini_worker, name="optimizer") + self.t.start() + elif config.optimizer is SCP.Dual_Annealing: + print("do it yourself") + return dual_annealing def suggest(self, num_points: int | None = None) -> list[dict]: """ @@ -79,18 +203,28 @@ def suggest(self, num_points: int | None = None) -> list[dict]: A list of dictionaries, each containing a parameterization of a point to evaluate next. Each dictionary must contain a unique "_id" key to identify each parameterization. """ - ... + if self.final is None: + suggestion = dict(zip(self._params, self._x, strict=True)) + else: + suggestion = dict(zip(self._params, self.final.x, strict=True)) + suggestion[ID_KEY] = self._increment + self.increment += 1 + return [suggestion] def ingest(self, points: list[dict]) -> None: """ Ingest a set of points into the experiment. Either from previously suggested points or from an external source. - The "_id" key is optional and can be used to identify points from previously suggested trials or to identify - the point as a "baseline" trial. + The "_id" key is optional. Parameters ---------- points : list[dict] A list of dictionaries, each containing the outcomes of each suggested parameterization. """ - ... + if self._semaphore.is_set() and not self.force_resiliance: + raise ValueError("optimizer did not expect to receive an update") + res = points[0] + re_val = [res[param] for param in res if param not in (*self._params, ID_KEY)] + self._y = re_val[0] + self._semaphore.set() From 35d153b4cd299a41ac52f903eb273fecf53a31ab Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 30 Apr 2026 16:54:02 -0400 Subject: [PATCH 03/43] add submodule init --- src/blop/gradient/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/blop/gradient/__init__.py diff --git a/src/blop/gradient/__init__.py b/src/blop/gradient/__init__.py new file mode 100644 index 00000000..e69de29b From ea6bfd7c3f1c5dcb71d0dc2e1c5c4d7cf8286201 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 30 Apr 2026 16:58:01 -0400 Subject: [PATCH 04/43] fix --- src/blop/gradient/__init__.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/blop/gradient/__init__.py b/src/blop/gradient/__init__.py index e69de29b..73814355 100644 --- a/src/blop/gradient/__init__.py +++ b/src/blop/gradient/__init__.py @@ -0,0 +1,8 @@ +from .Scipy import SCP, Scipy, ScipyCFG, ScipyOptimizer + +__all__ = [ + "SCP", + "ScipyCFG", + "Scipy", + "ScipyOptimizer" +] From 86957162ff0f9b30f5f771fd55511db9d60ae689 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 30 Apr 2026 17:26:50 -0400 Subject: [PATCH 05/43] bugfixes --- src/blop/gradient/Scipy.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/blop/gradient/Scipy.py b/src/blop/gradient/Scipy.py index 4a6f7669..3c83dfcb 100644 --- a/src/blop/gradient/Scipy.py +++ b/src/blop/gradient/Scipy.py @@ -6,7 +6,7 @@ import bluesky.preprocessors as bpp from bluesky.callbacks import CallbackBase -from scipy.optimize import Bounds, dual_annealing, minimize +from scipy.optimize import Bounds, OptimizeResult, dual_annealing, minimize from blop.ax.dof import RangeDOF from blop.callbacks.logger import OptimizationLogger @@ -29,7 +29,7 @@ class ScipyCFG: # outcome_constraints: Sequence[OutcomeConstraint] | None = None optimizer: str = "Default" initial: Sequence[float] | None = None - max_iter: int | None = None + max_iter: int | None = 100 eps: float | None = None @@ -145,6 +145,7 @@ def __init__(self, config: ScipyCFG): self._bounds = [] self._increment = 0 self._y = None + self.intermediate = None self.final = None self.force_resiliance = False midp = [] @@ -169,6 +170,9 @@ def cost(x): raise ValueError("return value is not present") return self._y + def optim_callback(intermediate_result: OptimizeResult): + self.intermediate = intermediate_result + kw = {} if config.eps is not None: kw["eps"] = config.eps @@ -176,7 +180,7 @@ def cost(x): kw["max_iter"] = config.max_iter def mini_worker(): - self.final = minimize(fun=cost, x0=self._x, args=kw) + self.final = minimize(fun=cost, x0=self._x, callback=optim_callback, options=kw) # self.t = Thread(target=minimize, args=(cost, self._x), kwargs=kw, name="optimizer") self.t = Thread(target=mini_worker, name="optimizer") @@ -185,6 +189,12 @@ def mini_worker(): print("do it yourself") return dual_annealing + def optimum(self): + if self.final is not None: + return self.final + + return self.intermediate + def suggest(self, num_points: int | None = None) -> list[dict]: """ Returns a set of points in the input space, to be evaulated next. @@ -208,7 +218,7 @@ def suggest(self, num_points: int | None = None) -> list[dict]: else: suggestion = dict(zip(self._params, self.final.x, strict=True)) suggestion[ID_KEY] = self._increment - self.increment += 1 + self._increment += 1 return [suggestion] def ingest(self, points: list[dict]) -> None: From 1c0083b4724a6af7b31357f864b191135bef6c93 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 30 Apr 2026 17:30:48 -0400 Subject: [PATCH 06/43] do more than one iteration --- src/blop/gradient/Scipy.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/blop/gradient/Scipy.py b/src/blop/gradient/Scipy.py index 3c83dfcb..813dda3a 100644 --- a/src/blop/gradient/Scipy.py +++ b/src/blop/gradient/Scipy.py @@ -122,8 +122,8 @@ def to_optimization_problem(self) -> OptimizationProblem: acquisition_plan=self._acquisition_plan, ) - def optimize(self): - optimize_plan = optimize(self.to_optimization_problem(), readable_cache=self._readable_cache) + def optimize(self, iterations=10): + optimize_plan = optimize(self.to_optimization_problem(), iterations=iterations, readable_cache=self._readable_cache) if self._callbacks: optimize_plan = bpp.subs_wrapper( From 362f23533f9d65e27105bce807e9d2d643721c18 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Fri, 1 May 2026 14:00:42 -0400 Subject: [PATCH 07/43] [IENH] added dual annealing support and cleaned worker prep --- src/blop/gradient/Scipy.py | 51 ++++++++++++++++++++------------------ 1 file changed, 27 insertions(+), 24 deletions(-) diff --git a/src/blop/gradient/Scipy.py b/src/blop/gradient/Scipy.py index 813dda3a..b925407d 100644 --- a/src/blop/gradient/Scipy.py +++ b/src/blop/gradient/Scipy.py @@ -123,6 +123,7 @@ def to_optimization_problem(self) -> OptimizationProblem: ) def optimize(self, iterations=10): + self._optimizer = ScipyOptimizer(self._config) optimize_plan = optimize(self.to_optimization_problem(), iterations=iterations, readable_cache=self._readable_cache) if self._callbacks: @@ -160,34 +161,36 @@ def __init__(self, config: ScipyCFG): else: self._x = midp - if config.optimizer in (SCP.Default): - - def cost(x): - self._x = x - self._semaphore.clear() - self._semaphore.wait() - if self._y is None: - raise ValueError("return value is not present") - return self._y + def cost(x): + self._x = x + self._semaphore.clear() + self._semaphore.wait() + if self._y is None: + raise ValueError("return value is not present") + return self._y - def optim_callback(intermediate_result: OptimizeResult): - self.intermediate = intermediate_result + def optim_callback(intermediate_result: OptimizeResult): + self.intermediate = intermediate_result - kw = {} - if config.eps is not None: - kw["eps"] = config.eps - if config.max_iter is not None: - kw["max_iter"] = config.max_iter + kw = {} + if config.eps is not None: + kw["eps"] = config.eps + if config.max_iter is not None: + kw["max_iter"] = config.max_iter + if config.optimizer in (SCP.Default): def mini_worker(): - self.final = minimize(fun=cost, x0=self._x, callback=optim_callback, options=kw) - - # self.t = Thread(target=minimize, args=(cost, self._x), kwargs=kw, name="optimizer") - self.t = Thread(target=mini_worker, name="optimizer") - self.t.start() - elif config.optimizer is SCP.Dual_Annealing: - print("do it yourself") - return dual_annealing + self.final = minimize(fun=cost, x0=self._x, bounds=self._bounds, callback=optim_callback, options=kw) + elif config.optimizer in (SCP.Dual_Annealing): + def mini_worker(): + self.final = dual_annealing( + func=cost, x0=self._x, bounds=self._bounds, callback=optim_callback, minimizer_kwargs=kw) + else: + raise NotImplementedError("") + + # self.t = Thread(target=minimize, args=(cost, self._x), kwargs=kw, name="optimizer") + self.t = Thread(target=mini_worker, name="optimizer") + self.t.start() def optimum(self): if self.final is not None: From a8ee1e12aefc34714d4a496d2d07bdfe2440fae4 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Mon, 4 May 2026 10:19:27 -0400 Subject: [PATCH 08/43] ruff fixes --- src/blop/gradient/Scipy.py | 5 ++++- src/blop/gradient/__init__.py | 7 +------ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/blop/gradient/Scipy.py b/src/blop/gradient/Scipy.py index b925407d..7d40c306 100644 --- a/src/blop/gradient/Scipy.py +++ b/src/blop/gradient/Scipy.py @@ -179,12 +179,15 @@ def optim_callback(intermediate_result: OptimizeResult): kw["max_iter"] = config.max_iter if config.optimizer in (SCP.Default): + def mini_worker(): self.final = minimize(fun=cost, x0=self._x, bounds=self._bounds, callback=optim_callback, options=kw) elif config.optimizer in (SCP.Dual_Annealing): + def mini_worker(): self.final = dual_annealing( - func=cost, x0=self._x, bounds=self._bounds, callback=optim_callback, minimizer_kwargs=kw) + func=cost, x0=self._x, bounds=self._bounds, callback=optim_callback, minimizer_kwargs=kw + ) else: raise NotImplementedError("") diff --git a/src/blop/gradient/__init__.py b/src/blop/gradient/__init__.py index 73814355..a9aacd1a 100644 --- a/src/blop/gradient/__init__.py +++ b/src/blop/gradient/__init__.py @@ -1,8 +1,3 @@ from .Scipy import SCP, Scipy, ScipyCFG, ScipyOptimizer -__all__ = [ - "SCP", - "ScipyCFG", - "Scipy", - "ScipyOptimizer" -] +__all__ = ["SCP", "ScipyCFG", "Scipy", "ScipyOptimizer"] From 0934ca8a8f803622965a594b850ddbf18370607f Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 14 May 2026 18:42:28 -0400 Subject: [PATCH 09/43] scipy opt best_points and axis rescaling --- src/blop/gradient/Scipy.py | 109 ++++++++++++++++++++++++++----------- 1 file changed, 78 insertions(+), 31 deletions(-) diff --git a/src/blop/gradient/Scipy.py b/src/blop/gradient/Scipy.py index 7d40c306..8909e66f 100644 --- a/src/blop/gradient/Scipy.py +++ b/src/blop/gradient/Scipy.py @@ -1,12 +1,13 @@ -from collections.abc import Sequence +from collections.abc import Mapping, Sequence from dataclasses import dataclass from enum import Enum from threading import Event, Thread from typing import Any, cast import bluesky.preprocessors as bpp +import numpy as np from bluesky.callbacks import CallbackBase -from scipy.optimize import Bounds, OptimizeResult, dual_annealing, minimize +from scipy.optimize import OptimizeResult, dual_annealing, minimize from blop.ax.dof import RangeDOF from blop.callbacks.logger import OptimizationLogger @@ -14,7 +15,15 @@ from blop.plans import optimize from blop.utils import InferredReadable -from ..protocols import ID_KEY, AcquisitionPlan, Actuator, EvaluationFunction, OptimizationProblem, Optimizer, Sensor +from ..protocols import ( + ID_KEY, + AcquisitionPlan, + Actuator, + EvaluationFunction, + OptimizationProblem, + Optimizer, + Sensor, +) class SCP(str, Enum): @@ -29,6 +38,7 @@ class ScipyCFG: # outcome_constraints: Sequence[OutcomeConstraint] | None = None optimizer: str = "Default" initial: Sequence[float] | None = None + rescale: Sequence[float] | float | None = None max_iter: int | None = 100 eps: float | None = None @@ -73,7 +83,12 @@ def Agent( if optimizer not in SCP: raise ValueError(f"optimizer {optimizer} not in supported optimizers:{list(SCP)}") - config = ScipyCFG(dofs=dofs, optimizer=optimizer, max_iter=kwargs.get("max_iter", None), eps=kwargs.get("eps", None)) + config = ScipyCFG( + dofs=dofs, + optimizer=optimizer, + max_iter=kwargs.get("max_iter", None), + eps=kwargs.get("eps", None), + ) return cls(sensors, config, evaluation_function, acquisition_plan) @property @@ -123,8 +138,13 @@ def to_optimization_problem(self) -> OptimizationProblem: ) def optimize(self, iterations=10): - self._optimizer = ScipyOptimizer(self._config) - optimize_plan = optimize(self.to_optimization_problem(), iterations=iterations, readable_cache=self._readable_cache) + if self._optimizer.final is not None: + self._optimizer = ScipyOptimizer(self._config) + optimize_plan = optimize( + self.to_optimization_problem(), + iterations=iterations, + readable_cache=self._readable_cache, + ) if self._callbacks: optimize_plan = bpp.subs_wrapper( @@ -142,24 +162,28 @@ class ScipyOptimizer(Optimizer): def __init__(self, config: ScipyCFG): self._semaphore = Event() - self._params = [] - self._bounds = [] - self._increment = 0 + self._params: list[str] = [] + self._bounds: list[tuple[Any, Any]] = [] + self._increment: int = 0 + self._objective = None self._y = None - self.intermediate = None - self.final = None + self.intermediate: OptimizeResult | None = None + self.final: OptimizeResult | None = None self.force_resiliance = False - midp = [] - - for dof in config.dofs: + self._scale = np.ones(len(config.dofs)) + if config.rescale is not None: + if isinstance(config.rescale, list): + self._scale = config.rescale + else: + self._scale *= config.rescale + + for ind, dof in enumerate(config.dofs): self._params.append(dof.parameter_name) - self._bounds.append(Bounds(lb=dof.bounds[0], ub=dof.bounds[1])) - midp.append(0.5 * dof.bounds[0] + 0.5 * dof.bounds[1]) + self._bounds.append(tuple(np.array(dof.bounds) / self._scale[ind])) + self._x = np.mean(self._bounds, axis=1) if config.initial is not None: - self._x = config.initial - else: - self._x = midp + self._x = np.array(config.initial) / self._scale def cost(x): self._x = x @@ -181,12 +205,22 @@ def optim_callback(intermediate_result: OptimizeResult): if config.optimizer in (SCP.Default): def mini_worker(): - self.final = minimize(fun=cost, x0=self._x, bounds=self._bounds, callback=optim_callback, options=kw) + self.final = minimize( + fun=cost, + x0=self._x, + bounds=self._bounds, + callback=optim_callback, + options=kw, + ) elif config.optimizer in (SCP.Dual_Annealing): def mini_worker(): self.final = dual_annealing( - func=cost, x0=self._x, bounds=self._bounds, callback=optim_callback, minimizer_kwargs=kw + func=cost, + x0=self._x, + bounds=self._bounds, + callback=optim_callback, + minimizer_kwargs=kw, ) else: raise NotImplementedError("") @@ -195,11 +229,20 @@ def mini_worker(): self.t = Thread(target=mini_worker, name="optimizer") self.t.start() - def optimum(self): + def get_best_points(self) -> list[tuple[Any, Mapping, Mapping]]: + result = self.intermediate if self.final is not None: - return self.final - - return self.intermediate + result = self.final + if (result is None) or (self._objective is None): + raise ValueError("no optimization epoch has been recorded") + + vector = [x_n * s for s, x_n in zip(self._scale, result.x, strict=True)] + cart = [ + result.nit - 1, + cast(Mapping, dict(zip(self._params, vector, strict=True))), + cast(Mapping, {self._objective: result.fun}), + ] + return cart def suggest(self, num_points: int | None = None) -> list[dict]: """ @@ -219,10 +262,13 @@ def suggest(self, num_points: int | None = None) -> list[dict]: A list of dictionaries, each containing a parameterization of a point to evaluate next. Each dictionary must contain a unique "_id" key to identify each parameterization. """ - if self.final is None: - suggestion = dict(zip(self._params, self._x, strict=True)) - else: - suggestion = dict(zip(self._params, self.final.x, strict=True)) + vector = [x_n * s for s, x_n in zip(self._scale, self._x, strict=True)] + if self.final is not None: + vector = [x_n * s for s, x_n in zip(self._scale, self.final.x, strict=True)] + + print("sample:", self._x, " rescaled to:", vector) + + suggestion = dict(zip(self._params, vector, strict=True)) suggestion[ID_KEY] = self._increment self._increment += 1 return [suggestion] @@ -241,6 +287,7 @@ def ingest(self, points: list[dict]) -> None: if self._semaphore.is_set() and not self.force_resiliance: raise ValueError("optimizer did not expect to receive an update") res = points[0] - re_val = [res[param] for param in res if param not in (*self._params, ID_KEY)] - self._y = re_val[0] + if self._objective is None: + self._objective = [param for param in res if param not in (*self._params, ID_KEY)][0] + self._y = res[self._objective] self._semaphore.set() From cf36fb750757898a9f587997f7a17847a772ac63 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 14 May 2026 18:44:46 -0400 Subject: [PATCH 10/43] cleaning of print statements --- src/blop/gradient/Scipy.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/blop/gradient/Scipy.py b/src/blop/gradient/Scipy.py index 8909e66f..6d056990 100644 --- a/src/blop/gradient/Scipy.py +++ b/src/blop/gradient/Scipy.py @@ -171,6 +171,7 @@ def __init__(self, config: ScipyCFG): self.final: OptimizeResult | None = None self.force_resiliance = False self._scale = np.ones(len(config.dofs)) + if config.rescale is not None: if isinstance(config.rescale, list): self._scale = config.rescale @@ -225,7 +226,6 @@ def mini_worker(): else: raise NotImplementedError("") - # self.t = Thread(target=minimize, args=(cost, self._x), kwargs=kw, name="optimizer") self.t = Thread(target=mini_worker, name="optimizer") self.t.start() @@ -266,8 +266,6 @@ def suggest(self, num_points: int | None = None) -> list[dict]: if self.final is not None: vector = [x_n * s for s, x_n in zip(self._scale, self.final.x, strict=True)] - print("sample:", self._x, " rescaled to:", vector) - suggestion = dict(zip(self._params, vector, strict=True)) suggestion[ID_KEY] = self._increment self._increment += 1 From d2df4d4c7c4762f8e0e24ca45d111964948e19bf Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Fri, 15 May 2026 10:28:01 -0400 Subject: [PATCH 11/43] fixes to dual annealing --- src/blop/gradient/Scipy.py | 34 +++++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/src/blop/gradient/Scipy.py b/src/blop/gradient/Scipy.py index 6d056990..630586b4 100644 --- a/src/blop/gradient/Scipy.py +++ b/src/blop/gradient/Scipy.py @@ -88,6 +88,7 @@ def Agent( optimizer=optimizer, max_iter=kwargs.get("max_iter", None), eps=kwargs.get("eps", None), + rescale=kwargs.get("scale", None), ) return cls(sensors, config, evaluation_function, acquisition_plan) @@ -167,11 +168,19 @@ def __init__(self, config: ScipyCFG): self._increment: int = 0 self._objective = None self._y = None - self.intermediate: OptimizeResult | None = None - self.final: OptimizeResult | None = None self.force_resiliance = False self._scale = np.ones(len(config.dofs)) + @dataclass + class Result: + x: list + fun: float + nit: int + status: int = 2 + + self.intermediate: OptimizeResult | Result | None = None + self.final: OptimizeResult | Result | None = None + if config.rescale is not None: if isinstance(config.rescale, list): self._scale = config.rescale @@ -194,33 +203,36 @@ def cost(x): raise ValueError("return value is not present") return self._y - def optim_callback(intermediate_result: OptimizeResult): - self.intermediate = intermediate_result - kw = {} - if config.eps is not None: - kw["eps"] = config.eps - if config.max_iter is not None: - kw["max_iter"] = config.max_iter if config.optimizer in (SCP.Default): + if config.max_iter is not None: + kw["max_iter"] = config.max_iter + if config.eps is not None: + kw["eps"] = config.eps + + def default_callback(intermediate_result: OptimizeResult): + self.intermediate = intermediate_result def mini_worker(): self.final = minimize( fun=cost, x0=self._x, bounds=self._bounds, - callback=optim_callback, + callback=default_callback, options=kw, ) elif config.optimizer in (SCP.Dual_Annealing): + def dual_callback(x, f, context): + self.intermediate = Result(x, f, self._increment, context) + def mini_worker(): self.final = dual_annealing( func=cost, x0=self._x, bounds=self._bounds, - callback=optim_callback, + callback=dual_callback, minimizer_kwargs=kw, ) else: From 0e58f1fa3d09f8a5f5a5cfa94fa19ed3bd994050 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Mon, 22 Jun 2026 14:22:08 -0400 Subject: [PATCH 12/43] updates to allow multi threaded optimizer sampling, more to come --- src/blop/gradient/Scipy.py | 229 +++++++++++++++++++++++++++---------- 1 file changed, 170 insertions(+), 59 deletions(-) diff --git a/src/blop/gradient/Scipy.py b/src/blop/gradient/Scipy.py index 630586b4..bc2ca11d 100644 --- a/src/blop/gradient/Scipy.py +++ b/src/blop/gradient/Scipy.py @@ -1,7 +1,9 @@ +from collections import OrderedDict from collections.abc import Mapping, Sequence +from concurrent.futures import Future from dataclasses import dataclass -from enum import Enum -from threading import Event, Thread +from enum import StrEnum +from threading import Thread from typing import Any, cast import bluesky.preprocessors as bpp @@ -10,6 +12,7 @@ from scipy.optimize import OptimizeResult, dual_annealing, minimize from blop.ax.dof import RangeDOF +from blop.ax.objective import Objective from blop.callbacks.logger import OptimizationLogger from blop.callbacks.router import OptimizationCallbackRouter from blop.plans import optimize @@ -26,7 +29,7 @@ ) -class SCP(str, Enum): +class SCP(StrEnum): Default = "Default" Dual_Annealing = "dual annealing" @@ -34,9 +37,10 @@ class SCP(str, Enum): @dataclass class ScipyCFG: dofs: Sequence[RangeDOF] + objective: Objective # dof_constraints: Sequence[DOFConstraint] | None = None # outcome_constraints: Sequence[OutcomeConstraint] | None = None - optimizer: str = "Default" + optimizer: SCP = SCP.Default initial: Sequence[float] | None = None rescale: Sequence[float] | float | None = None max_iter: int | None = 100 @@ -72,19 +76,51 @@ def Agent( cls, sensors: Sequence[Sensor], dofs: Sequence[RangeDOF], + objectives: Sequence[Objective], evaluation_function: EvaluationFunction, acquisition_plan: AcquisitionPlan | None = None, - optimizer: SCP | str = SCP.Default, + optimizer: SCP = SCP.Default, # dof_constraints: Sequence[DOFConstraint] | None = None, #implemented in future iterations? make to match ax? # outcome_constraints: Sequence[OutcomeConstraint] | None = None, **kwargs: Any, ): + ''' + A nearly emcompassing interface to provide strong interoperability with Ax agent formalism. + + Parameters + ---------- + sensors : Sequence[Sensor] + The sensors to use for acquisition. These should be the minimal set + of sensors that are needed to compute the objectives. + dofs : Sequence[DOF] + The degrees of freedom that the agent can control, which determine the search space. + objectives : Sequence[Objective] + The objectives which the agent will try to optimize. + evaluation_function : EvaluationFunction + The function to evaluate acquired data and produce outcomes. + acquisition_plan : AcquisitionPlan | None, optional + The acquisition plan to use for acquiring data from the beamline. If not provided, + :func:`blop.plans.default_acquire` will be used. + **kwargs : Any + Additional keyword arguments to configure the Ax experiment. + + Notes + ----- + This is a nearly drop in replacement for Ax agent sans dof + outcome constraints and checkpointing + + See Also + -------- + blop.ax.Agent + + ''' if optimizer not in SCP: raise ValueError(f"optimizer {optimizer} not in supported optimizers:{list(SCP)}") - + if len(objectives) > 0: + raise ValueError("Multiple Objectives are not supported for gradient optimizers") config = ScipyCFG( dofs=dofs, + objective=objectives[0], optimizer=optimizer, max_iter=kwargs.get("max_iter", None), eps=kwargs.get("eps", None), @@ -112,6 +148,51 @@ def acquisition_plan(self) -> AcquisitionPlan | None: """The acquisition plan for acquiring data, or ``None`` if using the default.""" return self._acquisition_plan + @property + def callbacks(self) -> list[CallbackBase]: + """The list of active optimization callbacks. + + Callbacks in this list receive documents from ``"optimize"`` and + ``"sample_suggestions"`` runs. The default list contains an + :class:`~blop.callbacks.logger.OptimizationLogger`. + + The list can be mutated directly, or use :meth:`subscribe` / + :meth:`unsubscribe` for convenience. + """ + return self._callbacks + + def subscribe(self, callback: CallbackBase) -> None: + """Subscribe a callback to receive optimization run documents. + + Parameters + ---------- + callback : CallbackBase + A Bluesky callback instance. + + Raises + ------ + ValueError + If *callback* is already subscribed. + """ + if callback in self._callbacks: + raise ValueError(f"Callback {callback!r} is already subscribed.") + self._callbacks.append(callback) + + def unsubscribe(self, callback: CallbackBase) -> None: + """Unsubscribe a previously subscribed callback. + + Parameters + ---------- + callback : CallbackBase + The callback instance to remove. + + Raises + ------ + ValueError + If *callback* is not subscribed. + """ + self._callbacks.remove(callback) + def to_optimization_problem(self) -> OptimizationProblem: """ Construct an optimization problem from the Scipy Base class @@ -160,26 +241,28 @@ class ScipyOptimizer(Optimizer): """ An optimizer object to supply an interactive interface for the scipy optimizers, with some caveats. """ + @dataclass + class Request: + args: tuple + future: Future + + @dataclass + class Result: + x: list + fun: float + nit: int + status: int = 2 def __init__(self, config: ScipyCFG): - self._semaphore = Event() self._params: list[str] = [] self._bounds: list[tuple[Any, Any]] = [] self._increment: int = 0 - self._objective = None - self._y = None - self.force_resiliance = False + self._objective: Objective = config.objective + self.force_resiliance = False # kinda hidden for now self._scale = np.ones(len(config.dofs)) - - @dataclass - class Result: - x: list - fun: float - nit: int - status: int = 2 - - self.intermediate: OptimizeResult | Result | None = None - self.final: OptimizeResult | Result | None = None + self._active: dict[int, ScipyOptimizer.Request] = OrderedDict() + self.intermediate: OptimizeResult | ScipyOptimizer.Result | None = None + self.final: OptimizeResult | ScipyOptimizer.Result | None = None if config.rescale is not None: if isinstance(config.rescale, list): @@ -191,17 +274,21 @@ class Result: self._params.append(dof.parameter_name) self._bounds.append(tuple(np.array(dof.bounds) / self._scale[ind])) - self._x = np.mean(self._bounds, axis=1) + _x = np.mean(self._bounds, axis=1) if config.initial is not None: - self._x = np.array(config.initial) / self._scale + _x = np.array(config.initial) / self._scale def cost(x): - self._x = x - self._semaphore.clear() - self._semaphore.wait() - if self._y is None: + ''' + simple cooperative thread that defers evaluation of cost call by scipy to the run engine + ''' + req = self.Request(args=x, future=Future()) + self._active[self._increment] = req + self._increment += 1 + res = req.future.result() + if res is None: raise ValueError("return value is not present") - return self._y + return res kw = {} @@ -217,7 +304,7 @@ def default_callback(intermediate_result: OptimizeResult): def mini_worker(): self.final = minimize( fun=cost, - x0=self._x, + x0=_x, bounds=self._bounds, callback=default_callback, options=kw, @@ -225,12 +312,12 @@ def mini_worker(): elif config.optimizer in (SCP.Dual_Annealing): def dual_callback(x, f, context): - self.intermediate = Result(x, f, self._increment, context) + self.intermediate = self.Result(x, f, self._increment, context) def mini_worker(): self.final = dual_annealing( func=cost, - x0=self._x, + x0=_x, bounds=self._bounds, callback=dual_callback, minimizer_kwargs=kw, @@ -238,23 +325,8 @@ def mini_worker(): else: raise NotImplementedError("") - self.t = Thread(target=mini_worker, name="optimizer") - self.t.start() - - def get_best_points(self) -> list[tuple[Any, Mapping, Mapping]]: - result = self.intermediate - if self.final is not None: - result = self.final - if (result is None) or (self._objective is None): - raise ValueError("no optimization epoch has been recorded") - - vector = [x_n * s for s, x_n in zip(self._scale, result.x, strict=True)] - cart = [ - result.nit - 1, - cast(Mapping, dict(zip(self._params, vector, strict=True))), - cast(Mapping, {self._objective: result.fun}), - ] - return cart + self._t = Thread(target=mini_worker, name="optimizer") + self._t.start() def suggest(self, num_points: int | None = None) -> list[dict]: """ @@ -274,14 +346,21 @@ def suggest(self, num_points: int | None = None) -> list[dict]: A list of dictionaries, each containing a parameterization of a point to evaluate next. Each dictionary must contain a unique "_id" key to identify each parameterization. """ - vector = [x_n * s for s, x_n in zip(self._scale, self._x, strict=True)] if self.final is not None: vector = [x_n * s for s, x_n in zip(self._scale, self.final.x, strict=True)] + suggestion = dict(zip(self._params, vector, strict=True)) + suggestion[ID_KEY] = self.final.nit + return [suggestion] - suggestion = dict(zip(self._params, vector, strict=True)) - suggestion[ID_KEY] = self._increment - self._increment += 1 - return [suggestion] + suggestions = [] + for id in list(self._active.keys())[:num_points if num_points is not None else 1]: + x = self._active[id].args + vector = [x_n * s for s, x_n in zip(self._scale, x, strict=True)] + + suggestion = dict(zip(self._params, vector, strict=True)) + suggestion[ID_KEY] = id + suggestions.append(suggestion) + return suggestions def ingest(self, points: list[dict]) -> None: """ @@ -294,10 +373,42 @@ def ingest(self, points: list[dict]) -> None: points : list[dict] A list of dictionaries, each containing the outcomes of each suggested parameterization. """ - if self._semaphore.is_set() and not self.force_resiliance: - raise ValueError("optimizer did not expect to receive an update") - res = points[0] - if self._objective is None: - self._objective = [param for param in res if param not in (*self._params, ID_KEY)][0] - self._y = res[self._objective] - self._semaphore.set() + for res in points: + if self._objective is None: + self._objective = [param for param in res if param not in (*self._params, ID_KEY)][0] + y = res[self._objective] + if (res[ID_KEY] not in self._active): + if not self.force_resiliance: + raise ValueError("optimizer did not expect to receive an update") + continue + self._active.pop(res[ID_KEY]).future.set_result(y) + + def get_best_points(self) -> list[tuple[Any, Mapping, Mapping]]: + """ + Get a list of the optimal point found during optimization. + + Returns + ------- + list[tuple[int, TParameterization, TOutcome]] + Each element in the list is a tuple of: + - trial index (int) + - parameter values (dict) + - metric values (dict, where values may be (value, sem) tuples) + + See Also + -------- + navigate_to_best : Plan stub to move actuators to a best point. + """ + result = self.intermediate + if self.final is not None: + result = self.final + if (result is None) or (self._objective is None): + raise ValueError("no optimization epoch has been recorded") + + vector = [x_n * s for s, x_n in zip(self._scale, result.x, strict=True)] + cart = [ + result.nit - 1, + cast(Mapping, dict(zip(self._params, vector, strict=True))), + cast(Mapping, {self._objective: result.fun}), + ] + return cart From f212b242487aac4b19a2ab95d9c762f7a8a5ab4c Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Mon, 22 Jun 2026 15:00:01 -0400 Subject: [PATCH 13/43] initial test implementations --- src/blop/gradient/Scipy.py | 46 +++++++++ src/blop/tests/gradient/test_scipy.py | 131 ++++++++++++++++++++++++++ 2 files changed, 177 insertions(+) create mode 100644 src/blop/tests/gradient/test_scipy.py diff --git a/src/blop/gradient/Scipy.py b/src/blop/gradient/Scipy.py index bc2ca11d..da084ef8 100644 --- a/src/blop/gradient/Scipy.py +++ b/src/blop/gradient/Scipy.py @@ -59,6 +59,7 @@ def __init__( config: ScipyCFG, evaluation_function: EvaluationFunction, acquisition_plan: AcquisitionPlan | None = None, + **kwargs: Any, ): self._config = config @@ -219,6 +220,51 @@ def to_optimization_problem(self) -> OptimizationProblem: acquisition_plan=self._acquisition_plan, ) + def suggest(self, num_points: int = 1) -> list[dict]: + """ + Get the next point(s) to evaluate in the search space. + + Uses the Bayesian optimization algorithm to suggest promising points based + on all previously acquired data. Each suggestion includes an "_id" key for + tracking. + + Parameters + ---------- + num_points : int, optional + The number of points to suggest. Default is 1. Higher values enable + batch optimization but may reduce optimization efficiency per iteration. + + Returns + ------- + list[dict] + A list of dictionaries, each containing a parameterization of a point to + evaluate next. Each dictionary includes an "_id" key for identification. + """ + return self._optimizer.suggest(num_points) + + def ingest(self, points: list[dict]) -> None: + """ + Ingest evaluation results into the optimizer. + + Updates the optimizer's model with new data. Can ingest both suggested points + (with "_id" key) and external data (without "_id" key). + + Parameters + ---------- + points : list[dict] + A list of dictionaries, each containing outcomes for a trial. For suggested + points, include the "_id" key. For external data, include DOF names and + objective values, and omit "_id". + + Notes + ----- + This method is typically called automatically by :meth:`optimize`. Manual usage + is only needed for custom workflows or when ingesting external data. + + For complete examples, see :doc:`/how-to-guides/attach-data-to-experiments`. + """ + self._optimizer.ingest(points) + def optimize(self, iterations=10): if self._optimizer.final is not None: self._optimizer = ScipyOptimizer(self._config) diff --git a/src/blop/tests/gradient/test_scipy.py b/src/blop/tests/gradient/test_scipy.py new file mode 100644 index 00000000..e0ef9d4c --- /dev/null +++ b/src/blop/tests/gradient/test_scipy.py @@ -0,0 +1,131 @@ +from unittest.mock import MagicMock, patch + +import pytest + +import blop.gradient.Scipy as scp +from blop.ax.dof import RangeDOF +from blop.ax.objective import Objective +from blop.protocols import AcquisitionPlan, EvaluationFunction + +from ..conftest import MovableSignal, ReadableSignal + + +@pytest.fixture(scope="function") +def mock_evaluation_function(): + return MagicMock(spec=EvaluationFunction) + + +@pytest.fixture(scope="function") +def mock_acquisition_plan(): + return MagicMock(spec=AcquisitionPlan) + + +@pytest.fixture(scope="function") +def agent_prep(): + movable1 = MovableSignal(name="test_movable1") + movable2 = MovableSignal(name="test_movable2") + readable = ReadableSignal(name="test_readable") + dof1 = RangeDOF(actuator=movable1, bounds=(0, 10), parameter_type="float") + dof2 = RangeDOF(actuator=movable2, bounds=(0, 10), parameter_type="float") + objective = Objective(name="test_objective", minimize=False) + config = scp.ScipyCFG( + dofs=[dof1, dof2], + objective=objective, + ) + agent = scp.Scipy( + sensors=[readable], + config=config, + evaluation_function=mock_evaluation_function, + acquisition_plan=mock_acquisition_plan, + name="test_experiment", + ) + return agent + + +def test_general_init(mock_evaluation_function, mock_acquisition_plan): + """Test that the simple Scipy can be initialized.""" + movable1 = MovableSignal(name="test_movable1") + movable2 = MovableSignal(name="test_movable2") + readable = ReadableSignal(name="test_readable") + dof1 = RangeDOF(actuator=movable1, bounds=(0, 10), parameter_type="float") + dof2 = RangeDOF(actuator=movable2, bounds=(0, 10), parameter_type="float") + objective = Objective(name="test_objective", minimize=False) + config = scp.ScipyCFG( + dofs=[dof1, dof2], + objective=objective, + ) + agent = scp.Scipy( + sensors=[readable], + config=config, + evaluation_function=mock_evaluation_function, + acquisition_plan=mock_acquisition_plan, + name="test_experiment", + ) + assert agent.sensors == [readable] + assert agent.actuators == [dof1.actuator, dof2.actuator] + assert agent.evaluation_function == mock_evaluation_function + assert agent.acquisition_plan == mock_acquisition_plan + + +def test_agent_init(mock_evaluation_function, mock_acquisition_plan): + """Test that the agent can be initialized.""" + movable1 = MovableSignal(name="test_movable1") + movable2 = MovableSignal(name="test_movable2") + readable = ReadableSignal(name="test_readable") + dof1 = RangeDOF(actuator=movable1, bounds=(0, 10), parameter_type="float") + dof2 = RangeDOF(actuator=movable2, bounds=(0, 10), parameter_type="float") + objective = Objective(name="test_objective", minimize=False) + agent = scp.Scipy.Agent( + sensors=[readable], + dofs=[dof1, dof2], + objectives=[objective], + evaluation_function=mock_evaluation_function, + acquisition_plan=mock_acquisition_plan, + name="test_experiment", + ) + assert agent.sensors == [readable] + assert agent.actuators == [dof1.actuator, dof2.actuator] + assert agent.evaluation_function == mock_evaluation_function + assert agent.acquisition_plan == mock_acquisition_plan + + +def test_agent_to_optimization_problem(mock_evaluation_function): + """Test that the agent can be converted to an optimization problem.""" + movable1 = MovableSignal(name="test_movable1") + movable2 = MovableSignal(name="test_movable2") + dof1 = RangeDOF(actuator=movable1, bounds=(0, 10), parameter_type="float") + dof2 = RangeDOF(actuator=movable2, bounds=(0, 10), parameter_type="float") + objective = Objective(name="test_objective", minimize=False) + agent = scp.Scipy.Agent( + sensors=[], + dofs=[dof1, dof2], + objectives=[objective], + evaluation_function=mock_evaluation_function, + ) + optimization_problem = agent.to_optimization_problem() + assert optimization_problem.evaluation_function == mock_evaluation_function + assert optimization_problem.actuators == [movable1, movable2] + assert optimization_problem.sensors == [] + assert isinstance(optimization_problem.optimizer, scp.ScipyOptimizer) + assert optimization_problem.acquisition_plan is None + + +def test_agent_suggest(agent_prep): + parameterizations = agent_prep.suggest(1) + assert len(parameterizations) == 1 + assert parameterizations[0]["_id"] == 0 + assert "test_movable1" in parameterizations[0] + assert "test_movable2" in parameterizations[0] + assert isinstance(parameterizations[0]["test_movable1"], (int, float)) + assert isinstance(parameterizations[0]["test_movable2"], (int, float)) + + +def test_agent_ingest(mock_evaluation_function): + movable1 = MovableSignal(name="test_movable1") + movable2 = MovableSignal(name="test_movable2") + dof1 = RangeDOF(actuator=movable1, bounds=(0, 10), parameter_type="float") + dof2 = RangeDOF(actuator=movable2, bounds=(0, 10), parameter_type="float") + objective = Objective(name="test_objective", minimize=False) + agent = scp.Scipy.Agent(sensors=[], dofs=[dof1, dof2], objectives=[objective], evaluation_function=mock_evaluation_function) + + agent.ingest([{"test_movable1": 0.1, "test_movable2": 0.2, "test_objective": 0.3}]) From 99bfbad57ddeda76e02ca6304b04740d04d53be6 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Mon, 22 Jun 2026 15:05:09 -0400 Subject: [PATCH 14/43] ruff fixes --- src/blop/gradient/Scipy.py | 15 ++++++++------- src/blop/tests/gradient/test_scipy.py | 6 ++++-- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/blop/gradient/Scipy.py b/src/blop/gradient/Scipy.py index da084ef8..ddca5e5f 100644 --- a/src/blop/gradient/Scipy.py +++ b/src/blop/gradient/Scipy.py @@ -85,7 +85,7 @@ def Agent( # outcome_constraints: Sequence[OutcomeConstraint] | None = None, **kwargs: Any, ): - ''' + """ A nearly emcompassing interface to provide strong interoperability with Ax agent formalism. Parameters @@ -113,7 +113,7 @@ def Agent( -------- blop.ax.Agent - ''' + """ if optimizer not in SCP: raise ValueError(f"optimizer {optimizer} not in supported optimizers:{list(SCP)}") @@ -287,6 +287,7 @@ class ScipyOptimizer(Optimizer): """ An optimizer object to supply an interactive interface for the scipy optimizers, with some caveats. """ + @dataclass class Request: args: tuple @@ -325,9 +326,9 @@ def __init__(self, config: ScipyCFG): _x = np.array(config.initial) / self._scale def cost(x): - ''' - simple cooperative thread that defers evaluation of cost call by scipy to the run engine - ''' + """ + simple cooperative thread that defers evaluation of cost call by scipy to the run engine + """ req = self.Request(args=x, future=Future()) self._active[self._increment] = req self._increment += 1 @@ -399,7 +400,7 @@ def suggest(self, num_points: int | None = None) -> list[dict]: return [suggestion] suggestions = [] - for id in list(self._active.keys())[:num_points if num_points is not None else 1]: + for id in list(self._active.keys())[: num_points if num_points is not None else 1]: x = self._active[id].args vector = [x_n * s for s, x_n in zip(self._scale, x, strict=True)] @@ -423,7 +424,7 @@ def ingest(self, points: list[dict]) -> None: if self._objective is None: self._objective = [param for param in res if param not in (*self._params, ID_KEY)][0] y = res[self._objective] - if (res[ID_KEY] not in self._active): + if res[ID_KEY] not in self._active: if not self.force_resiliance: raise ValueError("optimizer did not expect to receive an update") continue diff --git a/src/blop/tests/gradient/test_scipy.py b/src/blop/tests/gradient/test_scipy.py index e0ef9d4c..3b564fd5 100644 --- a/src/blop/tests/gradient/test_scipy.py +++ b/src/blop/tests/gradient/test_scipy.py @@ -1,4 +1,4 @@ -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock import pytest @@ -126,6 +126,8 @@ def test_agent_ingest(mock_evaluation_function): dof1 = RangeDOF(actuator=movable1, bounds=(0, 10), parameter_type="float") dof2 = RangeDOF(actuator=movable2, bounds=(0, 10), parameter_type="float") objective = Objective(name="test_objective", minimize=False) - agent = scp.Scipy.Agent(sensors=[], dofs=[dof1, dof2], objectives=[objective], evaluation_function=mock_evaluation_function) + agent = scp.Scipy.Agent( + sensors=[], dofs=[dof1, dof2], objectives=[objective], evaluation_function=mock_evaluation_function + ) agent.ingest([{"test_movable1": 0.1, "test_movable2": 0.2, "test_objective": 0.3}]) From 453441236d705ef7772b5c797aeac35a117c9494 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Mon, 22 Jun 2026 15:20:52 -0400 Subject: [PATCH 15/43] first unit test fixes --- src/blop/gradient/Scipy.py | 2 +- src/blop/tests/gradient/__init__.py | 0 src/blop/tests/gradient/test_scipy.py | 18 +++++++++--------- 3 files changed, 10 insertions(+), 10 deletions(-) create mode 100644 src/blop/tests/gradient/__init__.py diff --git a/src/blop/gradient/Scipy.py b/src/blop/gradient/Scipy.py index ddca5e5f..5237df17 100644 --- a/src/blop/gradient/Scipy.py +++ b/src/blop/gradient/Scipy.py @@ -117,7 +117,7 @@ def Agent( if optimizer not in SCP: raise ValueError(f"optimizer {optimizer} not in supported optimizers:{list(SCP)}") - if len(objectives) > 0: + if len(objectives) > 1: raise ValueError("Multiple Objectives are not supported for gradient optimizers") config = ScipyCFG( dofs=dofs, diff --git a/src/blop/tests/gradient/__init__.py b/src/blop/tests/gradient/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/blop/tests/gradient/test_scipy.py b/src/blop/tests/gradient/test_scipy.py index 3b564fd5..ccd4f2e2 100644 --- a/src/blop/tests/gradient/test_scipy.py +++ b/src/blop/tests/gradient/test_scipy.py @@ -2,9 +2,9 @@ import pytest -import blop.gradient.Scipy as scp from blop.ax.dof import RangeDOF from blop.ax.objective import Objective +from blop.gradient.Scipy import SCP, Scipy, ScipyCFG, ScipyOptimizer from blop.protocols import AcquisitionPlan, EvaluationFunction from ..conftest import MovableSignal, ReadableSignal @@ -28,11 +28,11 @@ def agent_prep(): dof1 = RangeDOF(actuator=movable1, bounds=(0, 10), parameter_type="float") dof2 = RangeDOF(actuator=movable2, bounds=(0, 10), parameter_type="float") objective = Objective(name="test_objective", minimize=False) - config = scp.ScipyCFG( + config = ScipyCFG( dofs=[dof1, dof2], objective=objective, ) - agent = scp.Scipy( + agent = Scipy( sensors=[readable], config=config, evaluation_function=mock_evaluation_function, @@ -50,11 +50,11 @@ def test_general_init(mock_evaluation_function, mock_acquisition_plan): dof1 = RangeDOF(actuator=movable1, bounds=(0, 10), parameter_type="float") dof2 = RangeDOF(actuator=movable2, bounds=(0, 10), parameter_type="float") objective = Objective(name="test_objective", minimize=False) - config = scp.ScipyCFG( + config = ScipyCFG( dofs=[dof1, dof2], objective=objective, ) - agent = scp.Scipy( + agent = Scipy( sensors=[readable], config=config, evaluation_function=mock_evaluation_function, @@ -75,7 +75,7 @@ def test_agent_init(mock_evaluation_function, mock_acquisition_plan): dof1 = RangeDOF(actuator=movable1, bounds=(0, 10), parameter_type="float") dof2 = RangeDOF(actuator=movable2, bounds=(0, 10), parameter_type="float") objective = Objective(name="test_objective", minimize=False) - agent = scp.Scipy.Agent( + agent = Scipy.Agent( sensors=[readable], dofs=[dof1, dof2], objectives=[objective], @@ -96,7 +96,7 @@ def test_agent_to_optimization_problem(mock_evaluation_function): dof1 = RangeDOF(actuator=movable1, bounds=(0, 10), parameter_type="float") dof2 = RangeDOF(actuator=movable2, bounds=(0, 10), parameter_type="float") objective = Objective(name="test_objective", minimize=False) - agent = scp.Scipy.Agent( + agent = Scipy.Agent( sensors=[], dofs=[dof1, dof2], objectives=[objective], @@ -106,7 +106,7 @@ def test_agent_to_optimization_problem(mock_evaluation_function): assert optimization_problem.evaluation_function == mock_evaluation_function assert optimization_problem.actuators == [movable1, movable2] assert optimization_problem.sensors == [] - assert isinstance(optimization_problem.optimizer, scp.ScipyOptimizer) + assert isinstance(optimization_problem.optimizer, ScipyOptimizer) assert optimization_problem.acquisition_plan is None @@ -126,7 +126,7 @@ def test_agent_ingest(mock_evaluation_function): dof1 = RangeDOF(actuator=movable1, bounds=(0, 10), parameter_type="float") dof2 = RangeDOF(actuator=movable2, bounds=(0, 10), parameter_type="float") objective = Objective(name="test_objective", minimize=False) - agent = scp.Scipy.Agent( + agent = Scipy.Agent( sensors=[], dofs=[dof1, dof2], objectives=[objective], evaluation_function=mock_evaluation_function ) From 46652606cfc56f95a221107e65462cfc5b77ec99 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Mon, 22 Jun 2026 17:27:43 -0400 Subject: [PATCH 16/43] final test fixes for the day --- src/blop/gradient/Scipy.py | 4 +--- src/blop/tests/gradient/test_scipy.py | 4 ++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/blop/gradient/Scipy.py b/src/blop/gradient/Scipy.py index 5237df17..7bde7b33 100644 --- a/src/blop/gradient/Scipy.py +++ b/src/blop/gradient/Scipy.py @@ -421,9 +421,7 @@ def ingest(self, points: list[dict]) -> None: A list of dictionaries, each containing the outcomes of each suggested parameterization. """ for res in points: - if self._objective is None: - self._objective = [param for param in res if param not in (*self._params, ID_KEY)][0] - y = res[self._objective] + y = res[self._objective.name] if res[ID_KEY] not in self._active: if not self.force_resiliance: raise ValueError("optimizer did not expect to receive an update") diff --git a/src/blop/tests/gradient/test_scipy.py b/src/blop/tests/gradient/test_scipy.py index ccd4f2e2..67d3cabb 100644 --- a/src/blop/tests/gradient/test_scipy.py +++ b/src/blop/tests/gradient/test_scipy.py @@ -5,7 +5,7 @@ from blop.ax.dof import RangeDOF from blop.ax.objective import Objective from blop.gradient.Scipy import SCP, Scipy, ScipyCFG, ScipyOptimizer -from blop.protocols import AcquisitionPlan, EvaluationFunction +from blop.protocols import AcquisitionPlan, EvaluationFunction, ID_KEY from ..conftest import MovableSignal, ReadableSignal @@ -130,4 +130,4 @@ def test_agent_ingest(mock_evaluation_function): sensors=[], dofs=[dof1, dof2], objectives=[objective], evaluation_function=mock_evaluation_function ) - agent.ingest([{"test_movable1": 0.1, "test_movable2": 0.2, "test_objective": 0.3}]) + agent.ingest([{"test_movable1": 0.1, "test_movable2": 0.2, "test_objective": 0.3, ID_KEY: 0}]) From f35ba09e82441e15c36991165ab9b02bc328aae4 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Mon, 22 Jun 2026 17:30:27 -0400 Subject: [PATCH 17/43] ruff --- src/blop/tests/gradient/test_scipy.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/blop/tests/gradient/test_scipy.py b/src/blop/tests/gradient/test_scipy.py index 67d3cabb..35bbe170 100644 --- a/src/blop/tests/gradient/test_scipy.py +++ b/src/blop/tests/gradient/test_scipy.py @@ -4,8 +4,8 @@ from blop.ax.dof import RangeDOF from blop.ax.objective import Objective -from blop.gradient.Scipy import SCP, Scipy, ScipyCFG, ScipyOptimizer -from blop.protocols import AcquisitionPlan, EvaluationFunction, ID_KEY +from blop.gradient.Scipy import Scipy, ScipyCFG, ScipyOptimizer +from blop.protocols import ID_KEY, AcquisitionPlan, EvaluationFunction from ..conftest import MovableSignal, ReadableSignal @@ -126,8 +126,6 @@ def test_agent_ingest(mock_evaluation_function): dof1 = RangeDOF(actuator=movable1, bounds=(0, 10), parameter_type="float") dof2 = RangeDOF(actuator=movable2, bounds=(0, 10), parameter_type="float") objective = Objective(name="test_objective", minimize=False) - agent = Scipy.Agent( - sensors=[], dofs=[dof1, dof2], objectives=[objective], evaluation_function=mock_evaluation_function - ) + agent = Scipy.Agent(sensors=[], dofs=[dof1, dof2], objectives=[objective], evaluation_function=mock_evaluation_function) agent.ingest([{"test_movable1": 0.1, "test_movable2": 0.2, "test_objective": 0.3, ID_KEY: 0}]) From f4bb9cd87a91d375f8719e7e4090c12c26d2cb1b Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Wed, 24 Jun 2026 10:48:03 -0400 Subject: [PATCH 18/43] bug fixes and code seperation. Continuing work on multi sampling --- src/blop/gradient/Scipy.py | 223 ++--------------------- src/blop/gradient/__init__.py | 3 +- src/blop/gradient/optimizer.py | 249 ++++++++++++++++++++++++++ src/blop/tests/gradient/test_scipy.py | 30 +++- 4 files changed, 288 insertions(+), 217 deletions(-) create mode 100644 src/blop/gradient/optimizer.py diff --git a/src/blop/gradient/Scipy.py b/src/blop/gradient/Scipy.py index 7bde7b33..2af3a9d0 100644 --- a/src/blop/gradient/Scipy.py +++ b/src/blop/gradient/Scipy.py @@ -1,50 +1,24 @@ -from collections import OrderedDict -from collections.abc import Mapping, Sequence -from concurrent.futures import Future -from dataclasses import dataclass -from enum import StrEnum -from threading import Thread +from collections.abc import Sequence from typing import Any, cast import bluesky.preprocessors as bpp -import numpy as np from bluesky.callbacks import CallbackBase -from scipy.optimize import OptimizeResult, dual_annealing, minimize from blop.ax.dof import RangeDOF from blop.ax.objective import Objective from blop.callbacks.logger import OptimizationLogger from blop.callbacks.router import OptimizationCallbackRouter from blop.plans import optimize -from blop.utils import InferredReadable - -from ..protocols import ( - ID_KEY, +from blop.protocols import ( AcquisitionPlan, Actuator, EvaluationFunction, OptimizationProblem, - Optimizer, Sensor, ) +from blop.utils import InferredReadable - -class SCP(StrEnum): - Default = "Default" - Dual_Annealing = "dual annealing" - - -@dataclass -class ScipyCFG: - dofs: Sequence[RangeDOF] - objective: Objective - # dof_constraints: Sequence[DOFConstraint] | None = None - # outcome_constraints: Sequence[OutcomeConstraint] | None = None - optimizer: SCP = SCP.Default - initial: Sequence[float] | None = None - rescale: Sequence[float] | float | None = None - max_iter: int | None = 100 - eps: float | None = None +from .optimizer import SCP, ScipyCFG, ScipyOptimizer class Scipy: @@ -62,15 +36,16 @@ def __init__( **kwargs: Any, ): - self._config = config + self.config = config self._sensors = sensors self._actuators = [cast(Actuator, dof.actuator) for dof in config.dofs if dof.actuator is not None] self._evaluation_function = evaluation_function self._acquisition_plan = acquisition_plan - self._optimizer = ScipyOptimizer(self._config) + self._optimizer = ScipyOptimizer(self.config) self._readable_cache: dict[str, InferredReadable] = {} self._callbacks: list[CallbackBase] = [OptimizationLogger()] self._callback_router = OptimizationCallbackRouter(self._callbacks) + self.sessioning = kwargs.pop("sessioning", True) @classmethod def Agent( @@ -267,7 +242,8 @@ def ingest(self, points: list[dict]) -> None: def optimize(self, iterations=10): if self._optimizer.final is not None: - self._optimizer = ScipyOptimizer(self._config) + self.config.initial = self._optimizer.final.x + self._optimizer = ScipyOptimizer(self.config) optimize_plan = optimize( self.to_optimization_problem(), iterations=iterations, @@ -279,181 +255,8 @@ def optimize(self, iterations=10): optimize_plan, self._callback_router, ) - - yield from optimize_plan - - -class ScipyOptimizer(Optimizer): - """ - An optimizer object to supply an interactive interface for the scipy optimizers, with some caveats. - """ - - @dataclass - class Request: - args: tuple - future: Future - - @dataclass - class Result: - x: list - fun: float - nit: int - status: int = 2 - - def __init__(self, config: ScipyCFG): - self._params: list[str] = [] - self._bounds: list[tuple[Any, Any]] = [] - self._increment: int = 0 - self._objective: Objective = config.objective - self.force_resiliance = False # kinda hidden for now - self._scale = np.ones(len(config.dofs)) - self._active: dict[int, ScipyOptimizer.Request] = OrderedDict() - self.intermediate: OptimizeResult | ScipyOptimizer.Result | None = None - self.final: OptimizeResult | ScipyOptimizer.Result | None = None - - if config.rescale is not None: - if isinstance(config.rescale, list): - self._scale = config.rescale - else: - self._scale *= config.rescale - - for ind, dof in enumerate(config.dofs): - self._params.append(dof.parameter_name) - self._bounds.append(tuple(np.array(dof.bounds) / self._scale[ind])) - - _x = np.mean(self._bounds, axis=1) - if config.initial is not None: - _x = np.array(config.initial) / self._scale - - def cost(x): - """ - simple cooperative thread that defers evaluation of cost call by scipy to the run engine - """ - req = self.Request(args=x, future=Future()) - self._active[self._increment] = req - self._increment += 1 - res = req.future.result() - if res is None: - raise ValueError("return value is not present") - return res - - kw = {} - - if config.optimizer in (SCP.Default): - if config.max_iter is not None: - kw["max_iter"] = config.max_iter - if config.eps is not None: - kw["eps"] = config.eps - - def default_callback(intermediate_result: OptimizeResult): - self.intermediate = intermediate_result - - def mini_worker(): - self.final = minimize( - fun=cost, - x0=_x, - bounds=self._bounds, - callback=default_callback, - options=kw, - ) - elif config.optimizer in (SCP.Dual_Annealing): - - def dual_callback(x, f, context): - self.intermediate = self.Result(x, f, self._increment, context) - - def mini_worker(): - self.final = dual_annealing( - func=cost, - x0=_x, - bounds=self._bounds, - callback=dual_callback, - minimizer_kwargs=kw, - ) + if self.sessioning: + with self._optimizer: + yield from optimize_plan else: - raise NotImplementedError("") - - self._t = Thread(target=mini_worker, name="optimizer") - self._t.start() - - def suggest(self, num_points: int | None = None) -> list[dict]: - """ - Returns a set of points in the input space, to be evaulated next. - - The "_id" key is optional and can be used to identify suggested trials for later evaluation - and ingestion. - - Parameters - ---------- - num_points : int | None, optional - The number of points to suggest. If not provided, will default to 1. - - Returns - ------- - list[dict] - A list of dictionaries, each containing a parameterization of a point to evaluate next. - Each dictionary must contain a unique "_id" key to identify each parameterization. - """ - if self.final is not None: - vector = [x_n * s for s, x_n in zip(self._scale, self.final.x, strict=True)] - suggestion = dict(zip(self._params, vector, strict=True)) - suggestion[ID_KEY] = self.final.nit - return [suggestion] - - suggestions = [] - for id in list(self._active.keys())[: num_points if num_points is not None else 1]: - x = self._active[id].args - vector = [x_n * s for s, x_n in zip(self._scale, x, strict=True)] - - suggestion = dict(zip(self._params, vector, strict=True)) - suggestion[ID_KEY] = id - suggestions.append(suggestion) - return suggestions - - def ingest(self, points: list[dict]) -> None: - """ - Ingest a set of points into the experiment. Either from previously suggested points or from an external source. - - The "_id" key is optional. - - Parameters - ---------- - points : list[dict] - A list of dictionaries, each containing the outcomes of each suggested parameterization. - """ - for res in points: - y = res[self._objective.name] - if res[ID_KEY] not in self._active: - if not self.force_resiliance: - raise ValueError("optimizer did not expect to receive an update") - continue - self._active.pop(res[ID_KEY]).future.set_result(y) - - def get_best_points(self) -> list[tuple[Any, Mapping, Mapping]]: - """ - Get a list of the optimal point found during optimization. - - Returns - ------- - list[tuple[int, TParameterization, TOutcome]] - Each element in the list is a tuple of: - - trial index (int) - - parameter values (dict) - - metric values (dict, where values may be (value, sem) tuples) - - See Also - -------- - navigate_to_best : Plan stub to move actuators to a best point. - """ - result = self.intermediate - if self.final is not None: - result = self.final - if (result is None) or (self._objective is None): - raise ValueError("no optimization epoch has been recorded") - - vector = [x_n * s for s, x_n in zip(self._scale, result.x, strict=True)] - cart = [ - result.nit - 1, - cast(Mapping, dict(zip(self._params, vector, strict=True))), - cast(Mapping, {self._objective: result.fun}), - ] - return cart + yield from optimize_plan diff --git a/src/blop/gradient/__init__.py b/src/blop/gradient/__init__.py index a9aacd1a..df7d71dc 100644 --- a/src/blop/gradient/__init__.py +++ b/src/blop/gradient/__init__.py @@ -1,3 +1,4 @@ -from .Scipy import SCP, Scipy, ScipyCFG, ScipyOptimizer +from .optimizer import SCP, ScipyCFG, ScipyOptimizer +from .Scipy import Scipy __all__ = ["SCP", "ScipyCFG", "Scipy", "ScipyOptimizer"] diff --git a/src/blop/gradient/optimizer.py b/src/blop/gradient/optimizer.py new file mode 100644 index 00000000..9d320f3f --- /dev/null +++ b/src/blop/gradient/optimizer.py @@ -0,0 +1,249 @@ +from collections import OrderedDict +from collections.abc import Mapping, Sequence +from concurrent.futures import Future, ThreadPoolExecutor +from dataclasses import dataclass +from enum import StrEnum +from threading import Thread +from typing import Any, cast + +import numpy as np +from scipy.optimize import OptimizeResult, dual_annealing, minimize + +from blop.ax.dof import RangeDOF +from blop.ax.objective import Objective +from blop.protocols import ID_KEY, Optimizer + + +class SCP(StrEnum): + Default = "Default" + BFGS = "BFGS" + Dual_Annealing = "dual annealing" + + +@dataclass +class ScipyCFG: + dofs: Sequence[RangeDOF] + objective: Objective + # dof_constraints: Sequence[DOFConstraint] | None = None + # outcome_constraints: Sequence[OutcomeConstraint] | None = None + optimizer: SCP = SCP.Default + initial: Sequence[float] | None = None + rescale: Sequence[float] | float | None = None + max_iter: int | None = 100 + eps: float | None = None + threads: int | None = None + + +class ScipyOptimizer(Optimizer): + """ + An optimizer object to supply an interactive interface for the scipy optimizers, with some caveats. + """ + + @dataclass + class Request: + args: tuple + future: Future + + @dataclass + class Result: + x: list[float | int] + fun: float + nit: int + status: int = 2 + + def __init__(self, config: ScipyCFG): + self.session(config=config, timeout=200) + + def session(self, config: ScipyCFG, timeout: int | None = None): + self._params: list[str] = [] + self._bounds: list[tuple[Any, Any]] = [] + self._increment: int = 0 + self._objective: Objective = config.objective + self.force_resiliance = False # kinda hidden for now + self._scale = np.ones(len(config.dofs)) + self._active: dict[int, ScipyOptimizer.Request] = OrderedDict() + self.intermediate: OptimizeResult | ScipyOptimizer.Result | None = None + self.final: OptimizeResult | ScipyOptimizer.Result | None = None + self.SUGGESTION_TIMEOUT = timeout + + if config.rescale is not None: + if isinstance(config.rescale, list): + self._scale = config.rescale + else: + self._scale *= config.rescale + + for ind, dof in enumerate(config.dofs): + self._params.append(dof.parameter_name) + self._bounds.append(tuple(np.array(dof.bounds) / self._scale[ind])) + + _x = np.mean(self._bounds, axis=1) + if config.initial is not None: + _x = np.array(config.initial) / self._scale + + def cost(x): # thread safety needs timeout so there is not infinite hang on programs + """ + simple cooperative thread that defers evaluation of cost call by scipy to the run engine + """ + print("pushing to request queue") + req = self.Request(args=x, future=Future()) + self._active[self._increment] = req + self._increment += 1 + res = req.future.result(timeout=self.SUGGESTION_TIMEOUT) + print(f"recovered result {res}") + if res is None: + raise ValueError("return value is not present") + return res + + kw = {} + self._thread_pool = None + if config.optimizer in (SCP.Default, SCP.BFGS): + if config.max_iter is not None: + kw["max_iter"] = config.max_iter + if config.eps is not None: + kw["eps"] = config.eps + + def default_callback(intermediate_result: OptimizeResult): + self.intermediate = intermediate_result + + def call(kws=None): + self.final = minimize( + fun=cost, + x0=_x, + method=config.optimizer if config.optimizer != SCP.Default else None, + bounds=self._bounds, + callback=default_callback, + options=kws, + ) + + elif config.optimizer in (SCP.Dual_Annealing): + + def dual_callback(x, f, context): + self.intermediate = self.Result(x, f, self._increment, context) + + def call(kws=None): + self.final = dual_annealing( + func=cost, + x0=_x, + bounds=self._bounds, + callback=dual_callback, + minimizer_kwargs=kws, + ) + + else: + raise NotImplementedError("") + + def mini_worker(): + try: + if config.threads and config.optimizer in (SCP.Default): + with ThreadPoolExecutor(max_workers=config.threads) as pool: + kw["workers"] = pool.map + print(f"creating {config.threads} workers") + call(kws=kw) + else: + call(kws=kw) + except (KeyboardInterrupt, TimeoutError): + # have to have timeout, made it so that it can be restored to its state on agent auto reboot + if self.final: + return + if self.intermediate: + self.final = self.intermediate + else: + self.final = self.Result(list(_x), np.nan, nit=self._increment) + + self._t = Thread(target=mini_worker, name="optimizer") + self._t.start() + return self + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.close() + + def suggest(self, num_points: int | None = None) -> list[dict]: + """ + Returns a set of points in the input space, to be evaulated next. + + The "_id" key is optional and can be used to identify suggested trials for later evaluation + and ingestion. + + Parameters + ---------- + num_points : int | None, optional + The number of points to suggest. If not provided, will default to 1. + + Returns + ------- + list[dict] + A list of dictionaries, each containing a parameterization of a point to evaluate next. + Each dictionary must contain a unique "_id" key to identify each parameterization. + """ + if self.final is not None: + vector = [x_n * s for s, x_n in zip(self._scale, self.final.x, strict=True)] + suggestion = dict(zip(self._params, vector, strict=True)) + suggestion[ID_KEY] = self.final.nit + return [suggestion] + + suggestions = [] + for id in list(self._active.keys())[: num_points if num_points is not None else 1]: + x = self._active[id].args + vector = [x_n * s for s, x_n in zip(self._scale, x, strict=True)] + + suggestion = dict(zip(self._params, vector, strict=True)) + suggestion[ID_KEY] = id + suggestions.append(suggestion) + print(f"returning {len(suggestions)} suggestions of {len(self._active.keys())} available") + return suggestions + + def ingest(self, points: list[dict]) -> None: + """ + Ingest a set of points into the experiment. Either from previously suggested points or from an external source. + + The "_id" key is optional. + + Parameters + ---------- + points : list[dict] + A list of dictionaries, each containing the outcomes of each suggested parameterization. + """ + for res in points: + y = res[self._objective.name] + if res[ID_KEY] not in self._active: + if not self.force_resiliance: + raise ValueError("optimizer did not expect to receive an update") + continue + self._active.pop(res[ID_KEY]).future.set_result(y) + + def get_best_points(self) -> list[tuple[Any, Mapping, Mapping]]: + """ + Get a list of the optimal point found during optimization. + + Returns + ------- + list[tuple[int, TParameterization, TOutcome]] + Each element in the list is a tuple of: + - trial index (int) + - parameter values (dict) + - metric values (dict, where values may be (value, sem) tuples) + + See Also + -------- + navigate_to_best : Plan stub to move actuators to a best point. + """ + result = self.intermediate + if self.final is not None: + result = self.final + if (result is None) or (self._objective is None): + raise ValueError("no optimization epoch has been recorded") + + vector = [x_n * s for s, x_n in zip(self._scale, result.x, strict=True)] + cart = [ + result.nit - 1, + cast(Mapping, dict(zip(self._params, vector, strict=True))), + cast(Mapping, {self._objective: result.fun}), + ] + return cart + + def close(self): + for fut in self._active.values(): + fut.future.set_exception(KeyboardInterrupt("Execution has been suspended")) diff --git a/src/blop/tests/gradient/test_scipy.py b/src/blop/tests/gradient/test_scipy.py index 35bbe170..7474686c 100644 --- a/src/blop/tests/gradient/test_scipy.py +++ b/src/blop/tests/gradient/test_scipy.py @@ -2,9 +2,8 @@ import pytest -from blop.ax.dof import RangeDOF -from blop.ax.objective import Objective -from blop.gradient.Scipy import Scipy, ScipyCFG, ScipyOptimizer +from blop.ax import Objective, RangeDOF +from blop.gradient import SCP, Scipy, ScipyCFG, ScipyOptimizer from blop.protocols import ID_KEY, AcquisitionPlan, EvaluationFunction from ..conftest import MovableSignal, ReadableSignal @@ -120,12 +119,31 @@ def test_agent_suggest(agent_prep): assert isinstance(parameterizations[0]["test_movable2"], (int, float)) -def test_agent_ingest(mock_evaluation_function): +def test_agent_multithread(agent_prep): movable1 = MovableSignal(name="test_movable1") movable2 = MovableSignal(name="test_movable2") + readable = ReadableSignal(name="test_readable") dof1 = RangeDOF(actuator=movable1, bounds=(0, 10), parameter_type="float") dof2 = RangeDOF(actuator=movable2, bounds=(0, 10), parameter_type="float") objective = Objective(name="test_objective", minimize=False) - agent = Scipy.Agent(sensors=[], dofs=[dof1, dof2], objectives=[objective], evaluation_function=mock_evaluation_function) + config = ScipyCFG( + dofs=[dof1, dof2], + objective=objective, + optimizer=SCP.BFGS, + threads=4, + ) + agent = Scipy( + sensors=[readable], + config=config, + evaluation_function=mock_evaluation_function, + acquisition_plan=mock_acquisition_plan, + name="test_experiment", + ) + parameterizations = agent.suggest(4) + print([param.args for param in agent._optimizer._active.values()]) + assert len(parameterizations) == 4 + - agent.ingest([{"test_movable1": 0.1, "test_movable2": 0.2, "test_objective": 0.3, ID_KEY: 0}]) +def test_agent_ingest(agent_prep): + suggestions = agent_prep.suggest() + agent_prep.ingest([{"test_movable1": 0.1, "test_movable2": 0.2, "test_objective": 0.3, ID_KEY: 0}]) From e2366bf857058c7ef8a509580b48446e32e03e57 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Wed, 24 Jun 2026 14:08:44 -0400 Subject: [PATCH 19/43] tiny patch to test now working multithread and imrove thread closure times for testing --- src/blop/gradient/Scipy.py | 7 ++-- src/blop/gradient/optimizer.py | 10 +++--- src/blop/tests/gradient/test_scipy.py | 51 +++++++++++++-------------- 3 files changed, 34 insertions(+), 34 deletions(-) diff --git a/src/blop/gradient/Scipy.py b/src/blop/gradient/Scipy.py index 2af3a9d0..b6806a4a 100644 --- a/src/blop/gradient/Scipy.py +++ b/src/blop/gradient/Scipy.py @@ -41,7 +41,8 @@ def __init__( self._actuators = [cast(Actuator, dof.actuator) for dof in config.dofs if dof.actuator is not None] self._evaluation_function = evaluation_function self._acquisition_plan = acquisition_plan - self._optimizer = ScipyOptimizer(self.config) + self.timeout = kwargs.pop("timeout", 200) + self._optimizer = ScipyOptimizer(self.config, timeout=self.timeout) self._readable_cache: dict[str, InferredReadable] = {} self._callbacks: list[CallbackBase] = [OptimizationLogger()] self._callback_router = OptimizationCallbackRouter(self._callbacks) @@ -102,7 +103,7 @@ def Agent( eps=kwargs.get("eps", None), rescale=kwargs.get("scale", None), ) - return cls(sensors, config, evaluation_function, acquisition_plan) + return cls(sensors, config, evaluation_function, acquisition_plan, **kwargs) @property def sensors(self) -> Sequence[Sensor]: @@ -243,7 +244,7 @@ def ingest(self, points: list[dict]) -> None: def optimize(self, iterations=10): if self._optimizer.final is not None: self.config.initial = self._optimizer.final.x - self._optimizer = ScipyOptimizer(self.config) + self._optimizer = ScipyOptimizer(self.config, timeout=self.timeout) optimize_plan = optimize( self.to_optimization_problem(), iterations=iterations, diff --git a/src/blop/gradient/optimizer.py b/src/blop/gradient/optimizer.py index 9d320f3f..8a1d92cd 100644 --- a/src/blop/gradient/optimizer.py +++ b/src/blop/gradient/optimizer.py @@ -16,7 +16,7 @@ class SCP(StrEnum): Default = "Default" - BFGS = "BFGS" + BFGS = "L-BFGS-B" Dual_Annealing = "dual annealing" @@ -51,8 +51,8 @@ class Result: nit: int status: int = 2 - def __init__(self, config: ScipyCFG): - self.session(config=config, timeout=200) + def __init__(self, config: ScipyCFG, timeout: int | None = 200): + self.session(config=config, timeout=timeout) def session(self, config: ScipyCFG, timeout: int | None = None): self._params: list[str] = [] @@ -134,10 +134,10 @@ def call(kws=None): def mini_worker(): try: - if config.threads and config.optimizer in (SCP.Default): + if config.threads: with ThreadPoolExecutor(max_workers=config.threads) as pool: kw["workers"] = pool.map - print(f"creating {config.threads} workers") + print(f"creating {config.threads} workers with:{kw}") call(kws=kw) else: call(kws=kw) diff --git a/src/blop/tests/gradient/test_scipy.py b/src/blop/tests/gradient/test_scipy.py index 7474686c..0b2569d9 100644 --- a/src/blop/tests/gradient/test_scipy.py +++ b/src/blop/tests/gradient/test_scipy.py @@ -1,3 +1,4 @@ +import time from unittest.mock import MagicMock import pytest @@ -18,9 +19,11 @@ def mock_evaluation_function(): def mock_acquisition_plan(): return MagicMock(spec=AcquisitionPlan) +# agent._optimizer.close() is called so the standard timeout doesnt make the testing take forever + @pytest.fixture(scope="function") -def agent_prep(): +def agent_prep(mock_evaluation_function, mock_acquisition_plan): movable1 = MovableSignal(name="test_movable1") movable2 = MovableSignal(name="test_movable2") readable = ReadableSignal(name="test_readable") @@ -30,6 +33,7 @@ def agent_prep(): config = ScipyCFG( dofs=[dof1, dof2], objective=objective, + threads=4 ) agent = Scipy( sensors=[readable], @@ -37,7 +41,9 @@ def agent_prep(): evaluation_function=mock_evaluation_function, acquisition_plan=mock_acquisition_plan, name="test_experiment", + timeout=5 ) + time.sleep(.1) return agent @@ -59,11 +65,13 @@ def test_general_init(mock_evaluation_function, mock_acquisition_plan): evaluation_function=mock_evaluation_function, acquisition_plan=mock_acquisition_plan, name="test_experiment", + timeout=5 ) assert agent.sensors == [readable] assert agent.actuators == [dof1.actuator, dof2.actuator] assert agent.evaluation_function == mock_evaluation_function assert agent.acquisition_plan == mock_acquisition_plan + agent._optimizer.close() def test_agent_init(mock_evaluation_function, mock_acquisition_plan): @@ -81,11 +89,13 @@ def test_agent_init(mock_evaluation_function, mock_acquisition_plan): evaluation_function=mock_evaluation_function, acquisition_plan=mock_acquisition_plan, name="test_experiment", + timeout=5 ) assert agent.sensors == [readable] assert agent.actuators == [dof1.actuator, dof2.actuator] assert agent.evaluation_function == mock_evaluation_function assert agent.acquisition_plan == mock_acquisition_plan + agent._optimizer.close() def test_agent_to_optimization_problem(mock_evaluation_function): @@ -100,6 +110,7 @@ def test_agent_to_optimization_problem(mock_evaluation_function): dofs=[dof1, dof2], objectives=[objective], evaluation_function=mock_evaluation_function, + timeout=5 ) optimization_problem = agent.to_optimization_problem() assert optimization_problem.evaluation_function == mock_evaluation_function @@ -107,6 +118,7 @@ def test_agent_to_optimization_problem(mock_evaluation_function): assert optimization_problem.sensors == [] assert isinstance(optimization_problem.optimizer, ScipyOptimizer) assert optimization_problem.acquisition_plan is None + agent._optimizer.close() def test_agent_suggest(agent_prep): @@ -117,33 +129,20 @@ def test_agent_suggest(agent_prep): assert "test_movable2" in parameterizations[0] assert isinstance(parameterizations[0]["test_movable1"], (int, float)) assert isinstance(parameterizations[0]["test_movable2"], (int, float)) + agent_prep._optimizer.close() -def test_agent_multithread(agent_prep): - movable1 = MovableSignal(name="test_movable1") - movable2 = MovableSignal(name="test_movable2") - readable = ReadableSignal(name="test_readable") - dof1 = RangeDOF(actuator=movable1, bounds=(0, 10), parameter_type="float") - dof2 = RangeDOF(actuator=movable2, bounds=(0, 10), parameter_type="float") - objective = Objective(name="test_objective", minimize=False) - config = ScipyCFG( - dofs=[dof1, dof2], - objective=objective, - optimizer=SCP.BFGS, - threads=4, - ) - agent = Scipy( - sensors=[readable], - config=config, - evaluation_function=mock_evaluation_function, - acquisition_plan=mock_acquisition_plan, - name="test_experiment", - ) - parameterizations = agent.suggest(4) - print([param.args for param in agent._optimizer._active.values()]) - assert len(parameterizations) == 4 +def test_agent_ingest(agent_prep): + agent_prep.suggest() + agent_prep.ingest([{"test_movable1": 0.1, "test_movable2": 0.2, "test_objective": 0.3, ID_KEY: 0}]) + agent_prep._optimizer.close() -def test_agent_ingest(agent_prep): - suggestions = agent_prep.suggest() +def test_agent_multithread(agent_prep): + agent_prep.suggest(1) agent_prep.ingest([{"test_movable1": 0.1, "test_movable2": 0.2, "test_objective": 0.3, ID_KEY: 0}]) + time.sleep(.1) + params = agent_prep.suggest(4) + print(agent_prep._optimizer._active) + assert len(params) > 1 + agent_prep._optimizer.close() From 69a1be44c5fcaf5be7bf7e1f1dad2439ddba7fac Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Wed, 24 Jun 2026 14:41:46 -0400 Subject: [PATCH 20/43] removed debug prints --- src/blop/gradient/optimizer.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/blop/gradient/optimizer.py b/src/blop/gradient/optimizer.py index 8a1d92cd..ed05a4fc 100644 --- a/src/blop/gradient/optimizer.py +++ b/src/blop/gradient/optimizer.py @@ -84,12 +84,10 @@ def cost(x): # thread safety needs timeout so there is not infinite hang on pro """ simple cooperative thread that defers evaluation of cost call by scipy to the run engine """ - print("pushing to request queue") req = self.Request(args=x, future=Future()) self._active[self._increment] = req self._increment += 1 res = req.future.result(timeout=self.SUGGESTION_TIMEOUT) - print(f"recovered result {res}") if res is None: raise ValueError("return value is not present") return res @@ -137,7 +135,6 @@ def mini_worker(): if config.threads: with ThreadPoolExecutor(max_workers=config.threads) as pool: kw["workers"] = pool.map - print(f"creating {config.threads} workers with:{kw}") call(kws=kw) else: call(kws=kw) @@ -192,7 +189,6 @@ def suggest(self, num_points: int | None = None) -> list[dict]: suggestion = dict(zip(self._params, vector, strict=True)) suggestion[ID_KEY] = id suggestions.append(suggestion) - print(f"returning {len(suggestions)} suggestions of {len(self._active.keys())} available") return suggestions def ingest(self, points: list[dict]) -> None: From aa8f3656cb15aefacd3986076080505590b409ac Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Wed, 24 Jun 2026 14:38:49 -0400 Subject: [PATCH 21/43] GO MY BOTS, BUILD UNIT TESTS --- src/blop/tests/gradient/test_scipy.py | 596 +++++++++++++++++++++++++- 1 file changed, 585 insertions(+), 11 deletions(-) diff --git a/src/blop/tests/gradient/test_scipy.py b/src/blop/tests/gradient/test_scipy.py index 0b2569d9..6e6ea69f 100644 --- a/src/blop/tests/gradient/test_scipy.py +++ b/src/blop/tests/gradient/test_scipy.py @@ -19,11 +19,33 @@ def mock_evaluation_function(): def mock_acquisition_plan(): return MagicMock(spec=AcquisitionPlan) + # agent._optimizer.close() is called so the standard timeout doesnt make the testing take forever @pytest.fixture(scope="function") def agent_prep(mock_evaluation_function, mock_acquisition_plan): + movable1 = MovableSignal(name="test_movable1") + movable2 = MovableSignal(name="test_movable2") + readable = ReadableSignal(name="test_readable") + dof1 = RangeDOF(actuator=movable1, bounds=(0, 10), parameter_type="float") + dof2 = RangeDOF(actuator=movable2, bounds=(0, 10), parameter_type="float") + objective = Objective(name="test_objective", minimize=False) + config = ScipyCFG(dofs=[dof1, dof2], objective=objective, threads=4) + agent = Scipy( + sensors=[readable], + config=config, + evaluation_function=mock_evaluation_function, + acquisition_plan=mock_acquisition_plan, + name="test_experiment", + timeout=5, + ) + time.sleep(0.1) + return agent + + +@pytest.fixture(scope="function") +def rescaled_agent_prep(mock_evaluation_function, mock_acquisition_plan): movable1 = MovableSignal(name="test_movable1") movable2 = MovableSignal(name="test_movable2") readable = ReadableSignal(name="test_readable") @@ -33,7 +55,8 @@ def agent_prep(mock_evaluation_function, mock_acquisition_plan): config = ScipyCFG( dofs=[dof1, dof2], objective=objective, - threads=4 + threads=4, + rescale=[2.0, 3.0], ) agent = Scipy( sensors=[readable], @@ -41,9 +64,27 @@ def agent_prep(mock_evaluation_function, mock_acquisition_plan): evaluation_function=mock_evaluation_function, acquisition_plan=mock_acquisition_plan, name="test_experiment", - timeout=5 + timeout=5, ) - time.sleep(.1) + time.sleep(0.1) + return agent + + +@pytest.fixture(scope="function") +def single_dof_agent_prep(mock_evaluation_function, mock_acquisition_plan): + movable = MovableSignal(name="test_movable") + readable = ReadableSignal(name="test_readable") + dof = RangeDOF(actuator=movable, bounds=(0, 10), parameter_type="float") + objective = Objective(name="test_objective", minimize=False) + config = ScipyCFG(dofs=[dof], objective=objective) + agent = Scipy( + sensors=[readable], + config=config, + evaluation_function=mock_evaluation_function, + acquisition_plan=mock_acquisition_plan, + timeout=5, + ) + time.sleep(0.1) return agent @@ -65,7 +106,7 @@ def test_general_init(mock_evaluation_function, mock_acquisition_plan): evaluation_function=mock_evaluation_function, acquisition_plan=mock_acquisition_plan, name="test_experiment", - timeout=5 + timeout=5, ) assert agent.sensors == [readable] assert agent.actuators == [dof1.actuator, dof2.actuator] @@ -89,7 +130,7 @@ def test_agent_init(mock_evaluation_function, mock_acquisition_plan): evaluation_function=mock_evaluation_function, acquisition_plan=mock_acquisition_plan, name="test_experiment", - timeout=5 + timeout=5, ) assert agent.sensors == [readable] assert agent.actuators == [dof1.actuator, dof2.actuator] @@ -106,11 +147,7 @@ def test_agent_to_optimization_problem(mock_evaluation_function): dof2 = RangeDOF(actuator=movable2, bounds=(0, 10), parameter_type="float") objective = Objective(name="test_objective", minimize=False) agent = Scipy.Agent( - sensors=[], - dofs=[dof1, dof2], - objectives=[objective], - evaluation_function=mock_evaluation_function, - timeout=5 + sensors=[], dofs=[dof1, dof2], objectives=[objective], evaluation_function=mock_evaluation_function, timeout=5 ) optimization_problem = agent.to_optimization_problem() assert optimization_problem.evaluation_function == mock_evaluation_function @@ -141,8 +178,545 @@ def test_agent_ingest(agent_prep): def test_agent_multithread(agent_prep): agent_prep.suggest(1) agent_prep.ingest([{"test_movable1": 0.1, "test_movable2": 0.2, "test_objective": 0.3, ID_KEY: 0}]) - time.sleep(.1) + time.sleep(0.1) params = agent_prep.suggest(4) print(agent_prep._optimizer._active) assert len(params) > 1 agent_prep._optimizer.close() + + +# ============================================================================ +# PHASE 1: Configuration & Initialization Tests +# ============================================================================ + + +def test_scipy_cfg_rescaling_scalar(mock_evaluation_function, mock_acquisition_plan): + """Test ScipyCFG with scalar rescaling.""" + movable = MovableSignal(name="test_movable") + dof = RangeDOF(actuator=movable, bounds=(0, 10), parameter_type="float") + objective = Objective(name="test_objective", minimize=False) + + config = ScipyCFG( + dofs=[dof], + objective=objective, + rescale=2.0, + ) + + agent = Scipy( + sensors=[], + config=config, + evaluation_function=mock_evaluation_function, + acquisition_plan=mock_acquisition_plan, + timeout=5, + ) + + # Verify rescaling was applied + assert agent._optimizer._scale[0] == 2.0 + agent._optimizer.close() + + +def test_scipy_cfg_rescaling_list(mock_evaluation_function, mock_acquisition_plan): + """Test ScipyCFG with list rescaling per parameter.""" + movable1 = MovableSignal(name="test_movable1") + movable2 = MovableSignal(name="test_movable2") + dof1 = RangeDOF(actuator=movable1, bounds=(0, 10), parameter_type="float") + dof2 = RangeDOF(actuator=movable2, bounds=(0, 10), parameter_type="float") + objective = Objective(name="test_objective", minimize=False) + + config = ScipyCFG( + dofs=[dof1, dof2], + objective=objective, + rescale=[2.0, 3.0], + ) + + agent = Scipy( + sensors=[], + config=config, + evaluation_function=mock_evaluation_function, + acquisition_plan=mock_acquisition_plan, + timeout=5, + ) + + # Verify rescaling per DOF + assert agent._optimizer._scale[0] == 2.0 + assert agent._optimizer._scale[1] == 3.0 + agent._optimizer.close() + + +def test_scipy_cfg_initial_parameters(mock_evaluation_function, mock_acquisition_plan): + """Test ScipyCFG with initial parameter values.""" + movable1 = MovableSignal(name="test_movable1") + movable2 = MovableSignal(name="test_movable2") + dof1 = RangeDOF(actuator=movable1, bounds=(0, 10), parameter_type="float") + dof2 = RangeDOF(actuator=movable2, bounds=(0, 10), parameter_type="float") + objective = Objective(name="test_objective", minimize=False) + + initial_params = [2.5, 7.5] + config = ScipyCFG( + dofs=[dof1, dof2], + objective=objective, + initial=initial_params, + ) + + agent = Scipy( + sensors=[], + config=config, + evaluation_function=mock_evaluation_function, + acquisition_plan=mock_acquisition_plan, + timeout=5, + ) + + # Verify initial parameters are set + agent._optimizer.close() + + +def test_scipy_cfg_max_iter_and_eps(mock_evaluation_function, mock_acquisition_plan): + """Test ScipyCFG with max_iter and eps parameters.""" + movable = MovableSignal(name="test_movable") + dof = RangeDOF(actuator=movable, bounds=(0, 10), parameter_type="float") + objective = Objective(name="test_objective", minimize=False) + + config = ScipyCFG( + dofs=[dof], + objective=objective, + max_iter=50, + eps=1e-6, + ) + + assert config.max_iter == 50 + assert config.eps == 1e-6 + + +def test_agent_invalid_optimizer_enum(mock_evaluation_function, mock_acquisition_plan): + """Test Scipy.Agent raises ValueError for invalid optimizer.""" + movable = MovableSignal(name="test_movable") + dof = RangeDOF(actuator=movable, bounds=(0, 10), parameter_type="float") + objective = Objective(name="test_objective", minimize=False) + readable = ReadableSignal(name="test_readable") + + with pytest.raises(ValueError, match="optimizer.*not in supported optimizers"): + Scipy.Agent( + sensors=[readable], + dofs=[dof], + objectives=[objective], + evaluation_function=mock_evaluation_function, + optimizer="invalid_optimizer", + ) + + +def test_agent_multiple_objectives_not_supported(mock_evaluation_function, mock_acquisition_plan): + """Test Scipy.Agent raises ValueError for multiple objectives.""" + movable = MovableSignal(name="test_movable") + dof = RangeDOF(actuator=movable, bounds=(0, 10), parameter_type="float") + objective1 = Objective(name="test_objective_1", minimize=False) + objective2 = Objective(name="test_objective_2", minimize=False) + readable = ReadableSignal(name="test_readable") + + with pytest.raises(ValueError, match="Multiple Objectives are not supported"): + Scipy.Agent( + sensors=[readable], + dofs=[dof], + objectives=[objective1, objective2], + evaluation_function=mock_evaluation_function, + ) + + +# ============================================================================ +# PHASE 2: Optimizer Algorithm Variations Tests +# ============================================================================ + + +@pytest.mark.parametrize("optimizer", [SCP.Default, SCP.BFGS, SCP.Dual_Annealing]) +def test_scipy_optimizer_algorithms(mock_evaluation_function, mock_acquisition_plan, optimizer): + """Test ScipyOptimizer with different SCP algorithms.""" + movable1 = MovableSignal(name="test_movable1") + movable2 = MovableSignal(name="test_movable2") + dof1 = RangeDOF(actuator=movable1, bounds=(0, 10), parameter_type="float") + dof2 = RangeDOF(actuator=movable2, bounds=(0, 10), parameter_type="float") + objective = Objective(name="test_objective", minimize=False) + + config = ScipyCFG( + dofs=[dof1, dof2], + objective=objective, + optimizer=optimizer, + max_iter=10, + ) + + opt = ScipyOptimizer(config, timeout=5) + assert opt._active is not None + opt.close() + + +def test_scipy_optimizer_bfgs_specific(mock_evaluation_function, mock_acquisition_plan): + """Test ScipyOptimizer explicitly with BFGS.""" + movable1 = MovableSignal(name="test_movable1") + movable2 = MovableSignal(name="test_movable2") + dof1 = RangeDOF(actuator=movable1, bounds=(0, 10), parameter_type="float") + dof2 = RangeDOF(actuator=movable2, bounds=(0, 10), parameter_type="float") + objective = Objective(name="test_objective", minimize=False) + + config = ScipyCFG( + dofs=[dof1, dof2], + objective=objective, + optimizer=SCP.BFGS, + max_iter=10, + ) + + opt = ScipyOptimizer(config, timeout=5) + assert opt.final is None # No optimization run yet + opt.close() + + +def test_scipy_optimizer_dual_annealing_specific(mock_evaluation_function, mock_acquisition_plan): + """Test ScipyOptimizer explicitly with Dual_Annealing.""" + movable = MovableSignal(name="test_movable") + dof = RangeDOF(actuator=movable, bounds=(0, 10), parameter_type="float") + objective = Objective(name="test_objective", minimize=False) + + config = ScipyCFG( + dofs=[dof], + objective=objective, + optimizer=SCP.Dual_Annealing, + ) + + opt = ScipyOptimizer(config, timeout=5) + assert opt.final is None # No optimization run yet + opt.close() + + +def test_scipy_optimizer_threads_none(mock_evaluation_function, mock_acquisition_plan): + """Test ScipyOptimizer with threads=None (no parallelization).""" + movable = MovableSignal(name="test_movable") + dof = RangeDOF(actuator=movable, bounds=(0, 10), parameter_type="float") + objective = Objective(name="test_objective", minimize=False) + + config = ScipyCFG( + dofs=[dof], + objective=objective, + threads=None, + ) + + opt = ScipyOptimizer(config, timeout=5) + assert opt._thread_pool is None # No thread pool when threads=None + opt.close() + + +def test_scipy_optimizer_threads_multiple(mock_evaluation_function, mock_acquisition_plan): + """Test ScipyOptimizer with multiple threads.""" + movable = MovableSignal(name="test_movable") + dof = RangeDOF(actuator=movable, bounds=(0, 10), parameter_type="float") + objective = Objective(name="test_objective", minimize=False) + + config = ScipyCFG( + dofs=[dof], + objective=objective, + threads=2, + ) + + opt = ScipyOptimizer(config, timeout=5) + # Configuration accepted + opt.close() + + +# ============================================================================ +# PHASE 3: Rescaling & Parameter Handling Tests +# ============================================================================ + + +def test_rescaling_suggest_output(rescaled_agent_prep): + """Test suggest() respects rescaling (scaled input → unscaled output).""" + suggestions = rescaled_agent_prep.suggest(1) + assert len(suggestions) == 1 + + # Suggested values should be in original (unscaled) space + # Bounds are (0, 10), so suggestions should be in [0, 10] + assert 0 <= suggestions[0]["test_movable1"] <= 10 + assert 0 <= suggestions[0]["test_movable2"] <= 10 + rescaled_agent_prep._optimizer.close() + + +def test_rescaling_ingest_parameters(rescaled_agent_prep): + """Test ingest() processes scaled parameters correctly.""" + # Suggest first + rescaled_agent_prep.suggest(1) + # Ingest with outcome + rescaled_agent_prep.ingest([{"test_movable1": 2.5, "test_movable2": 5.0, "test_objective": 0.8, ID_KEY: 0}]) + rescaled_agent_prep._optimizer.close() + + +def test_get_best_points_scaling(rescaled_agent_prep): + """Test get_best_points() with scaling works (verify basic structure).""" + # Set final result manually (simulate completed optimization) + rescaled_agent_prep._optimizer.final = ScipyOptimizer.Result( + x=[2.5, 3.0], # Scaled values + fun=0.85, + nit=15, + status=0, + ) + + best = rescaled_agent_prep._optimizer.get_best_points() + assert isinstance(best, list) + assert len(best) == 3 # (trial_idx, params_dict, metrics_dict) + assert best[0] == 14 # nit - 1 + assert "test_movable1" in best[1] + assert "test_movable2" in best[1] + rescaled_agent_prep._optimizer.close() + + +# ============================================================================ +# PHASE 4: Error Handling & Validation Tests +# ============================================================================ + + +def test_ingest_raises_on_unknown_id(agent_prep): + """Test ingest() raises ValueError when ID not in _active requests.""" + # Try to ingest with unknown ID + with pytest.raises(ValueError, match="optimizer did not expect to receive an update"): + agent_prep.ingest([{"test_movable1": 5.0, "test_movable2": 5.0, "test_objective": 0.5, ID_KEY: 999}]) + + agent_prep._optimizer.close() + + +def test_ingest_force_resiliance_skips_unknown_id(agent_prep): + """Test ingest() with force_resiliance=True skips unknown IDs.""" + # Enable resiliance + agent_prep._optimizer.force_resiliance = True + + # This should NOT raise (unknown IDs are skipped) + agent_prep.ingest([{"test_movable1": 5.0, "test_movable2": 5.0, "test_objective": 0.5, ID_KEY: 999}]) + + agent_prep._optimizer.close() + + +def test_ingest_missing_objective_name(agent_prep): + """Test ingest() raises ValueError if objective name missing from data.""" + # Suggest first to create an active request + agent_prep.suggest(1) + + # Try to ingest without objective value + with pytest.raises(KeyError): + agent_prep.ingest([{"test_movable1": 5.0, "test_movable2": 5.0, ID_KEY: 0}]) # Missing "test_objective" + + agent_prep._optimizer.close() + + +def test_optimizer_close_cancels_futures(agent_prep): + """Test ScipyOptimizer.close() cancels all active futures.""" + # Suggest to create active futures + agent_prep.suggest(2) + active_count = len(agent_prep._optimizer._active) + assert active_count > 0 + + # Close should cancel all futures + agent_prep._optimizer.close() + # All futures should now have exceptions set + for future_wrapper in agent_prep._optimizer._active.values(): + assert future_wrapper.future.done() + + +def test_suggest_before_optimization(agent_prep): + """Test suggest() before any optimization returns expected state.""" + # Suggest before any ingest + suggestions = agent_prep.suggest(1) + assert len(suggestions) == 1 + assert "_id" in suggestions[0] + agent_prep._optimizer.close() + + +# ============================================================================ +# PHASE 5: Callback Management Tests +# ============================================================================ + + +def test_subscribe_callback(single_dof_agent_prep): + """Test subscribe() adds callback to list.""" + callback = MagicMock() + initial_count = len(single_dof_agent_prep.callbacks) + single_dof_agent_prep.subscribe(callback) + + assert len(single_dof_agent_prep.callbacks) == initial_count + 1 + assert callback in single_dof_agent_prep.callbacks + single_dof_agent_prep._optimizer.close() + + +def test_subscribe_duplicate_raises(single_dof_agent_prep): + """Test subscribe() raises ValueError on duplicate callback.""" + callback = MagicMock() + single_dof_agent_prep.subscribe(callback) + + with pytest.raises(ValueError, match="already subscribed"): + single_dof_agent_prep.subscribe(callback) + + single_dof_agent_prep._optimizer.close() + + +def test_unsubscribe_callback(single_dof_agent_prep): + """Test unsubscribe() removes callback from list.""" + callback = MagicMock() + single_dof_agent_prep.subscribe(callback) + assert callback in single_dof_agent_prep.callbacks + + single_dof_agent_prep.unsubscribe(callback) + assert callback not in single_dof_agent_prep.callbacks + single_dof_agent_prep._optimizer.close() + + +def test_unsubscribe_not_subscribed_raises(single_dof_agent_prep): + """Test unsubscribe() raises ValueError if not subscribed.""" + callback = MagicMock() + + with pytest.raises(ValueError): + single_dof_agent_prep.unsubscribe(callback) + + single_dof_agent_prep._optimizer.close() + + +# ============================================================================ +# PHASE 6: State Management & Context Manager Tests +# ============================================================================ + + +def test_scipy_optimizer_context_manager(mock_evaluation_function, mock_acquisition_plan): + """Test ScipyOptimizer context manager protocol (__enter__/__exit__).""" + movable = MovableSignal(name="test_movable") + dof = RangeDOF(actuator=movable, bounds=(0, 10), parameter_type="float") + objective = Objective(name="test_objective", minimize=False) + + config = ScipyCFG(dofs=[dof], objective=objective) + + with ScipyOptimizer(config, timeout=5) as opt: + assert opt is not None + time.sleep(0.1) + suggestions = opt.suggest(1) + assert len(suggestions) == 1 + + +def test_get_best_points_intermediate_only(single_dof_agent_prep): + """Test get_best_points() with only intermediate results (final=None).""" + # Set intermediate result manually (simulate partway through optimization) + single_dof_agent_prep._optimizer.intermediate = ScipyOptimizer.Result( + x=[5.0], + fun=0.7, + nit=5, + status=0, + ) + + best = single_dof_agent_prep._optimizer.get_best_points() + assert len(best) == 3 + assert best[0] == 4 # nit - 1 + single_dof_agent_prep._optimizer.close() + + +def test_get_best_points_final_preferred(single_dof_agent_prep): + """Test get_best_points() prefers final over intermediate.""" + # Set both intermediate and final + single_dof_agent_prep._optimizer.intermediate = ScipyOptimizer.Result( + x=[5.0], + fun=0.7, + nit=5, + status=0, + ) + single_dof_agent_prep._optimizer.final = ScipyOptimizer.Result( + x=[7.0], + fun=0.9, + nit=10, + status=0, + ) + + best = single_dof_agent_prep._optimizer.get_best_points() + # best[2] is a dict with objective as key; get the first (only) value + objective_value = list(best[2].values())[0] + assert objective_value == 0.9 # Uses final result + single_dof_agent_prep._optimizer.close() + + +def test_get_best_points_no_optimization_raises(single_dof_agent_prep): + """Test get_best_points() raises ValueError if no optimization run.""" + # No optimization run: both intermediate and final are None + with pytest.raises(ValueError, match="no optimization epoch has been recorded"): + single_dof_agent_prep._optimizer.get_best_points() + + single_dof_agent_prep._optimizer.close() + + +def test_scipy_optimizer_session_reinit(mock_evaluation_function, mock_acquisition_plan): + """Test ScipyOptimizer.session() reinitializes state.""" + movable = MovableSignal(name="test_movable") + dof = RangeDOF(actuator=movable, bounds=(0, 10), parameter_type="float") + objective = Objective(name="test_objective", minimize=False) + + config = ScipyCFG(dofs=[dof], objective=objective) + + opt = ScipyOptimizer(config, timeout=5) + opt.suggest(1) + initial_increment = opt._increment + + # Call session to reinitialize + opt.session(config, timeout=5) + + # State should be reset + assert opt._increment == 0 + assert len(opt._active) == 0 + opt.close() + + +# ============================================================================ +# PHASE 7: Edge Cases & Boundary Conditions Tests +# ============================================================================ + + +def test_scipy_single_dof(single_dof_agent_prep): + """Test Scipy with single DOF (one parameter).""" + suggestions = single_dof_agent_prep.suggest(1) + assert len(suggestions) == 1 + assert "test_movable" in suggestions[0] + single_dof_agent_prep._optimizer.close() + + +def test_scipy_large_rescale_factors(mock_evaluation_function, mock_acquisition_plan): + """Test Scipy with large rescale factors (extreme scaling).""" + movable1 = MovableSignal(name="test_movable1") + movable2 = MovableSignal(name="test_movable2") + dof1 = RangeDOF(actuator=movable1, bounds=(0, 10), parameter_type="float") + dof2 = RangeDOF(actuator=movable2, bounds=(0, 10), parameter_type="float") + objective = Objective(name="test_objective", minimize=False) + readable = ReadableSignal(name="test_readable") + + config = ScipyCFG( + dofs=[dof1, dof2], + objective=objective, + rescale=[0.001, 1000.0], # Extreme scaling + ) + + agent = Scipy( + sensors=[readable], + config=config, + evaluation_function=mock_evaluation_function, + acquisition_plan=mock_acquisition_plan, + timeout=5, + ) + time.sleep(0.1) + + suggestions = agent.suggest(1) + assert len(suggestions) == 1 + # Values should still be in original bounds + assert 0 <= suggestions[0]["test_movable1"] <= 10 + assert 0 <= suggestions[0]["test_movable2"] <= 10 + agent._optimizer.close() + + +def test_suggest_after_final_optimization(single_dof_agent_prep): + """Test suggest() after final optimization returns final result parameterization.""" + # Set final optimization result + single_dof_agent_prep._optimizer.final = ScipyOptimizer.Result( + x=[7.0], + fun=0.95, + nit=20, + status=0, + ) + + suggestions = single_dof_agent_prep._optimizer.suggest() + assert len(suggestions) == 1 + assert suggestions[0]["test_movable"] == 7.0 + assert suggestions[0][ID_KEY] == 20 + single_dof_agent_prep._optimizer.close() From b6bec4ec58bc257e42e29cc0a35fc21f2c1cafce Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Wed, 24 Jun 2026 14:47:22 -0400 Subject: [PATCH 22/43] ruff fixes --- src/blop/tests/gradient/test_scipy.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/blop/tests/gradient/test_scipy.py b/src/blop/tests/gradient/test_scipy.py index 6e6ea69f..1fd0924e 100644 --- a/src/blop/tests/gradient/test_scipy.py +++ b/src/blop/tests/gradient/test_scipy.py @@ -649,7 +649,6 @@ def test_scipy_optimizer_session_reinit(mock_evaluation_function, mock_acquisiti opt = ScipyOptimizer(config, timeout=5) opt.suggest(1) - initial_increment = opt._increment # Call session to reinitialize opt.session(config, timeout=5) From 0b254f327499ed037ef04b894ce8913b82315262 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Wed, 24 Jun 2026 15:30:55 -0400 Subject: [PATCH 23/43] strenum flaky in 3.11 and race condition fix on resessioning --- src/blop/gradient/Scipy.py | 3 ++- src/blop/gradient/optimizer.py | 4 ++-- src/blop/tests/gradient/test_scipy.py | 6 +++--- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/blop/gradient/Scipy.py b/src/blop/gradient/Scipy.py index b6806a4a..f0ee6152 100644 --- a/src/blop/gradient/Scipy.py +++ b/src/blop/gradient/Scipy.py @@ -241,13 +241,14 @@ def ingest(self, points: list[dict]) -> None: """ self._optimizer.ingest(points) - def optimize(self, iterations=10): + def optimize(self, iterations=10, n_points=1): if self._optimizer.final is not None: self.config.initial = self._optimizer.final.x self._optimizer = ScipyOptimizer(self.config, timeout=self.timeout) optimize_plan = optimize( self.to_optimization_problem(), iterations=iterations, + n_points=n_points, readable_cache=self._readable_cache, ) diff --git a/src/blop/gradient/optimizer.py b/src/blop/gradient/optimizer.py index ed05a4fc..9ed1f872 100644 --- a/src/blop/gradient/optimizer.py +++ b/src/blop/gradient/optimizer.py @@ -2,7 +2,7 @@ from collections.abc import Mapping, Sequence from concurrent.futures import Future, ThreadPoolExecutor from dataclasses import dataclass -from enum import StrEnum +from enum import Enum from threading import Thread from typing import Any, cast @@ -14,7 +14,7 @@ from blop.protocols import ID_KEY, Optimizer -class SCP(StrEnum): +class SCP(str, Enum): Default = "Default" BFGS = "L-BFGS-B" Dual_Annealing = "dual annealing" diff --git a/src/blop/tests/gradient/test_scipy.py b/src/blop/tests/gradient/test_scipy.py index 1fd0924e..6ea7e761 100644 --- a/src/blop/tests/gradient/test_scipy.py +++ b/src/blop/tests/gradient/test_scipy.py @@ -652,10 +652,10 @@ def test_scipy_optimizer_session_reinit(mock_evaluation_function, mock_acquisiti # Call session to reinitialize opt.session(config, timeout=5) - + time.sleep(.1) # State should be reset - assert opt._increment == 0 - assert len(opt._active) == 0 + assert opt._increment == 1 + assert len(opt._active) == 1 opt.close() From e381a1388442a837ddf3bd17300a900b1e6f041e Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 25 Jun 2026 11:10:31 -0400 Subject: [PATCH 24/43] fix lint check from throwing error on backwards compat fix --- src/blop/gradient/optimizer.py | 2 +- src/blop/tests/gradient/test_scipy.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/blop/gradient/optimizer.py b/src/blop/gradient/optimizer.py index 9ed1f872..db4c3c6f 100644 --- a/src/blop/gradient/optimizer.py +++ b/src/blop/gradient/optimizer.py @@ -14,7 +14,7 @@ from blop.protocols import ID_KEY, Optimizer -class SCP(str, Enum): +class SCP(str, Enum): # noqa: UP042 Default = "Default" BFGS = "L-BFGS-B" Dual_Annealing = "dual annealing" diff --git a/src/blop/tests/gradient/test_scipy.py b/src/blop/tests/gradient/test_scipy.py index 6ea7e761..70bd673f 100644 --- a/src/blop/tests/gradient/test_scipy.py +++ b/src/blop/tests/gradient/test_scipy.py @@ -652,7 +652,7 @@ def test_scipy_optimizer_session_reinit(mock_evaluation_function, mock_acquisiti # Call session to reinitialize opt.session(config, timeout=5) - time.sleep(.1) + time.sleep(0.1) # State should be reset assert opt._increment == 1 assert len(opt._active) == 1 From cd36fe4d741f44235fa2a207ddbb29f642ce2b02 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 25 Jun 2026 11:14:28 -0400 Subject: [PATCH 25/43] added tutorial for gradient --- .../source/tutorials/gradient-optimization.md | 229 ++++++++++++++++++ 1 file changed, 229 insertions(+) create mode 100644 docs/source/tutorials/gradient-optimization.md diff --git a/docs/source/tutorials/gradient-optimization.md b/docs/source/tutorials/gradient-optimization.md new file mode 100644 index 00000000..e0e38949 --- /dev/null +++ b/docs/source/tutorials/gradient-optimization.md @@ -0,0 +1,229 @@ +--- +jupyter: + jupytext: + default_lexer: ipython3 + text_representation: + extension: .md + format_name: markdown + format_version: '1.3' + jupytext_version: 1.19.1 + kernelspec: + display_name: Python 3 + language: python + name: python3 +--- + +# Your first Scipy optimization with Blop + +In this tutorial, you will learn the three core concepts of Blop: **DOFs** (the parameters you can adjust), **objectives** (what you want to optimize), and the **Agent** (which coordinates the optimization). We'll optimize a simple mathematical function using simulated devices—the same patterns apply to real hardware. + +## Setup + +First, let's import what we need and start the data infrastructure: + +```python +import logging +import time +import warnings +from typing import Any + +from bluesky.protocols import HasHints, HasParent, Hints, NamedMovable, Readable, Status +from bluesky.run_engine import RunEngine +from bluesky_tiled_plugins import TiledWriter +from tiled.client import from_uri +from tiled.client.container import Container +from tiled.server import SimpleTiledServer + +from blop.ax import Objective, RangeDOF +from blop.gradient import SCP, Scipy, ScipyCFG + +# Suppress noisy logs from httpx +logging.getLogger("httpx").setLevel(logging.WARNING) +``` + +```python +# Start a local Tiled server for data storage +tiled_server = SimpleTiledServer() + +# Set up the Bluesky RunEngine and connect it to Tiled +RE = RunEngine({}) +tiled_client = from_uri(tiled_server.uri) +tiled_writer = TiledWriter(tiled_client) +RE.subscribe(tiled_writer) +``` + +## Creating simulated devices + +Bluesky controls devices through protocols. For this tutorial, we create simple simulated "movable" devices. In real experiments, you would use [Ophyd](https://blueskyproject.io/ophyd-async) devices or similar—the code below is just boilerplate to simulate hardware: + +```python +class AlwaysSuccessfulStatus(Status): + def add_callback(self, callback) -> None: + callback(self) + def exception(self, timeout = 0.0): + return None + @property + def done(self) -> bool: + return True + @property + def success(self) -> bool: + return True + +class ReadableSignal(Readable, HasHints, HasParent): + def __init__(self, name: str) -> None: + self._name = name + self._value = 0.0 + @property + def name(self) -> str: + return self._name + @property + def hints(self) -> Hints: + return {"fields": [self._name], "dimensions": [], "gridding": "rectilinear"} + @property + def parent(self) -> Any | None: + return None + def read(self): + return {self._name: {"value": self._value, "timestamp": time.time()}} + def describe(self): + return {self._name: {"source": self._name, "dtype": "number", "shape": []}} + +class MovableSignal(ReadableSignal, NamedMovable): + def __init__(self, name: str, initial_value: float = 0.0) -> None: + super().__init__(name) + self._value: float = initial_value + def set(self, value: float) -> Status: + self._value = value + return AlwaysSuccessfulStatus() +``` + +## Defining DOFs and objectives + +**DOFs** (degrees of freedom) are the parameters the optimizer can adjust. **Objectives** are what you want to optimize. Here we define two DOFs (`x1` and `x2`) that can range from -5 to 5, and one objective (the Himmelblau function) that we want to minimize: + +```python +x1 = MovableSignal("x1", initial_value=0.1) +x2 = MovableSignal("x2", initial_value=0.23) + +dofs = [ + RangeDOF(actuator=x1, bounds=(-5, 5), parameter_type="float"), + RangeDOF(actuator=x2, bounds=(-5, 5), parameter_type="float"), +] +objectives = [ + Objective(name="himmelblau_2d", minimize=True), +] +sensors = [] +``` + +## Writing the evaluation function + +The **evaluation function** computes objective values from experimental data. After each run, Blop calls this function with the run's unique ID and the suggestions that were tried. It returns the computed objective values: + +```python +class Himmelblau2DEvaluation(): + def __init__(self, tiled_client: Container): + self.tiled_client = tiled_client + + def __call__(self, uid: str, suggestions: list[dict]) -> list[dict]: + run = self.tiled_client[uid] + outcomes = [] + reordered_suggestions = run.start["blop_suggestions"] + x1_data = run["primary/x1"].read() + x2_data = run["primary/x2"].read() + + print("[Himmelblau] evaluating suggestions: ", [s["_id"] for s in suggestions], " reordered to: ", [s["_id"] for s in reordered_suggestions]) + for index, suggestion in enumerate(reordered_suggestions): + # Special key to identify a suggestion + suggestion_id = suggestion["_id"] + x1 = x1_data[index] + x2 = x2_data[index] + # Himmelblau function: has four global minima where value = 0 + outcomes.append({ + "himmelblau_2d": (x1 ** 2 + x2 - 11) ** 2 + (x1 + x2 ** 2 - 7) ** 2, + "_id": suggestion_id + }) + + return outcomes +``` + +## Running the optimization + +The **Agent** brings everything together. Create one with your DOFs, objectives, and evaluation function, then run the optimization: + +```python +agent = Scipy.Agent( + sensors=sensors, + dofs=dofs, + objectives=objectives, + evaluation_function=Himmelblau2DEvaluation(tiled_client=tiled_client), + name="simple-experiment", + description="A simple experiment optimizing the Himmelblau function", +) + +RE(agent.optimize(10)) +``` + +## Configuring the optimization + +Sometimes a default **Agent** optimization may not do all that you'd like. We expose a configuration object called ScipyCFG and a pure scipy interface so that the classic parameters of scipy minimize can be tweaked (and some multipoint sampling can be used). + +```python +config = ScipyCFG( + dofs=dofs, + objective=objectives[0], + optimizer=SCP.Default, + threads=4, + eps=.1 +) +agent = Scipy( + sensors=sensors, + config=config, + evaluation_function=Himmelblau2DEvaluation(tiled_client=tiled_client), + name="test_experiment", +) +res_uid = RE(agent.optimize(20, n_points=2)) +``` + +## Viewing the results + +Scipy is a local optimizer so it doesn't have internal point tracking, but we can to grab it from our datastore. + +```python +import numpy as np +import matplotlib.pyplot as plt + +res_client = tiled_client[res_uid[0]] +data = res_client["primary/internal"].read() +vec = data[["suggestion_ids", "x1", "x2", "himmelblau_2d"]] +res = [] +for _, row in vec.iterrows(): + vic = [row.suggestion_ids, row.x1, row.x2, row.himmelblau_2d] + vic = [x.strip("[]").split() for x in vic] + for id, x, y, obj in zip(*vic,strict=True): + if id != "''": + res.append([int(id.strip("'")), float(x), float(y), float(obj)]) +res = np.array(res) + +fig, ax = plt.subplots(figsize=(12, 8)) + +xb, yb = np.random.uniform(-5, 5, (2, 1000)) +ax.tripcolor(xb, yb, (xb**2 + yb - 11)**2 + (xb + yb**2 - 7)**2, shading="gouraud") + +i, x, y, z = res.T +ps = ax.scatter(x, y, c=range(len(x)), cmap='plasma', s=50) +plt.colorbar(ps).set_label('sample index') +plt.title("Visualizing Scipy's traversal of Himmelblau") +``` + +The Himmelblau function has four global minima (all with value 0). The `summarize` output shows which one(s) the optimizer found. + +## What you learned + +You now understand the three core concepts of Blop: + +- **DOFs**: The parameters the optimizer adjusts (here, `x1` and `x2` with bounds) +- **Objectives**: What you're optimizing (here, minimizing the Himmelblau function) +- **Agent**: Coordinates the optimization loop between Bluesky and the evaluation function + +## Next steps + +For a more comprehensive tutorial with multiple objectives and diagnostic tools, see [Optimizing KB Mirrors](./xrt-kb-mirrors.md). From 4e07388db7abe491b7ebfc59d4b7fe996e87d7b8 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 25 Jun 2026 11:28:00 -0400 Subject: [PATCH 26/43] lint fixes and new optimizer --- .../source/tutorials/gradient-optimization.md | 47 +++++++++++-------- src/blop/gradient/optimizer.py | 5 +- src/blop/tests/gradient/test_scipy.py | 2 +- 3 files changed, 31 insertions(+), 23 deletions(-) diff --git a/docs/source/tutorials/gradient-optimization.md b/docs/source/tutorials/gradient-optimization.md index e0e38949..07c35f5f 100644 --- a/docs/source/tutorials/gradient-optimization.md +++ b/docs/source/tutorials/gradient-optimization.md @@ -37,7 +37,7 @@ from tiled.server import SimpleTiledServer from blop.ax import Objective, RangeDOF from blop.gradient import SCP, Scipy, ScipyCFG -# Suppress noisy logs from httpx +# Suppress noisy logs from httpx logging.getLogger("httpx").setLevel(logging.WARNING) ``` @@ -60,37 +60,48 @@ Bluesky controls devices through protocols. For this tutorial, we create simple class AlwaysSuccessfulStatus(Status): def add_callback(self, callback) -> None: callback(self) - def exception(self, timeout = 0.0): + + def exception(self, timeout=0.0): return None + @property def done(self) -> bool: return True + @property def success(self) -> bool: return True + class ReadableSignal(Readable, HasHints, HasParent): def __init__(self, name: str) -> None: self._name = name self._value = 0.0 + @property def name(self) -> str: return self._name + @property def hints(self) -> Hints: return {"fields": [self._name], "dimensions": [], "gridding": "rectilinear"} + @property def parent(self) -> Any | None: return None + def read(self): return {self._name: {"value": self._value, "timestamp": time.time()}} + def describe(self): return {self._name: {"source": self._name, "dtype": "number", "shape": []}} + class MovableSignal(ReadableSignal, NamedMovable): def __init__(self, name: str, initial_value: float = 0.0) -> None: super().__init__(name) self._value: float = initial_value + def set(self, value: float) -> Status: self._value = value return AlwaysSuccessfulStatus() @@ -119,7 +130,7 @@ sensors = [] The **evaluation function** computes objective values from experimental data. After each run, Blop calls this function with the run's unique ID and the suggestions that were tried. It returns the computed objective values: ```python -class Himmelblau2DEvaluation(): +class Himmelblau2DEvaluation: def __init__(self, tiled_client: Container): self.tiled_client = tiled_client @@ -130,18 +141,20 @@ class Himmelblau2DEvaluation(): x1_data = run["primary/x1"].read() x2_data = run["primary/x2"].read() - print("[Himmelblau] evaluating suggestions: ", [s["_id"] for s in suggestions], " reordered to: ", [s["_id"] for s in reordered_suggestions]) + print( + "[Himmelblau] evaluating suggestions: ", + [s["_id"] for s in suggestions], + " reordered to: ", + [s["_id"] for s in reordered_suggestions], + ) for index, suggestion in enumerate(reordered_suggestions): # Special key to identify a suggestion suggestion_id = suggestion["_id"] x1 = x1_data[index] x2 = x2_data[index] # Himmelblau function: has four global minima where value = 0 - outcomes.append({ - "himmelblau_2d": (x1 ** 2 + x2 - 11) ** 2 + (x1 + x2 ** 2 - 7) ** 2, - "_id": suggestion_id - }) - + outcomes.append({"himmelblau_2d": (x1**2 + x2 - 11) ** 2 + (x1 + x2**2 - 7) ** 2, "_id": suggestion_id}) + return outcomes ``` @@ -167,13 +180,7 @@ RE(agent.optimize(10)) Sometimes a default **Agent** optimization may not do all that you'd like. We expose a configuration object called ScipyCFG and a pure scipy interface so that the classic parameters of scipy minimize can be tweaked (and some multipoint sampling can be used). ```python -config = ScipyCFG( - dofs=dofs, - objective=objectives[0], - optimizer=SCP.Default, - threads=4, - eps=.1 -) +config = ScipyCFG(dofs=dofs, objective=objectives[0], optimizer=SCP.Default, threads=4, eps=0.1) agent = Scipy( sensors=sensors, config=config, @@ -198,7 +205,7 @@ res = [] for _, row in vec.iterrows(): vic = [row.suggestion_ids, row.x1, row.x2, row.himmelblau_2d] vic = [x.strip("[]").split() for x in vic] - for id, x, y, obj in zip(*vic,strict=True): + for id, x, y, obj in zip(*vic, strict=True): if id != "''": res.append([int(id.strip("'")), float(x), float(y), float(obj)]) res = np.array(res) @@ -206,11 +213,11 @@ res = np.array(res) fig, ax = plt.subplots(figsize=(12, 8)) xb, yb = np.random.uniform(-5, 5, (2, 1000)) -ax.tripcolor(xb, yb, (xb**2 + yb - 11)**2 + (xb + yb**2 - 7)**2, shading="gouraud") +ax.tripcolor(xb, yb, (xb**2 + yb - 11) ** 2 + (xb + yb**2 - 7) ** 2, shading="gouraud") i, x, y, z = res.T -ps = ax.scatter(x, y, c=range(len(x)), cmap='plasma', s=50) -plt.colorbar(ps).set_label('sample index') +ps = ax.scatter(x, y, c=range(len(x)), cmap="plasma", s=50) +plt.colorbar(ps).set_label("sample index") plt.title("Visualizing Scipy's traversal of Himmelblau") ``` diff --git a/src/blop/gradient/optimizer.py b/src/blop/gradient/optimizer.py index db4c3c6f..f3f487f6 100644 --- a/src/blop/gradient/optimizer.py +++ b/src/blop/gradient/optimizer.py @@ -16,7 +16,8 @@ class SCP(str, Enum): # noqa: UP042 Default = "Default" - BFGS = "L-BFGS-B" + BFGS = "BFGS" + LBFGS = "L-BFGS-B" Dual_Annealing = "dual annealing" @@ -94,7 +95,7 @@ def cost(x): # thread safety needs timeout so there is not infinite hang on pro kw = {} self._thread_pool = None - if config.optimizer in (SCP.Default, SCP.BFGS): + if config.optimizer in (SCP.Default, SCP.BFGS, SCP.LBFGS): if config.max_iter is not None: kw["max_iter"] = config.max_iter if config.eps is not None: diff --git a/src/blop/tests/gradient/test_scipy.py b/src/blop/tests/gradient/test_scipy.py index 70bd673f..d586d67d 100644 --- a/src/blop/tests/gradient/test_scipy.py +++ b/src/blop/tests/gradient/test_scipy.py @@ -326,7 +326,7 @@ def test_agent_multiple_objectives_not_supported(mock_evaluation_function, mock_ # ============================================================================ -@pytest.mark.parametrize("optimizer", [SCP.Default, SCP.BFGS, SCP.Dual_Annealing]) +@pytest.mark.parametrize("optimizer", [SCP.Default, SCP.BFGS, SCP.LBFGS, SCP.Dual_Annealing]) def test_scipy_optimizer_algorithms(mock_evaluation_function, mock_acquisition_plan, optimizer): """Test ScipyOptimizer with different SCP algorithms.""" movable1 = MovableSignal(name="test_movable1") From 2b5acd70341f5f6464889044367d293645019935 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 25 Jun 2026 12:20:27 -0400 Subject: [PATCH 27/43] dual annealing qol for best points, better tutorail visuals and agent typing --- .../source/tutorials/gradient-optimization.md | 16 +++++++++++-- src/blop/gradient/Scipy.py | 23 ++++++++++++++++++- src/blop/gradient/optimizer.py | 23 ++++++++++++------- 3 files changed, 51 insertions(+), 11 deletions(-) diff --git a/docs/source/tutorials/gradient-optimization.md b/docs/source/tutorials/gradient-optimization.md index 07c35f5f..d2b072c6 100644 --- a/docs/source/tutorials/gradient-optimization.md +++ b/docs/source/tutorials/gradient-optimization.md @@ -180,7 +180,7 @@ RE(agent.optimize(10)) Sometimes a default **Agent** optimization may not do all that you'd like. We expose a configuration object called ScipyCFG and a pure scipy interface so that the classic parameters of scipy minimize can be tweaked (and some multipoint sampling can be used). ```python -config = ScipyCFG(dofs=dofs, objective=objectives[0], optimizer=SCP.Default, threads=4, eps=0.1) +config = ScipyCFG(dofs=dofs, objective=objectives[0], optimizer=SCP.Dual_Annealing, threads=4, max_iter=2, eps=0.1) agent = Scipy( sensors=sensors, config=config, @@ -197,10 +197,12 @@ Scipy is a local optimizer so it doesn't have internal point tracking, but we ca ```python import numpy as np import matplotlib.pyplot as plt +import pandas as pd res_client = tiled_client[res_uid[0]] data = res_client["primary/internal"].read() -vec = data[["suggestion_ids", "x1", "x2", "himmelblau_2d"]] +cols = ["suggestion_ids", "x1", "x2", "himmelblau_2d"] +vec = data[cols] res = [] for _, row in vec.iterrows(): vic = [row.suggestion_ids, row.x1, row.x2, row.himmelblau_2d] @@ -221,6 +223,16 @@ plt.colorbar(ps).set_label("sample index") plt.title("Visualizing Scipy's traversal of Himmelblau") ``` +Seeing the sample history + +```python +pd.DataFrame(data=res, columns=cols) +``` + +```python +print(agent.get_best_points()) +``` + The Himmelblau function has four global minima (all with value 0). The `summarize` output shows which one(s) the optimizer found. ## What you learned diff --git a/src/blop/gradient/Scipy.py b/src/blop/gradient/Scipy.py index f0ee6152..b9e5b50f 100644 --- a/src/blop/gradient/Scipy.py +++ b/src/blop/gradient/Scipy.py @@ -1,4 +1,4 @@ -from collections.abc import Sequence +from collections.abc import Mapping, Sequence from typing import Any, cast import bluesky.preprocessors as bpp @@ -262,3 +262,24 @@ def optimize(self, iterations=10, n_points=1): yield from optimize_plan else: yield from optimize_plan + + def get_best_points(self) -> list[tuple[Any, Mapping, Mapping]]: + """ + Get a list of the optimal points found during optimization. + + For single-objective optimization, returns a single best point. + For multi-objective optimization, returns the Pareto-optimal set. + + Returns + ------- + list[tuple[int, TParameterization, TOutcome]] + Each element in the list is a tuple of: + - trial index (int) + - parameter values (dict) + - metric values (dict, where values may be (value, sem) tuples) + + See Also + -------- + navigate_to_best : Plan stub to move actuators to a best point. + """ + return self._optimizer.get_best_points() diff --git a/src/blop/gradient/optimizer.py b/src/blop/gradient/optimizer.py index f3f487f6..27549c53 100644 --- a/src/blop/gradient/optimizer.py +++ b/src/blop/gradient/optimizer.py @@ -95,14 +95,18 @@ def cost(x): # thread safety needs timeout so there is not infinite hang on pro kw = {} self._thread_pool = None - if config.optimizer in (SCP.Default, SCP.BFGS, SCP.LBFGS): - if config.max_iter is not None: - kw["max_iter"] = config.max_iter - if config.eps is not None: - kw["eps"] = config.eps + if config.max_iter is not None: + kw["max_iter"] = config.max_iter + if config.eps is not None: + kw["eps"] = config.eps + + def default_callback(intermediate_result: OptimizeResult): + if self.intermediate and self.intermediate.fun < intermediate_result.fun: + return + self.intermediate = intermediate_result + self.intermediate.nit = self._increment - def default_callback(intermediate_result: OptimizeResult): - self.intermediate = intermediate_result + if config.optimizer in (SCP.Default, SCP.BFGS, SCP.LBFGS): def call(kws=None): self.final = minimize( @@ -117,6 +121,9 @@ def call(kws=None): elif config.optimizer in (SCP.Dual_Annealing): def dual_callback(x, f, context): + print(f"callback on opt val {f} with current best of {self.intermediate}") + if self.intermediate and self.intermediate.fun < f: + return self.intermediate = self.Result(x, f, self._increment, context) def call(kws=None): @@ -125,7 +132,7 @@ def call(kws=None): x0=_x, bounds=self._bounds, callback=dual_callback, - minimizer_kwargs=kws, + minimizer_kwargs={"callback": default_callback, "bounds": self._bounds, "options": kws}, ) else: From 59f24cbe500424376b41ad3cd3929c144f199c52 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 25 Jun 2026 12:38:13 -0400 Subject: [PATCH 28/43] 3.11 fix v2 --- src/blop/gradient/optimizer.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/blop/gradient/optimizer.py b/src/blop/gradient/optimizer.py index 27549c53..844f3bed 100644 --- a/src/blop/gradient/optimizer.py +++ b/src/blop/gradient/optimizer.py @@ -2,7 +2,7 @@ from collections.abc import Mapping, Sequence from concurrent.futures import Future, ThreadPoolExecutor from dataclasses import dataclass -from enum import Enum +from enum import StrEnum from threading import Thread from typing import Any, cast @@ -14,7 +14,7 @@ from blop.protocols import ID_KEY, Optimizer -class SCP(str, Enum): # noqa: UP042 +class SCP(StrEnum): Default = "Default" BFGS = "BFGS" LBFGS = "L-BFGS-B" From 7bfb29e598bdbdefe2ad8302aa75bc83ffa388e9 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 25 Jun 2026 12:47:32 -0400 Subject: [PATCH 29/43] juytext fix? --- .../source/tutorials/gradient-optimization.md | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/docs/source/tutorials/gradient-optimization.md b/docs/source/tutorials/gradient-optimization.md index d2b072c6..1db27d21 100644 --- a/docs/source/tutorials/gradient-optimization.md +++ b/docs/source/tutorials/gradient-optimization.md @@ -1,16 +1,14 @@ --- -jupyter: - jupytext: - default_lexer: ipython3 - text_representation: - extension: .md - format_name: markdown - format_version: '1.3' - jupytext_version: 1.19.1 - kernelspec: - display_name: Python 3 - language: python - name: python3 +jupytext: + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.17.3 +kernelspec: + display_name: dev + language: python + name: python3 --- # Your first Scipy optimization with Blop From 965af435409a70c67fa26125d9258bea512fbbeb Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 25 Jun 2026 14:24:17 -0400 Subject: [PATCH 30/43] attempt doc fixes --- docs/source/tutorials.rst | 1 + .../source/tutorials/gradient-optimization.md | 20 +++++++++---------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/docs/source/tutorials.rst b/docs/source/tutorials.rst index 502efbe3..6339da1a 100644 --- a/docs/source/tutorials.rst +++ b/docs/source/tutorials.rst @@ -5,6 +5,7 @@ Tutorials :maxdepth: 1 tutorials/simple-experiment.md + tutorials/gradient-optimization.md tutorials/queueserver.md tutorials/xrt-demo.md tutorials/xrt-kb-mirrors.md diff --git a/docs/source/tutorials/gradient-optimization.md b/docs/source/tutorials/gradient-optimization.md index 1db27d21..40487091 100644 --- a/docs/source/tutorials/gradient-optimization.md +++ b/docs/source/tutorials/gradient-optimization.md @@ -19,7 +19,7 @@ In this tutorial, you will learn the three core concepts of Blop: **DOFs** (the First, let's import what we need and start the data infrastructure: -```python +```{code-cell} ipython3 import logging import time import warnings @@ -39,7 +39,7 @@ from blop.gradient import SCP, Scipy, ScipyCFG logging.getLogger("httpx").setLevel(logging.WARNING) ``` -```python +```{code-cell} ipython3 # Start a local Tiled server for data storage tiled_server = SimpleTiledServer() @@ -54,7 +54,7 @@ RE.subscribe(tiled_writer) Bluesky controls devices through protocols. For this tutorial, we create simple simulated "movable" devices. In real experiments, you would use [Ophyd](https://blueskyproject.io/ophyd-async) devices or similar—the code below is just boilerplate to simulate hardware: -```python +```{code-cell} ipython3 class AlwaysSuccessfulStatus(Status): def add_callback(self, callback) -> None: callback(self) @@ -109,7 +109,7 @@ class MovableSignal(ReadableSignal, NamedMovable): **DOFs** (degrees of freedom) are the parameters the optimizer can adjust. **Objectives** are what you want to optimize. Here we define two DOFs (`x1` and `x2`) that can range from -5 to 5, and one objective (the Himmelblau function) that we want to minimize: -```python +```{code-cell} ipython3 x1 = MovableSignal("x1", initial_value=0.1) x2 = MovableSignal("x2", initial_value=0.23) @@ -127,7 +127,7 @@ sensors = [] The **evaluation function** computes objective values from experimental data. After each run, Blop calls this function with the run's unique ID and the suggestions that were tried. It returns the computed objective values: -```python +```{code-cell} ipython3 class Himmelblau2DEvaluation: def __init__(self, tiled_client: Container): self.tiled_client = tiled_client @@ -160,7 +160,7 @@ class Himmelblau2DEvaluation: The **Agent** brings everything together. Create one with your DOFs, objectives, and evaluation function, then run the optimization: -```python +```{code-cell} ipython3 agent = Scipy.Agent( sensors=sensors, dofs=dofs, @@ -177,7 +177,7 @@ RE(agent.optimize(10)) Sometimes a default **Agent** optimization may not do all that you'd like. We expose a configuration object called ScipyCFG and a pure scipy interface so that the classic parameters of scipy minimize can be tweaked (and some multipoint sampling can be used). -```python +```{code-cell} ipython3 config = ScipyCFG(dofs=dofs, objective=objectives[0], optimizer=SCP.Dual_Annealing, threads=4, max_iter=2, eps=0.1) agent = Scipy( sensors=sensors, @@ -192,7 +192,7 @@ res_uid = RE(agent.optimize(20, n_points=2)) Scipy is a local optimizer so it doesn't have internal point tracking, but we can to grab it from our datastore. -```python +```{code-cell} ipython3 import numpy as np import matplotlib.pyplot as plt import pandas as pd @@ -223,11 +223,11 @@ plt.title("Visualizing Scipy's traversal of Himmelblau") Seeing the sample history -```python +```{code-cell} ipython3 pd.DataFrame(data=res, columns=cols) ``` -```python +```{code-cell} ipython3 print(agent.get_best_points()) ``` From 2203342253b3cab701080fbebf543d89826e959b Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 25 Jun 2026 14:41:38 -0400 Subject: [PATCH 31/43] 3.11 fix --- src/blop/gradient/Scipy.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/blop/gradient/Scipy.py b/src/blop/gradient/Scipy.py index b9e5b50f..b0b9dcab 100644 --- a/src/blop/gradient/Scipy.py +++ b/src/blop/gradient/Scipy.py @@ -90,9 +90,11 @@ def Agent( blop.ax.Agent """ - - if optimizer not in SCP: - raise ValueError(f"optimizer {optimizer} not in supported optimizers:{list(SCP)}") + try: + if optimizer not in SCP: + raise ValueError(f"optimizer {optimizer} not in supported optimizers:{list(SCP)}") + except TypeError: + ... if len(objectives) > 1: raise ValueError("Multiple Objectives are not supported for gradient optimizers") config = ScipyCFG( From b9a8f350172dd286d22f43978c09e72cc2cea117 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 25 Jun 2026 14:49:40 -0400 Subject: [PATCH 32/43] fix for 3.11 tests --- src/blop/gradient/optimizer.py | 2 +- src/blop/tests/gradient/test_scipy.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/blop/gradient/optimizer.py b/src/blop/gradient/optimizer.py index 844f3bed..115f8532 100644 --- a/src/blop/gradient/optimizer.py +++ b/src/blop/gradient/optimizer.py @@ -136,7 +136,7 @@ def call(kws=None): ) else: - raise NotImplementedError("") + raise NotImplementedError(f"optimizer {config.optimizer} not in supported optimizers:{list(SCP)}") def mini_worker(): try: diff --git a/src/blop/tests/gradient/test_scipy.py b/src/blop/tests/gradient/test_scipy.py index d586d67d..2f5cdb8e 100644 --- a/src/blop/tests/gradient/test_scipy.py +++ b/src/blop/tests/gradient/test_scipy.py @@ -294,7 +294,7 @@ def test_agent_invalid_optimizer_enum(mock_evaluation_function, mock_acquisition objective = Objective(name="test_objective", minimize=False) readable = ReadableSignal(name="test_readable") - with pytest.raises(ValueError, match="optimizer.*not in supported optimizers"): + with pytest.raises((ValueError, NotImplementedError), match="optimizer.*not in supported optimizers"): Scipy.Agent( sensors=[readable], dofs=[dof], From cb5ccbc9ae1c604a66dc76dad56fe8b5190e24e5 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 25 Jun 2026 15:19:12 -0400 Subject: [PATCH 33/43] seperated scipy and optimizer tests --- src/blop/tests/gradient/test_optimizer.py | 325 +++++++++++++++++++ src/blop/tests/gradient/test_scipy.py | 373 ++-------------------- 2 files changed, 353 insertions(+), 345 deletions(-) create mode 100644 src/blop/tests/gradient/test_optimizer.py diff --git a/src/blop/tests/gradient/test_optimizer.py b/src/blop/tests/gradient/test_optimizer.py new file mode 100644 index 00000000..d1dec2b9 --- /dev/null +++ b/src/blop/tests/gradient/test_optimizer.py @@ -0,0 +1,325 @@ +import time +from unittest.mock import MagicMock + +import pytest + +from blop.ax import Objective, RangeDOF +from blop.gradient import SCP, ScipyCFG, ScipyOptimizer +from blop.protocols import ID_KEY, AcquisitionPlan, EvaluationFunction + +from ..conftest import MovableSignal + + +@pytest.fixture(scope="function") +def mock_evaluation_function(): + return MagicMock(spec=EvaluationFunction) + + +@pytest.fixture(scope="function") +def mock_acquisition_plan(): + return MagicMock(spec=AcquisitionPlan) + + +@pytest.fixture(scope="function") +def optimizer_prep(): + movable1 = MovableSignal(name="test_movable1") + movable2 = MovableSignal(name="test_movable2") + dof1 = RangeDOF(actuator=movable1, bounds=(0, 10), parameter_type="float") + dof2 = RangeDOF(actuator=movable2, bounds=(0, 10), parameter_type="float") + objective = Objective(name="test_objective", minimize=False) + config = ScipyCFG( + dofs=[dof1, dof2], + objective=objective, + threads=4, + rescale=[2.0, 3.0], + ) + return ScipyOptimizer(config, timeout=5) + + +# ============================================================================ +# PHASE 2: Optimizer Algorithm Variations Tests +# ============================================================================ + + +@pytest.mark.parametrize("optimizer", [SCP.Default, SCP.BFGS, SCP.LBFGS, SCP.Dual_Annealing]) +def test_scipy_optimizer_algorithms(mock_evaluation_function, mock_acquisition_plan, optimizer): + """Test ScipyOptimizer with different SCP algorithms.""" + movable1 = MovableSignal(name="test_movable1") + movable2 = MovableSignal(name="test_movable2") + dof1 = RangeDOF(actuator=movable1, bounds=(0, 10), parameter_type="float") + dof2 = RangeDOF(actuator=movable2, bounds=(0, 10), parameter_type="float") + objective = Objective(name="test_objective", minimize=False) + + config = ScipyCFG( + dofs=[dof1, dof2], + objective=objective, + optimizer=optimizer, + max_iter=10, + ) + + opt = ScipyOptimizer(config, timeout=5) + assert opt._active is not None + opt.close() + + +def test_scipy_optimizer_bfgs_specific(mock_evaluation_function, mock_acquisition_plan): + """Test ScipyOptimizer explicitly with BFGS.""" + movable1 = MovableSignal(name="test_movable1") + movable2 = MovableSignal(name="test_movable2") + dof1 = RangeDOF(actuator=movable1, bounds=(0, 10), parameter_type="float") + dof2 = RangeDOF(actuator=movable2, bounds=(0, 10), parameter_type="float") + objective = Objective(name="test_objective", minimize=False) + + config = ScipyCFG( + dofs=[dof1, dof2], + objective=objective, + optimizer=SCP.BFGS, + max_iter=10, + ) + + opt = ScipyOptimizer(config, timeout=5) + assert opt.final is None # No optimization run yet + opt.close() + + +def test_scipy_optimizer_dual_annealing_specific(mock_evaluation_function, mock_acquisition_plan): + """Test ScipyOptimizer explicitly with Dual_Annealing.""" + movable = MovableSignal(name="test_movable") + dof = RangeDOF(actuator=movable, bounds=(0, 10), parameter_type="float") + objective = Objective(name="test_objective", minimize=False) + + config = ScipyCFG( + dofs=[dof], + objective=objective, + optimizer=SCP.Dual_Annealing, + ) + + opt = ScipyOptimizer(config, timeout=5) + assert opt.final is None # No optimization run yet + opt.close() + + +def test_scipy_optimizer_threads_none(mock_evaluation_function, mock_acquisition_plan): + """Test ScipyOptimizer with threads=None (no parallelization).""" + movable = MovableSignal(name="test_movable") + dof = RangeDOF(actuator=movable, bounds=(0, 10), parameter_type="float") + objective = Objective(name="test_objective", minimize=False) + + config = ScipyCFG( + dofs=[dof], + objective=objective, + threads=None, + ) + + opt = ScipyOptimizer(config, timeout=5) + assert opt._thread_pool is None # No thread pool when threads=None + opt.close() + + +def test_scipy_optimizer_threads_multiple(mock_evaluation_function, mock_acquisition_plan): + """Test ScipyOptimizer with multiple threads.""" + movable = MovableSignal(name="test_movable") + dof = RangeDOF(actuator=movable, bounds=(0, 10), parameter_type="float") + objective = Objective(name="test_objective", minimize=False) + + config = ScipyCFG( + dofs=[dof], + objective=objective, + threads=2, + ) + + opt = ScipyOptimizer(config, timeout=5) + # Configuration accepted + opt.close() + + +# ============================================================================ +# PHASE 3: Rescaling & Parameter Handling Tests +# ============================================================================ + + +def test_rescaling_suggest_output(optimizer_prep): + """Test suggest() respects rescaling (scaled input → unscaled output).""" + suggestions = optimizer_prep.suggest(1) + assert len(suggestions) == 1 + + # Suggested values should be in original (unscaled) space + # Bounds are (0, 10), so suggestions should be in [0, 10] + assert 0 <= suggestions[0]["test_movable1"] <= 10 + assert 0 <= suggestions[0]["test_movable2"] <= 10 + optimizer_prep.close() + + +def test_rescaling_ingest_parameters(optimizer_prep): + """Test ingest() processes scaled parameters correctly.""" + # Suggest first + optimizer_prep.suggest(1) + # Ingest with outcome + optimizer_prep.ingest([{"test_movable1": 2.5, "test_movable2": 5.0, "test_objective": 0.8, ID_KEY: 0}]) + optimizer_prep.close() + + +def test_get_best_points_scaling(optimizer_prep): + """Test get_best_points() with scaling works (verify basic structure).""" + # Set final result manually (simulate completed optimization) + optimizer_prep.final = ScipyOptimizer.Result( + x=[2.5, 3.0], # Scaled values + fun=0.85, + nit=15, + status=0, + ) + + best = optimizer_prep.get_best_points() + assert isinstance(best, list) + assert len(best) == 3 # (trial_idx, params_dict, metrics_dict) + assert best[0] == 14 # nit - 1 + assert "test_movable1" in best[1] + assert "test_movable2" in best[1] + optimizer_prep.close() + +# ============================================================================ +# PHASE 4: Error Handling & Validation Tests +# ============================================================================ + + +def test_ingest_raises_on_unknown_id(optimizer_prep): + """Test ingest() raises ValueError when ID not in _active requests.""" + # Try to ingest with unknown ID + with pytest.raises(ValueError, match="optimizer did not expect to receive an update"): + optimizer_prep.ingest([{"test_movable1": 5.0, "test_movable2": 5.0, "test_objective": 0.5, ID_KEY: 999}]) + + optimizer_prep.close() + + +def test_ingest_force_resiliance_skips_unknown_id(optimizer_prep): + """Test ingest() with force_resiliance=True skips unknown IDs.""" + # Enable resiliance + optimizer_prep.force_resiliance = True + + # This should NOT raise (unknown IDs are skipped) + optimizer_prep.ingest([{"test_movable1": 5.0, "test_movable2": 5.0, "test_objective": 0.5, ID_KEY: 999}]) + + optimizer_prep.close() + + +def test_ingest_missing_objective_name(optimizer_prep): + """Test ingest() raises ValueError if objective name missing from data.""" + # Suggest first to create an active request + optimizer_prep.suggest(1) + + # Try to ingest without objective value + with pytest.raises(KeyError): + optimizer_prep.ingest([{"test_movable1": 5.0, "test_movable2": 5.0, ID_KEY: 0}]) # Missing "test_objective" + + optimizer_prep.close() + + +def test_optimizer_close_cancels_futures(optimizer_prep): + """Test ScipyOptimizer.close() cancels all active futures.""" + # Suggest to create active futures + optimizer_prep.suggest(2) + active_count = len(optimizer_prep._active) + assert active_count > 0 + + # Close should cancel all futures + optimizer_prep.close() + # All futures should now have exceptions set + for future_wrapper in optimizer_prep._active.values(): + assert future_wrapper.future.done() + + +def test_suggest_before_optimization(optimizer_prep): + """Test suggest() before any optimization returns expected state.""" + # Suggest before any ingest + suggestions = optimizer_prep.suggest(1) + assert len(suggestions) == 1 + assert "_id" in suggestions[0] + optimizer_prep.close() + + +# ============================================================================ +# PHASE 6: State Management & Context Manager Tests +# ============================================================================ + + +def test_scipy_optimizer_context_manager(mock_evaluation_function, mock_acquisition_plan): + """Test ScipyOptimizer context manager protocol (__enter__/__exit__).""" + movable = MovableSignal(name="test_movable") + dof = RangeDOF(actuator=movable, bounds=(0, 10), parameter_type="float") + objective = Objective(name="test_objective", minimize=False) + + config = ScipyCFG(dofs=[dof], objective=objective) + + with ScipyOptimizer(config, timeout=5) as opt: + assert opt is not None + time.sleep(0.1) + suggestions = opt.suggest(1) + assert len(suggestions) == 1 + + +def test_scipy_optimizer_session_reinit(mock_evaluation_function, mock_acquisition_plan): + """Test ScipyOptimizer.session() reinitializes state.""" + movable = MovableSignal(name="test_movable") + dof = RangeDOF(actuator=movable, bounds=(0, 10), parameter_type="float") + objective = Objective(name="test_objective", minimize=False) + + config = ScipyCFG(dofs=[dof], objective=objective) + + opt = ScipyOptimizer(config, timeout=5) + opt.suggest(1) + + # Call session to reinitialize + opt.session(config, timeout=5) + time.sleep(0.1) + # State should be reset + assert opt._increment == 1 + assert len(opt._active) == 1 + opt.close() + + +def test_get_best_points_intermediate_only(optimizer_prep): + """Test get_best_points() with only intermediate results (final=None).""" + # Set intermediate result manually (simulate partway through optimization) + optimizer_prep.intermediate = ScipyOptimizer.Result( + x=[5.0, -5.0], + fun=0.7, + nit=5, + status=0, + ) + + best = optimizer_prep.get_best_points() + assert len(best) == 3 + assert best[0] == 4 # nit - 1 + optimizer_prep.close() + + +def test_get_best_points_final_preferred(optimizer_prep): + """Test get_best_points() prefers final over intermediate.""" + # Set both intermediate and final + optimizer_prep.intermediate = ScipyOptimizer.Result( + x=[5.0, -5.0], + fun=0.7, + nit=5, + status=0, + ) + optimizer_prep.final = ScipyOptimizer.Result( + x=[7.0, -5.0], + fun=0.9, + nit=10, + status=0, + ) + + best = optimizer_prep.get_best_points() + # best[2] is a dict with objective as key; get the first (only) value + objective_value = list(best[2].values())[0] + assert objective_value == 0.9 # Uses final result + optimizer_prep.close() + + +def test_get_best_points_no_optimization_raises(optimizer_prep): + """Test get_best_points() raises ValueError if no optimization run.""" + # No optimization run: both intermediate and final are None + with pytest.raises(ValueError, match="no optimization epoch has been recorded"): + optimizer_prep.get_best_points() + + optimizer_prep.close() diff --git a/src/blop/tests/gradient/test_scipy.py b/src/blop/tests/gradient/test_scipy.py index 2f5cdb8e..bc55c1f6 100644 --- a/src/blop/tests/gradient/test_scipy.py +++ b/src/blop/tests/gradient/test_scipy.py @@ -4,7 +4,7 @@ import pytest from blop.ax import Objective, RangeDOF -from blop.gradient import SCP, Scipy, ScipyCFG, ScipyOptimizer +from blop.gradient import Scipy, ScipyCFG, ScipyOptimizer from blop.protocols import ID_KEY, AcquisitionPlan, EvaluationFunction from ..conftest import MovableSignal, ReadableSignal @@ -45,33 +45,7 @@ def agent_prep(mock_evaluation_function, mock_acquisition_plan): @pytest.fixture(scope="function") -def rescaled_agent_prep(mock_evaluation_function, mock_acquisition_plan): - movable1 = MovableSignal(name="test_movable1") - movable2 = MovableSignal(name="test_movable2") - readable = ReadableSignal(name="test_readable") - dof1 = RangeDOF(actuator=movable1, bounds=(0, 10), parameter_type="float") - dof2 = RangeDOF(actuator=movable2, bounds=(0, 10), parameter_type="float") - objective = Objective(name="test_objective", minimize=False) - config = ScipyCFG( - dofs=[dof1, dof2], - objective=objective, - threads=4, - rescale=[2.0, 3.0], - ) - agent = Scipy( - sensors=[readable], - config=config, - evaluation_function=mock_evaluation_function, - acquisition_plan=mock_acquisition_plan, - name="test_experiment", - timeout=5, - ) - time.sleep(0.1) - return agent - - -@pytest.fixture(scope="function") -def single_dof_agent_prep(mock_evaluation_function, mock_acquisition_plan): +def secoundary_agent_prep(mock_evaluation_function, mock_acquisition_plan): movable = MovableSignal(name="test_movable") readable = ReadableSignal(name="test_readable") dof = RangeDOF(actuator=movable, bounds=(0, 10), parameter_type="float") @@ -320,343 +294,52 @@ def test_agent_multiple_objectives_not_supported(mock_evaluation_function, mock_ evaluation_function=mock_evaluation_function, ) - -# ============================================================================ -# PHASE 2: Optimizer Algorithm Variations Tests -# ============================================================================ - - -@pytest.mark.parametrize("optimizer", [SCP.Default, SCP.BFGS, SCP.LBFGS, SCP.Dual_Annealing]) -def test_scipy_optimizer_algorithms(mock_evaluation_function, mock_acquisition_plan, optimizer): - """Test ScipyOptimizer with different SCP algorithms.""" - movable1 = MovableSignal(name="test_movable1") - movable2 = MovableSignal(name="test_movable2") - dof1 = RangeDOF(actuator=movable1, bounds=(0, 10), parameter_type="float") - dof2 = RangeDOF(actuator=movable2, bounds=(0, 10), parameter_type="float") - objective = Objective(name="test_objective", minimize=False) - - config = ScipyCFG( - dofs=[dof1, dof2], - objective=objective, - optimizer=optimizer, - max_iter=10, - ) - - opt = ScipyOptimizer(config, timeout=5) - assert opt._active is not None - opt.close() - - -def test_scipy_optimizer_bfgs_specific(mock_evaluation_function, mock_acquisition_plan): - """Test ScipyOptimizer explicitly with BFGS.""" - movable1 = MovableSignal(name="test_movable1") - movable2 = MovableSignal(name="test_movable2") - dof1 = RangeDOF(actuator=movable1, bounds=(0, 10), parameter_type="float") - dof2 = RangeDOF(actuator=movable2, bounds=(0, 10), parameter_type="float") - objective = Objective(name="test_objective", minimize=False) - - config = ScipyCFG( - dofs=[dof1, dof2], - objective=objective, - optimizer=SCP.BFGS, - max_iter=10, - ) - - opt = ScipyOptimizer(config, timeout=5) - assert opt.final is None # No optimization run yet - opt.close() - - -def test_scipy_optimizer_dual_annealing_specific(mock_evaluation_function, mock_acquisition_plan): - """Test ScipyOptimizer explicitly with Dual_Annealing.""" - movable = MovableSignal(name="test_movable") - dof = RangeDOF(actuator=movable, bounds=(0, 10), parameter_type="float") - objective = Objective(name="test_objective", minimize=False) - - config = ScipyCFG( - dofs=[dof], - objective=objective, - optimizer=SCP.Dual_Annealing, - ) - - opt = ScipyOptimizer(config, timeout=5) - assert opt.final is None # No optimization run yet - opt.close() - - -def test_scipy_optimizer_threads_none(mock_evaluation_function, mock_acquisition_plan): - """Test ScipyOptimizer with threads=None (no parallelization).""" - movable = MovableSignal(name="test_movable") - dof = RangeDOF(actuator=movable, bounds=(0, 10), parameter_type="float") - objective = Objective(name="test_objective", minimize=False) - - config = ScipyCFG( - dofs=[dof], - objective=objective, - threads=None, - ) - - opt = ScipyOptimizer(config, timeout=5) - assert opt._thread_pool is None # No thread pool when threads=None - opt.close() - - -def test_scipy_optimizer_threads_multiple(mock_evaluation_function, mock_acquisition_plan): - """Test ScipyOptimizer with multiple threads.""" - movable = MovableSignal(name="test_movable") - dof = RangeDOF(actuator=movable, bounds=(0, 10), parameter_type="float") - objective = Objective(name="test_objective", minimize=False) - - config = ScipyCFG( - dofs=[dof], - objective=objective, - threads=2, - ) - - opt = ScipyOptimizer(config, timeout=5) - # Configuration accepted - opt.close() - - -# ============================================================================ -# PHASE 3: Rescaling & Parameter Handling Tests -# ============================================================================ - - -def test_rescaling_suggest_output(rescaled_agent_prep): - """Test suggest() respects rescaling (scaled input → unscaled output).""" - suggestions = rescaled_agent_prep.suggest(1) - assert len(suggestions) == 1 - - # Suggested values should be in original (unscaled) space - # Bounds are (0, 10), so suggestions should be in [0, 10] - assert 0 <= suggestions[0]["test_movable1"] <= 10 - assert 0 <= suggestions[0]["test_movable2"] <= 10 - rescaled_agent_prep._optimizer.close() - - -def test_rescaling_ingest_parameters(rescaled_agent_prep): - """Test ingest() processes scaled parameters correctly.""" - # Suggest first - rescaled_agent_prep.suggest(1) - # Ingest with outcome - rescaled_agent_prep.ingest([{"test_movable1": 2.5, "test_movable2": 5.0, "test_objective": 0.8, ID_KEY: 0}]) - rescaled_agent_prep._optimizer.close() - - -def test_get_best_points_scaling(rescaled_agent_prep): - """Test get_best_points() with scaling works (verify basic structure).""" - # Set final result manually (simulate completed optimization) - rescaled_agent_prep._optimizer.final = ScipyOptimizer.Result( - x=[2.5, 3.0], # Scaled values - fun=0.85, - nit=15, - status=0, - ) - - best = rescaled_agent_prep._optimizer.get_best_points() - assert isinstance(best, list) - assert len(best) == 3 # (trial_idx, params_dict, metrics_dict) - assert best[0] == 14 # nit - 1 - assert "test_movable1" in best[1] - assert "test_movable2" in best[1] - rescaled_agent_prep._optimizer.close() - - -# ============================================================================ -# PHASE 4: Error Handling & Validation Tests -# ============================================================================ - - -def test_ingest_raises_on_unknown_id(agent_prep): - """Test ingest() raises ValueError when ID not in _active requests.""" - # Try to ingest with unknown ID - with pytest.raises(ValueError, match="optimizer did not expect to receive an update"): - agent_prep.ingest([{"test_movable1": 5.0, "test_movable2": 5.0, "test_objective": 0.5, ID_KEY: 999}]) - - agent_prep._optimizer.close() - - -def test_ingest_force_resiliance_skips_unknown_id(agent_prep): - """Test ingest() with force_resiliance=True skips unknown IDs.""" - # Enable resiliance - agent_prep._optimizer.force_resiliance = True - - # This should NOT raise (unknown IDs are skipped) - agent_prep.ingest([{"test_movable1": 5.0, "test_movable2": 5.0, "test_objective": 0.5, ID_KEY: 999}]) - - agent_prep._optimizer.close() - - -def test_ingest_missing_objective_name(agent_prep): - """Test ingest() raises ValueError if objective name missing from data.""" - # Suggest first to create an active request - agent_prep.suggest(1) - - # Try to ingest without objective value - with pytest.raises(KeyError): - agent_prep.ingest([{"test_movable1": 5.0, "test_movable2": 5.0, ID_KEY: 0}]) # Missing "test_objective" - - agent_prep._optimizer.close() - - -def test_optimizer_close_cancels_futures(agent_prep): - """Test ScipyOptimizer.close() cancels all active futures.""" - # Suggest to create active futures - agent_prep.suggest(2) - active_count = len(agent_prep._optimizer._active) - assert active_count > 0 - - # Close should cancel all futures - agent_prep._optimizer.close() - # All futures should now have exceptions set - for future_wrapper in agent_prep._optimizer._active.values(): - assert future_wrapper.future.done() - - -def test_suggest_before_optimization(agent_prep): - """Test suggest() before any optimization returns expected state.""" - # Suggest before any ingest - suggestions = agent_prep.suggest(1) - assert len(suggestions) == 1 - assert "_id" in suggestions[0] - agent_prep._optimizer.close() - - # ============================================================================ # PHASE 5: Callback Management Tests # ============================================================================ -def test_subscribe_callback(single_dof_agent_prep): +def test_subscribe_callback(secoundary_agent_prep): """Test subscribe() adds callback to list.""" callback = MagicMock() - initial_count = len(single_dof_agent_prep.callbacks) - single_dof_agent_prep.subscribe(callback) + initial_count = len(secoundary_agent_prep.callbacks) + secoundary_agent_prep.subscribe(callback) - assert len(single_dof_agent_prep.callbacks) == initial_count + 1 - assert callback in single_dof_agent_prep.callbacks - single_dof_agent_prep._optimizer.close() + assert len(secoundary_agent_prep.callbacks) == initial_count + 1 + assert callback in secoundary_agent_prep.callbacks + secoundary_agent_prep._optimizer.close() -def test_subscribe_duplicate_raises(single_dof_agent_prep): +def test_subscribe_duplicate_raises(secoundary_agent_prep): """Test subscribe() raises ValueError on duplicate callback.""" callback = MagicMock() - single_dof_agent_prep.subscribe(callback) + secoundary_agent_prep.subscribe(callback) with pytest.raises(ValueError, match="already subscribed"): - single_dof_agent_prep.subscribe(callback) + secoundary_agent_prep.subscribe(callback) - single_dof_agent_prep._optimizer.close() + secoundary_agent_prep._optimizer.close() -def test_unsubscribe_callback(single_dof_agent_prep): +def test_unsubscribe_callback(secoundary_agent_prep): """Test unsubscribe() removes callback from list.""" callback = MagicMock() - single_dof_agent_prep.subscribe(callback) - assert callback in single_dof_agent_prep.callbacks + secoundary_agent_prep.subscribe(callback) + assert callback in secoundary_agent_prep.callbacks - single_dof_agent_prep.unsubscribe(callback) - assert callback not in single_dof_agent_prep.callbacks - single_dof_agent_prep._optimizer.close() + secoundary_agent_prep.unsubscribe(callback) + assert callback not in secoundary_agent_prep.callbacks + secoundary_agent_prep._optimizer.close() -def test_unsubscribe_not_subscribed_raises(single_dof_agent_prep): +def test_unsubscribe_not_subscribed_raises(secoundary_agent_prep): """Test unsubscribe() raises ValueError if not subscribed.""" callback = MagicMock() with pytest.raises(ValueError): - single_dof_agent_prep.unsubscribe(callback) - - single_dof_agent_prep._optimizer.close() - - -# ============================================================================ -# PHASE 6: State Management & Context Manager Tests -# ============================================================================ - - -def test_scipy_optimizer_context_manager(mock_evaluation_function, mock_acquisition_plan): - """Test ScipyOptimizer context manager protocol (__enter__/__exit__).""" - movable = MovableSignal(name="test_movable") - dof = RangeDOF(actuator=movable, bounds=(0, 10), parameter_type="float") - objective = Objective(name="test_objective", minimize=False) - - config = ScipyCFG(dofs=[dof], objective=objective) - - with ScipyOptimizer(config, timeout=5) as opt: - assert opt is not None - time.sleep(0.1) - suggestions = opt.suggest(1) - assert len(suggestions) == 1 - - -def test_get_best_points_intermediate_only(single_dof_agent_prep): - """Test get_best_points() with only intermediate results (final=None).""" - # Set intermediate result manually (simulate partway through optimization) - single_dof_agent_prep._optimizer.intermediate = ScipyOptimizer.Result( - x=[5.0], - fun=0.7, - nit=5, - status=0, - ) + secoundary_agent_prep.unsubscribe(callback) - best = single_dof_agent_prep._optimizer.get_best_points() - assert len(best) == 3 - assert best[0] == 4 # nit - 1 - single_dof_agent_prep._optimizer.close() - - -def test_get_best_points_final_preferred(single_dof_agent_prep): - """Test get_best_points() prefers final over intermediate.""" - # Set both intermediate and final - single_dof_agent_prep._optimizer.intermediate = ScipyOptimizer.Result( - x=[5.0], - fun=0.7, - nit=5, - status=0, - ) - single_dof_agent_prep._optimizer.final = ScipyOptimizer.Result( - x=[7.0], - fun=0.9, - nit=10, - status=0, - ) - - best = single_dof_agent_prep._optimizer.get_best_points() - # best[2] is a dict with objective as key; get the first (only) value - objective_value = list(best[2].values())[0] - assert objective_value == 0.9 # Uses final result - single_dof_agent_prep._optimizer.close() - - -def test_get_best_points_no_optimization_raises(single_dof_agent_prep): - """Test get_best_points() raises ValueError if no optimization run.""" - # No optimization run: both intermediate and final are None - with pytest.raises(ValueError, match="no optimization epoch has been recorded"): - single_dof_agent_prep._optimizer.get_best_points() - - single_dof_agent_prep._optimizer.close() - - -def test_scipy_optimizer_session_reinit(mock_evaluation_function, mock_acquisition_plan): - """Test ScipyOptimizer.session() reinitializes state.""" - movable = MovableSignal(name="test_movable") - dof = RangeDOF(actuator=movable, bounds=(0, 10), parameter_type="float") - objective = Objective(name="test_objective", minimize=False) - - config = ScipyCFG(dofs=[dof], objective=objective) - - opt = ScipyOptimizer(config, timeout=5) - opt.suggest(1) - - # Call session to reinitialize - opt.session(config, timeout=5) - time.sleep(0.1) - # State should be reset - assert opt._increment == 1 - assert len(opt._active) == 1 - opt.close() + secoundary_agent_prep._optimizer.close() # ============================================================================ @@ -664,12 +347,12 @@ def test_scipy_optimizer_session_reinit(mock_evaluation_function, mock_acquisiti # ============================================================================ -def test_scipy_single_dof(single_dof_agent_prep): +def test_scipy_secoundary(secoundary_agent_prep): """Test Scipy with single DOF (one parameter).""" - suggestions = single_dof_agent_prep.suggest(1) + suggestions = secoundary_agent_prep.suggest(1) assert len(suggestions) == 1 assert "test_movable" in suggestions[0] - single_dof_agent_prep._optimizer.close() + secoundary_agent_prep._optimizer.close() def test_scipy_large_rescale_factors(mock_evaluation_function, mock_acquisition_plan): @@ -704,18 +387,18 @@ def test_scipy_large_rescale_factors(mock_evaluation_function, mock_acquisition_ agent._optimizer.close() -def test_suggest_after_final_optimization(single_dof_agent_prep): +def test_suggest_after_final_optimization(secoundary_agent_prep): """Test suggest() after final optimization returns final result parameterization.""" # Set final optimization result - single_dof_agent_prep._optimizer.final = ScipyOptimizer.Result( + secoundary_agent_prep._optimizer.final = ScipyOptimizer.Result( x=[7.0], fun=0.95, nit=20, status=0, ) - suggestions = single_dof_agent_prep._optimizer.suggest() + suggestions = secoundary_agent_prep._optimizer.suggest() assert len(suggestions) == 1 assert suggestions[0]["test_movable"] == 7.0 assert suggestions[0][ID_KEY] == 20 - single_dof_agent_prep._optimizer.close() + secoundary_agent_prep._optimizer.close() From 7ec77618e0313e544b8e3781288b978d136dd88b Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 25 Jun 2026 15:19:40 -0400 Subject: [PATCH 34/43] ruff --- src/blop/tests/gradient/test_optimizer.py | 1 + src/blop/tests/gradient/test_scipy.py | 1 + 2 files changed, 2 insertions(+) diff --git a/src/blop/tests/gradient/test_optimizer.py b/src/blop/tests/gradient/test_optimizer.py index d1dec2b9..65eced9b 100644 --- a/src/blop/tests/gradient/test_optimizer.py +++ b/src/blop/tests/gradient/test_optimizer.py @@ -177,6 +177,7 @@ def test_get_best_points_scaling(optimizer_prep): assert "test_movable2" in best[1] optimizer_prep.close() + # ============================================================================ # PHASE 4: Error Handling & Validation Tests # ============================================================================ diff --git a/src/blop/tests/gradient/test_scipy.py b/src/blop/tests/gradient/test_scipy.py index bc55c1f6..44cdccc2 100644 --- a/src/blop/tests/gradient/test_scipy.py +++ b/src/blop/tests/gradient/test_scipy.py @@ -294,6 +294,7 @@ def test_agent_multiple_objectives_not_supported(mock_evaluation_function, mock_ evaluation_function=mock_evaluation_function, ) + # ============================================================================ # PHASE 5: Callback Management Tests # ============================================================================ From e0f1ecfbd1452e2074f0363059aef5f521579913 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 25 Jun 2026 16:54:58 -0400 Subject: [PATCH 35/43] edit to default resiliance in agent and cleaning of active queue with close --- src/blop/gradient/Scipy.py | 2 + src/blop/gradient/optimizer.py | 6 +-- src/blop/tests/gradient/test_integration.py | 43 +++++++++++++++++++++ 3 files changed, 48 insertions(+), 3 deletions(-) create mode 100644 src/blop/tests/gradient/test_integration.py diff --git a/src/blop/gradient/Scipy.py b/src/blop/gradient/Scipy.py index b0b9dcab..ecf23acd 100644 --- a/src/blop/gradient/Scipy.py +++ b/src/blop/gradient/Scipy.py @@ -43,6 +43,7 @@ def __init__( self._acquisition_plan = acquisition_plan self.timeout = kwargs.pop("timeout", 200) self._optimizer = ScipyOptimizer(self.config, timeout=self.timeout) + self._optimizer.force_resiliance = self.resiliance = kwargs.pop("resiliance", True) self._readable_cache: dict[str, InferredReadable] = {} self._callbacks: list[CallbackBase] = [OptimizationLogger()] self._callback_router = OptimizationCallbackRouter(self._callbacks) @@ -247,6 +248,7 @@ def optimize(self, iterations=10, n_points=1): if self._optimizer.final is not None: self.config.initial = self._optimizer.final.x self._optimizer = ScipyOptimizer(self.config, timeout=self.timeout) + self._optimizer.force_resiliance = self.resiliance optimize_plan = optimize( self.to_optimization_problem(), iterations=iterations, diff --git a/src/blop/gradient/optimizer.py b/src/blop/gradient/optimizer.py index 115f8532..a919d55a 100644 --- a/src/blop/gradient/optimizer.py +++ b/src/blop/gradient/optimizer.py @@ -244,10 +244,10 @@ def get_best_points(self) -> list[tuple[Any, Mapping, Mapping]]: cart = [ result.nit - 1, cast(Mapping, dict(zip(self._params, vector, strict=True))), - cast(Mapping, {self._objective: result.fun}), + cast(Mapping, {self._objective.name: result.fun}), ] return cart def close(self): - for fut in self._active.values(): - fut.future.set_exception(KeyboardInterrupt("Execution has been suspended")) + for ind in list(self._active.keys()): + self._active.pop(ind).future.set_exception(KeyboardInterrupt("Execution has been suspended")) diff --git a/src/blop/tests/gradient/test_integration.py b/src/blop/tests/gradient/test_integration.py new file mode 100644 index 00000000..c8fef7e6 --- /dev/null +++ b/src/blop/tests/gradient/test_integration.py @@ -0,0 +1,43 @@ +import time +from unittest.mock import MagicMock + +import pytest +from bluesky import RunEngine + +from blop.ax import Objective, RangeDOF +from blop.gradient import Scipy, ScipyCFG, ScipyOptimizer +from blop.protocols import ID_KEY, AcquisitionPlan, EvaluationFunction + +from ..conftest import MovableSignal, ReadableSignal + + +def test_integrated_iteration(): + movable = MovableSignal(name="test_movable") + readable = ReadableSignal(name="test_readable") + dof = RangeDOF(actuator=movable, bounds=(0, 1E-4), parameter_type="float") + objective = Objective(name="test_objective", minimize=False) + config = ScipyCFG(dofs=[dof], objective=objective) + + class deflating_evaluation(EvaluationFunction): + def __init__(self): + self.counter = 0 + super().__init__() + def __call__(self, uid, suggestions): + self.counter += 1 + return [s | {objective.name: 2**(-.5 * self.counter)} for s in suggestions] + + agent = Scipy( + sensors=[readable], + config=config, + evaluation_function=deflating_evaluation(), + timeout=5, + ) + agent._optimizer.force_resiliance = True + RE = RunEngine({}) + RE(agent.optimize(20)) + time.sleep(.1) + assert agent._optimizer.final is not None + assert agent._optimizer.intermediate is not None + assert not agent._optimizer._active + RE(agent.optimize(20)) + assert agent.get_best_points() is not None From 5eb102137f66512fab8711f95b70182c651b4fc4 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 25 Jun 2026 16:55:51 -0400 Subject: [PATCH 36/43] ruff --- src/blop/tests/gradient/test_integration.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/blop/tests/gradient/test_integration.py b/src/blop/tests/gradient/test_integration.py index c8fef7e6..1c593e01 100644 --- a/src/blop/tests/gradient/test_integration.py +++ b/src/blop/tests/gradient/test_integration.py @@ -1,12 +1,10 @@ import time -from unittest.mock import MagicMock -import pytest from bluesky import RunEngine from blop.ax import Objective, RangeDOF -from blop.gradient import Scipy, ScipyCFG, ScipyOptimizer -from blop.protocols import ID_KEY, AcquisitionPlan, EvaluationFunction +from blop.gradient import Scipy, ScipyCFG +from blop.protocols import EvaluationFunction from ..conftest import MovableSignal, ReadableSignal @@ -14,7 +12,7 @@ def test_integrated_iteration(): movable = MovableSignal(name="test_movable") readable = ReadableSignal(name="test_readable") - dof = RangeDOF(actuator=movable, bounds=(0, 1E-4), parameter_type="float") + dof = RangeDOF(actuator=movable, bounds=(0, 1e-4), parameter_type="float") objective = Objective(name="test_objective", minimize=False) config = ScipyCFG(dofs=[dof], objective=objective) @@ -22,9 +20,10 @@ class deflating_evaluation(EvaluationFunction): def __init__(self): self.counter = 0 super().__init__() + def __call__(self, uid, suggestions): self.counter += 1 - return [s | {objective.name: 2**(-.5 * self.counter)} for s in suggestions] + return [s | {objective.name: 2 ** (-0.5 * self.counter)} for s in suggestions] agent = Scipy( sensors=[readable], @@ -35,7 +34,7 @@ def __call__(self, uid, suggestions): agent._optimizer.force_resiliance = True RE = RunEngine({}) RE(agent.optimize(20)) - time.sleep(.1) + time.sleep(0.1) assert agent._optimizer.final is not None assert agent._optimizer.intermediate is not None assert not agent._optimizer._active From d969affb7a08c6f0a8abcba2bfdbaf1fc9b6d829 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 25 Jun 2026 17:05:54 -0400 Subject: [PATCH 37/43] cheeky shift to dual annealing for code cov --- src/blop/tests/gradient/test_integration.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/blop/tests/gradient/test_integration.py b/src/blop/tests/gradient/test_integration.py index 1c593e01..010e92c3 100644 --- a/src/blop/tests/gradient/test_integration.py +++ b/src/blop/tests/gradient/test_integration.py @@ -3,7 +3,7 @@ from bluesky import RunEngine from blop.ax import Objective, RangeDOF -from blop.gradient import Scipy, ScipyCFG +from blop.gradient import SCP, Scipy, ScipyCFG from blop.protocols import EvaluationFunction from ..conftest import MovableSignal, ReadableSignal @@ -14,7 +14,7 @@ def test_integrated_iteration(): readable = ReadableSignal(name="test_readable") dof = RangeDOF(actuator=movable, bounds=(0, 1e-4), parameter_type="float") objective = Objective(name="test_objective", minimize=False) - config = ScipyCFG(dofs=[dof], objective=objective) + config = ScipyCFG(dofs=[dof], objective=objective, optimizer=SCP.Dual_Annealing) class deflating_evaluation(EvaluationFunction): def __init__(self): From 66b416ac9d075172a61ddf760555be88781a83fb Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Wed, 22 Jul 2026 13:05:14 -0400 Subject: [PATCH 38/43] ruff woke up angry this morning --- src/blop/ax/dof.py | 4 +--- src/blop/gradient/Scipy.py | 22 ++++++++++++-------- src/blop/gradient/__init__.py | 2 ++ src/blop/gradient/optimizer.py | 37 +++++++++++++++++++++++++--------- 4 files changed, 44 insertions(+), 21 deletions(-) diff --git a/src/blop/ax/dof.py b/src/blop/ax/dof.py index ada55ec1..8decd66a 100644 --- a/src/blop/ax/dof.py +++ b/src/blop/ax/dof.py @@ -126,9 +126,7 @@ def to_ax_parameter_config(self) -> RangeParameterConfig: ) def to_scipy_bounds(self) -> Bounds: - """ - convert DOF to the Scipy equivalent Bounds - """ + """Convert DOF to the Scipy equivalent Bounds.""" return Bounds(lb=self.bounds[0], ub=self.bounds[1]) diff --git a/src/blop/gradient/Scipy.py b/src/blop/gradient/Scipy.py index ecf23acd..439bb909 100644 --- a/src/blop/gradient/Scipy.py +++ b/src/blop/gradient/Scipy.py @@ -1,3 +1,5 @@ +"""Scipy optimization power class for fast start QOL and Ax like agent behavior.""" + from collections.abc import Mapping, Sequence from typing import Any, cast @@ -24,8 +26,10 @@ class Scipy: """ A convenience interface associated with running optimizations with Scipy, providing similar syntax to the Ax Agent - (allowing drop in swapping as much as possible). Useful as a cover in for all the QOL provided by the Agent object. - """ + (allowing drop in swapping as much as possible). + + Useful as a cover in for all the QOL provided by the Agent object. + """ # noqa: D205 def __init__( self, @@ -63,7 +67,7 @@ def Agent( **kwargs: Any, ): """ - A nearly emcompassing interface to provide strong interoperability with Ax agent formalism. + An emcompassing interface to provide strong interoperability with Ax agent formalism. Parameters ---------- @@ -82,15 +86,16 @@ def Agent( **kwargs : Any Additional keyword arguments to configure the Ax experiment. + See Also + -------- + blop.ax.Agent + Notes ----- This is a nearly drop in replacement for Ax agent sans dof + outcome constraints and checkpointing - See Also - -------- - blop.ax.Agent - """ + """ # noqa: D401 try: if optimizer not in SCP: raise ValueError(f"optimizer {optimizer} not in supported optimizers:{list(SCP)}") @@ -175,7 +180,7 @@ def unsubscribe(self, callback: CallbackBase) -> None: def to_optimization_problem(self) -> OptimizationProblem: """ - Construct an optimization problem from the Scipy Base class + Construct an optimization problem from the Scipy Base class. Creates an immutable :class:`blop.protocols.OptimizationProblem` that encapsulates all components needed for optimization. This is typically @@ -245,6 +250,7 @@ def ingest(self, points: list[dict]) -> None: self._optimizer.ingest(points) def optimize(self, iterations=10, n_points=1): + """Optimization plan wrapper used by the agent interface.""" if self._optimizer.final is not None: self.config.initial = self._optimizer.final.x self._optimizer = ScipyOptimizer(self.config, timeout=self.timeout) diff --git a/src/blop/gradient/__init__.py b/src/blop/gradient/__init__.py index df7d71dc..6c760147 100644 --- a/src/blop/gradient/__init__.py +++ b/src/blop/gradient/__init__.py @@ -1,3 +1,5 @@ +"""Scipy Backend for Pertubative gradient and in house global optimizers.""" + from .optimizer import SCP, ScipyCFG, ScipyOptimizer from .Scipy import Scipy diff --git a/src/blop/gradient/optimizer.py b/src/blop/gradient/optimizer.py index a919d55a..0f9f0ed9 100644 --- a/src/blop/gradient/optimizer.py +++ b/src/blop/gradient/optimizer.py @@ -1,3 +1,5 @@ +"""Core Scipy optimizer porting scipy algorithms.""" + from collections import OrderedDict from collections.abc import Mapping, Sequence from concurrent.futures import Future, ThreadPoolExecutor @@ -15,6 +17,8 @@ class SCP(StrEnum): + """Enumeration of all optimizers currently supported/tested.""" + Default = "Default" BFGS = "BFGS" LBFGS = "L-BFGS-B" @@ -23,6 +27,12 @@ class SCP(StrEnum): @dataclass class ScipyCFG: + """ + Configuration dataclass that encompasses the core optimization problem and extra parameters within Scipy. + + Used as the optimizer/generation function is not injectable like in Ax + """ + dofs: Sequence[RangeDOF] objective: Objective # dof_constraints: Sequence[DOFConstraint] | None = None @@ -36,17 +46,17 @@ class ScipyCFG: class ScipyOptimizer(Optimizer): - """ - An optimizer object to supply an interactive interface for the scipy optimizers, with some caveats. - """ + """An optimizer object to supply an interactive interface for the scipy optimizers, with some caveats.""" @dataclass - class Request: + class _Request: args: tuple future: Future @dataclass class Result: + """Class to unify Optimize Result and Scipy Result.""" + x: list[float | int] fun: float nit: int @@ -56,13 +66,19 @@ def __init__(self, config: ScipyCFG, timeout: int | None = 200): self.session(config=config, timeout=timeout) def session(self, config: ScipyCFG, timeout: int | None = None): + """ + Through path for initialization and stateful reinitialization of optimization. + + derived so that mutiple initializations and lifetimes can be used for optimization. + Such as the standard ScipyOptimizer(...) call or a following "with" + """ self._params: list[str] = [] self._bounds: list[tuple[Any, Any]] = [] self._increment: int = 0 self._objective: Objective = config.objective self.force_resiliance = False # kinda hidden for now self._scale = np.ones(len(config.dofs)) - self._active: dict[int, ScipyOptimizer.Request] = OrderedDict() + self._active: dict[int, ScipyOptimizer._Request] = OrderedDict() self.intermediate: OptimizeResult | ScipyOptimizer.Result | None = None self.final: OptimizeResult | ScipyOptimizer.Result | None = None self.SUGGESTION_TIMEOUT = timeout @@ -82,10 +98,8 @@ def session(self, config: ScipyCFG, timeout: int | None = None): _x = np.array(config.initial) / self._scale def cost(x): # thread safety needs timeout so there is not infinite hang on programs - """ - simple cooperative thread that defers evaluation of cost call by scipy to the run engine - """ - req = self.Request(args=x, future=Future()) + """Cooperative thread that defers evaluation of cost call by scipy to the run engine.""" + req = self._Request(args=x, future=Future()) self._active[self._increment] = req self._increment += 1 res = req.future.result(timeout=self.SUGGESTION_TIMEOUT) @@ -160,14 +174,16 @@ def mini_worker(): return self def __enter__(self): + """Magic convenience to use "with" to better control thread lifetime.""" return self def __exit__(self, exc_type, exc_val, exc_tb): + """Lifetime threads when using with.""" self.close() def suggest(self, num_points: int | None = None) -> list[dict]: """ - Returns a set of points in the input space, to be evaulated next. + Provide a set of points in the input space, to be evaulated next. The "_id" key is optional and can be used to identify suggested trials for later evaluation and ingestion. @@ -249,5 +265,6 @@ def get_best_points(self) -> list[tuple[Any, Mapping, Mapping]]: return cart def close(self): + """Clear out futures to allow cleanup of threads.""" for ind in list(self._active.keys()): self._active.pop(ind).future.set_exception(KeyboardInterrupt("Execution has been suspended")) From 5e427f48492351d838a4d75efc96d870999f41f4 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Wed, 22 Jul 2026 16:46:35 -0400 Subject: [PATCH 39/43] added lot more optimizers --- src/blop/gradient/optimizer.py | 51 ++++++++++++++++------- src/blop/tests/gradient/test_optimizer.py | 2 +- 2 files changed, 37 insertions(+), 16 deletions(-) diff --git a/src/blop/gradient/optimizer.py b/src/blop/gradient/optimizer.py index 0f9f0ed9..ebb386d7 100644 --- a/src/blop/gradient/optimizer.py +++ b/src/blop/gradient/optimizer.py @@ -17,11 +17,31 @@ class SCP(StrEnum): - """Enumeration of all optimizers currently supported/tested.""" + """Enumeration of all optimizers currently supported/tested. + + #TODO all commented optimizers require jacobian and are currently suspended in impl until its clear that external + # gradient sampling is necesary and clearly cross implementable. likely necesary for noisy opts but this is clearly + # a usage defined addition. + """ Default = "Default" + + Nelder_Mead = "Nelder-Mead" + Powell = "Powell" + CG = "CG" BFGS = "BFGS" + # Newton_CG = "Newton-CG" LBFGS = "L-BFGS-B" + TNC = "TNC" + COBYLA = "COBYLA" + COBYQA = "COBYQA" + SLSQP = "SLSQP" + Trust_Constr = "trust-constr" + # Dogleg = "dogleg" + # Trust_NCG = "trust-ncg" + # Trust_Exact = "trust-exact" + # Trust_Krylov = "trust-krylov" + Dual_Annealing = "dual annealing" @@ -110,7 +130,10 @@ def cost(x): # thread safety needs timeout so there is not infinite hang on pro kw = {} self._thread_pool = None if config.max_iter is not None: - kw["max_iter"] = config.max_iter + if config.optimizer is not SCP.Trust_Constr: + kw["max_iter"] = config.max_iter + else: + kw["maxiter"] = config.max_iter if config.eps is not None: kw["eps"] = config.eps @@ -120,19 +143,7 @@ def default_callback(intermediate_result: OptimizeResult): self.intermediate = intermediate_result self.intermediate.nit = self._increment - if config.optimizer in (SCP.Default, SCP.BFGS, SCP.LBFGS): - - def call(kws=None): - self.final = minimize( - fun=cost, - x0=_x, - method=config.optimizer if config.optimizer != SCP.Default else None, - bounds=self._bounds, - callback=default_callback, - options=kws, - ) - - elif config.optimizer in (SCP.Dual_Annealing): + if config.optimizer is SCP.Dual_Annealing: def dual_callback(x, f, context): print(f"callback on opt val {f} with current best of {self.intermediate}") @@ -148,7 +159,17 @@ def call(kws=None): callback=dual_callback, minimizer_kwargs={"callback": default_callback, "bounds": self._bounds, "options": kws}, ) + elif config.optimizer in SCP: + def call(kws=None): + self.final = minimize( + fun=cost, + x0=_x, + method=config.optimizer if config.optimizer != SCP.Default else None, + bounds=self._bounds, + callback=default_callback, + options=kws, + ) else: raise NotImplementedError(f"optimizer {config.optimizer} not in supported optimizers:{list(SCP)}") diff --git a/src/blop/tests/gradient/test_optimizer.py b/src/blop/tests/gradient/test_optimizer.py index 65eced9b..d7fa2e93 100644 --- a/src/blop/tests/gradient/test_optimizer.py +++ b/src/blop/tests/gradient/test_optimizer.py @@ -41,7 +41,7 @@ def optimizer_prep(): # ============================================================================ -@pytest.mark.parametrize("optimizer", [SCP.Default, SCP.BFGS, SCP.LBFGS, SCP.Dual_Annealing]) +@pytest.mark.parametrize("optimizer", list(SCP)) def test_scipy_optimizer_algorithms(mock_evaluation_function, mock_acquisition_plan, optimizer): """Test ScipyOptimizer with different SCP algorithms.""" movable1 = MovableSignal(name="test_movable1") From 1666ebc0c433e3ebb0d309f887a89f89b58922d4 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Wed, 22 Jul 2026 17:26:31 -0400 Subject: [PATCH 40/43] added SHGO --- src/blop/gradient/optimizer.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/blop/gradient/optimizer.py b/src/blop/gradient/optimizer.py index ebb386d7..d0b9500c 100644 --- a/src/blop/gradient/optimizer.py +++ b/src/blop/gradient/optimizer.py @@ -9,7 +9,7 @@ from typing import Any, cast import numpy as np -from scipy.optimize import OptimizeResult, dual_annealing, minimize +from scipy.optimize import OptimizeResult, dual_annealing, minimize, shgo from blop.ax.dof import RangeDOF from blop.ax.objective import Objective @@ -43,6 +43,7 @@ class SCP(StrEnum): # Trust_Krylov = "trust-krylov" Dual_Annealing = "dual annealing" + SHGO = "SHGO" @dataclass @@ -159,6 +160,24 @@ def call(kws=None): callback=dual_callback, minimizer_kwargs={"callback": default_callback, "bounds": self._bounds, "options": kws}, ) + elif config.optimizer is SCP.SHGO: + # TODO the utility of SHGO is quite underepresented in this implementation, much more thought needs to go into + # how parameters are passed through this formalism + print("warning: as a global optimizer, SHGO does not use an X0 but its own Sobol sampling") + + def shgo_callback(x): + print(f"callback point {x} with current best of {self.intermediate}") + # self.intermediate = self.Result(x, -1, self._increment, 1) + + def call(kws=None): + workers = kws.pop("workers", 1) if kws else 1 + self.final = shgo( + func=cost, + bounds=self._bounds, + callback=shgo_callback, + minimizer_kwargs={"callback": default_callback, "options": kws}, + workers=workers, + ) elif config.optimizer in SCP: def call(kws=None): @@ -181,6 +200,7 @@ def mini_worker(): call(kws=kw) else: call(kws=kw) + except (KeyboardInterrupt, TimeoutError): # have to have timeout, made it so that it can be restored to its state on agent auto reboot if self.final: From ca6818f606a2e8447ba884d0b136b69168557dd4 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Wed, 22 Jul 2026 17:30:55 -0400 Subject: [PATCH 41/43] python 3.11 again doing its complaints (fix?) --- src/blop/gradient/optimizer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/blop/gradient/optimizer.py b/src/blop/gradient/optimizer.py index d0b9500c..fae10e63 100644 --- a/src/blop/gradient/optimizer.py +++ b/src/blop/gradient/optimizer.py @@ -178,7 +178,7 @@ def call(kws=None): minimizer_kwargs={"callback": default_callback, "options": kws}, workers=workers, ) - elif config.optimizer in SCP: + elif config.optimizer in list(SCP): def call(kws=None): self.final = minimize( From c1487ba0e75dfb3625405340102ad6b66c35407c Mon Sep 17 00:00:00 2001 From: MTakahashi-KWH <60798296+MTakahashi-KWH@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:19:52 -0400 Subject: [PATCH 42/43] Apply suggestion from @thopkins32 dropping crossover Co-authored-by: Thomas Hopkins --- src/blop/ax/dof.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/blop/ax/dof.py b/src/blop/ax/dof.py index 8decd66a..5da5becb 100644 --- a/src/blop/ax/dof.py +++ b/src/blop/ax/dof.py @@ -8,7 +8,6 @@ from ax import ChoiceParameterConfig, RangeParameterConfig from ax.api.types import TParameterValue -from scipy.optimize import Bounds from ..protocols import Actuator From 563f3286f1676456be17d341047d268e09c928f3 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Wed, 29 Jul 2026 12:12:13 -0400 Subject: [PATCH 43/43] renaming and reorganizing of additions --- src/blop/ax/dof.py | 4 - src/blop/{gradient => scipy}/__init__.py | 2 +- src/blop/scipy/configs.py | 151 ++++++++++++++++++ src/blop/{gradient => scipy}/optimizer.py | 56 +------ .../{gradient/Scipy.py => scipy/scipy.py} | 5 +- src/blop/tests/gradient/test_integration.py | 12 +- src/blop/tests/gradient/test_optimizer.py | 3 +- src/blop/tests/gradient/test_scipy.py | 4 +- 8 files changed, 166 insertions(+), 71 deletions(-) rename src/blop/{gradient => scipy}/__init__.py (88%) create mode 100644 src/blop/scipy/configs.py rename src/blop/{gradient => scipy}/optimizer.py (86%) rename src/blop/{gradient/Scipy.py => scipy/scipy.py} (98%) diff --git a/src/blop/ax/dof.py b/src/blop/ax/dof.py index 5da5becb..28b2a399 100644 --- a/src/blop/ax/dof.py +++ b/src/blop/ax/dof.py @@ -124,10 +124,6 @@ def to_ax_parameter_config(self) -> RangeParameterConfig: scaling=self.scaling, ) - def to_scipy_bounds(self) -> Bounds: - """Convert DOF to the Scipy equivalent Bounds.""" - return Bounds(lb=self.bounds[0], ub=self.bounds[1]) - @dataclass(frozen=True, kw_only=True, eq=False) class ChoiceDOF(DOF): diff --git a/src/blop/gradient/__init__.py b/src/blop/scipy/__init__.py similarity index 88% rename from src/blop/gradient/__init__.py rename to src/blop/scipy/__init__.py index 6c760147..1ddbaebb 100644 --- a/src/blop/gradient/__init__.py +++ b/src/blop/scipy/__init__.py @@ -1,6 +1,6 @@ """Scipy Backend for Pertubative gradient and in house global optimizers.""" from .optimizer import SCP, ScipyCFG, ScipyOptimizer -from .Scipy import Scipy +from .scipy import Scipy __all__ = ["SCP", "ScipyCFG", "Scipy", "ScipyOptimizer"] diff --git a/src/blop/scipy/configs.py b/src/blop/scipy/configs.py new file mode 100644 index 00000000..3bcb31a9 --- /dev/null +++ b/src/blop/scipy/configs.py @@ -0,0 +1,151 @@ +"""Collection of data and configuration objects used by scipy package.""" + +from collections.abc import Sequence +from dataclasses import dataclass +from enum import StrEnum +from typing import Literal, cast + +from scipy.optimize import Bounds + +from blop.protocols import Actuator + + +@dataclass(frozen=True, kw_only=True, eq=False) +class RangeDOF: + """ + A degree of freedom that is a continuous range. + + Use this class for continuous parameters that can take any value within + specified bounds, such as motor positions, voltages, or temperatures. + + Attributes + ---------- + bounds : tuple[float, float] + The search domain of the DOF as (lower_bound, upper_bound). + parameter_type : Literal["float", "int"] + The data type of the DOF. Use "float" for continuous values or "int" for integer values. + step_size : float | None, optional + The step size of the DOF. If provided, the optimizer will only suggest values + at multiples of this step size. + scaling : Literal["linear", "log"] | None, optional + The scaling of the DOF. Use "log" for parameters that span orders of magnitude. + + Examples + -------- + Define a continuous DOF with a name (for non-actuator parameters): + + >>> from blop.scipy.configs import RangeDOF + >>> dof = RangeDOF(name="voltage", bounds=(-10.0, 10.0), parameter_type="float") + + Define an integer DOF with a step size: + + >>> dof = RangeDOF(name="num_exposures", bounds=(1, 100), parameter_type="int", step_size=1) + + For examples with actuators, see :doc:`/tutorials/simple-experiment`. + """ + + name: str | None = None + actuator: Actuator | str | None = None + bounds: tuple[float, float] + parameter_type: Literal["float", "int"] + step_size: float | None = None + scaling: Literal["linear", "log"] | None = None + + @property + def parameter_name(self) -> str: + """The parameter name used internally by Ax.""" + if isinstance(self.actuator, Actuator): + param_name = self.actuator.name + elif isinstance(self.actuator, str): + param_name = self.actuator + else: + param_name = cast(str, self.name) + return param_name + + def to_scipy_bounds(self) -> Bounds: + """Convert DOF to the Scipy equivalent Bounds.""" + return Bounds(lb=self.bounds[0], ub=self.bounds[1]) + + +@dataclass(frozen=True, kw_only=True) +class Objective: + """ + An objective to optimize. + + An objective represents a measurable outcome that you want to optimize. + The optimizer will try to minimize or maximize this outcome based on the + acquired data and evaluation function. + + Attributes + ---------- + name : str + The name of the objective. This must match the key returned by the + evaluation function for this outcome. + minimize : bool + Whether to minimize or maximize the objective. Set to True for minimization + (e.g., reducing beam width) or False for maximization (e.g., increasing intensity). + + Examples + -------- + Define an objective to maximize beam intensity: + + >>> from blop.scipy.objective import Objective + >>> objective = Objective(name="beam_intensity", minimize=False) + + Define an objective to minimize beam width: + + >>> objective = Objective(name="beam_width", minimize=True) + """ + + name: str + minimize: bool + + +class SCP(StrEnum): + """Enumeration of all optimizers currently supported/tested. + + #TODO all commented optimizers require jacobian and are currently suspended in impl until its clear that external + # gradient sampling is necesary and clearly cross implementable. likely necesary for noisy opts but this is clearly + # a usage defined addition. + """ + + Default = "Default" + + Nelder_Mead = "Nelder-Mead" + Powell = "Powell" + CG = "CG" + BFGS = "BFGS" + # Newton_CG = "Newton-CG" + LBFGS = "L-BFGS-B" + TNC = "TNC" + COBYLA = "COBYLA" + COBYQA = "COBYQA" + SLSQP = "SLSQP" + Trust_Constr = "trust-constr" + # Dogleg = "dogleg" + # Trust_NCG = "trust-ncg" + # Trust_Exact = "trust-exact" + # Trust_Krylov = "trust-krylov" + + Dual_Annealing = "dual annealing" + SHGO = "SHGO" + + +@dataclass +class ScipyCFG: + """ + Configuration dataclass that encompasses the core optimization problem and extra parameters within Scipy. + + Used as the optimizer/generation function is not injectable like in Ax + """ + + dofs: Sequence[RangeDOF] + objective: Objective + # dof_constraints: Sequence[DOFConstraint] | None = None + # outcome_constraints: Sequence[OutcomeConstraint] | None = None + optimizer: SCP = SCP.Default + initial: Sequence[float] | None = None + rescale: Sequence[float] | float | None = None + max_iter: int | None = 100 + eps: float | None = None + threads: int | None = None diff --git a/src/blop/gradient/optimizer.py b/src/blop/scipy/optimizer.py similarity index 86% rename from src/blop/gradient/optimizer.py rename to src/blop/scipy/optimizer.py index fae10e63..fca8fdf2 100644 --- a/src/blop/gradient/optimizer.py +++ b/src/blop/scipy/optimizer.py @@ -1,69 +1,17 @@ """Core Scipy optimizer porting scipy algorithms.""" from collections import OrderedDict -from collections.abc import Mapping, Sequence +from collections.abc import Mapping from concurrent.futures import Future, ThreadPoolExecutor from dataclasses import dataclass -from enum import StrEnum from threading import Thread from typing import Any, cast import numpy as np from scipy.optimize import OptimizeResult, dual_annealing, minimize, shgo -from blop.ax.dof import RangeDOF -from blop.ax.objective import Objective from blop.protocols import ID_KEY, Optimizer - - -class SCP(StrEnum): - """Enumeration of all optimizers currently supported/tested. - - #TODO all commented optimizers require jacobian and are currently suspended in impl until its clear that external - # gradient sampling is necesary and clearly cross implementable. likely necesary for noisy opts but this is clearly - # a usage defined addition. - """ - - Default = "Default" - - Nelder_Mead = "Nelder-Mead" - Powell = "Powell" - CG = "CG" - BFGS = "BFGS" - # Newton_CG = "Newton-CG" - LBFGS = "L-BFGS-B" - TNC = "TNC" - COBYLA = "COBYLA" - COBYQA = "COBYQA" - SLSQP = "SLSQP" - Trust_Constr = "trust-constr" - # Dogleg = "dogleg" - # Trust_NCG = "trust-ncg" - # Trust_Exact = "trust-exact" - # Trust_Krylov = "trust-krylov" - - Dual_Annealing = "dual annealing" - SHGO = "SHGO" - - -@dataclass -class ScipyCFG: - """ - Configuration dataclass that encompasses the core optimization problem and extra parameters within Scipy. - - Used as the optimizer/generation function is not injectable like in Ax - """ - - dofs: Sequence[RangeDOF] - objective: Objective - # dof_constraints: Sequence[DOFConstraint] | None = None - # outcome_constraints: Sequence[OutcomeConstraint] | None = None - optimizer: SCP = SCP.Default - initial: Sequence[float] | None = None - rescale: Sequence[float] | float | None = None - max_iter: int | None = 100 - eps: float | None = None - threads: int | None = None +from blop.scipy.configs import SCP, Objective, ScipyCFG class ScipyOptimizer(Optimizer): diff --git a/src/blop/gradient/Scipy.py b/src/blop/scipy/scipy.py similarity index 98% rename from src/blop/gradient/Scipy.py rename to src/blop/scipy/scipy.py index 439bb909..b769985c 100644 --- a/src/blop/gradient/Scipy.py +++ b/src/blop/scipy/scipy.py @@ -6,8 +6,6 @@ import bluesky.preprocessors as bpp from bluesky.callbacks import CallbackBase -from blop.ax.dof import RangeDOF -from blop.ax.objective import Objective from blop.callbacks.logger import OptimizationLogger from blop.callbacks.router import OptimizationCallbackRouter from blop.plans import optimize @@ -18,9 +16,10 @@ OptimizationProblem, Sensor, ) +from blop.scipy.configs import SCP, Objective, RangeDOF, ScipyCFG from blop.utils import InferredReadable -from .optimizer import SCP, ScipyCFG, ScipyOptimizer +from .optimizer import ScipyOptimizer class Scipy: diff --git a/src/blop/tests/gradient/test_integration.py b/src/blop/tests/gradient/test_integration.py index 010e92c3..90e55f15 100644 --- a/src/blop/tests/gradient/test_integration.py +++ b/src/blop/tests/gradient/test_integration.py @@ -1,10 +1,8 @@ -import time - from bluesky import RunEngine -from blop.ax import Objective, RangeDOF -from blop.gradient import SCP, Scipy, ScipyCFG from blop.protocols import EvaluationFunction +from blop.scipy import Scipy +from blop.scipy.configs import SCP, Objective, RangeDOF, ScipyCFG from ..conftest import MovableSignal, ReadableSignal @@ -33,10 +31,10 @@ def __call__(self, uid, suggestions): ) agent._optimizer.force_resiliance = True RE = RunEngine({}) - RE(agent.optimize(20)) - time.sleep(0.1) - assert agent._optimizer.final is not None + RE(agent.optimize(40)) + # time.sleep(0.1) assert agent._optimizer.intermediate is not None assert not agent._optimizer._active RE(agent.optimize(20)) + assert agent._optimizer.final is not None assert agent.get_best_points() is not None diff --git a/src/blop/tests/gradient/test_optimizer.py b/src/blop/tests/gradient/test_optimizer.py index d7fa2e93..cfd0bfab 100644 --- a/src/blop/tests/gradient/test_optimizer.py +++ b/src/blop/tests/gradient/test_optimizer.py @@ -4,8 +4,9 @@ import pytest from blop.ax import Objective, RangeDOF -from blop.gradient import SCP, ScipyCFG, ScipyOptimizer from blop.protocols import ID_KEY, AcquisitionPlan, EvaluationFunction +from blop.scipy import ScipyOptimizer +from blop.scipy.configs import SCP, ScipyCFG from ..conftest import MovableSignal diff --git a/src/blop/tests/gradient/test_scipy.py b/src/blop/tests/gradient/test_scipy.py index 44cdccc2..d90c2361 100644 --- a/src/blop/tests/gradient/test_scipy.py +++ b/src/blop/tests/gradient/test_scipy.py @@ -4,8 +4,10 @@ import pytest from blop.ax import Objective, RangeDOF -from blop.gradient import Scipy, ScipyCFG, ScipyOptimizer from blop.protocols import ID_KEY, AcquisitionPlan, EvaluationFunction +from blop.scipy import ScipyOptimizer +from blop.scipy.configs import ScipyCFG +from blop.scipy.scipy import Scipy from ..conftest import MovableSignal, ReadableSignal