From c1541cdbf0960224d9b96c25efce47759b8ec90a Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Wed, 29 Apr 2026 17:19:59 -0400 Subject: [PATCH 001/116] 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 a469f0a6..a99074d4 100644 --- a/src/blop/ax/dof.py +++ b/src/blop/ax/dof.py @@ -6,6 +6,7 @@ from ax import ChoiceParameterConfig, RangeParameterConfig from ax.api.types import TParameterValue +from scipy.optimize import Bounds from ..protocols import Actuator @@ -118,6 +119,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) 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 5effe0af3e18e9fe1dec469189041c43509c60ee Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 30 Apr 2026 16:20:44 -0400 Subject: [PATCH 002/116] [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 2c1470a76ff761bb21b4a650066c0d26ec3a2fc9 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 30 Apr 2026 16:54:02 -0400 Subject: [PATCH 003/116] 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 fce266bee66809a0f2f84ca545109140a5a02d6b Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 30 Apr 2026 16:58:01 -0400 Subject: [PATCH 004/116] 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 0ff3994431f3d8855310e628674e0d1dbfd99552 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 30 Apr 2026 17:26:50 -0400 Subject: [PATCH 005/116] 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 0ceeda21e6c49defbb4310a7724472c102a755ff Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 30 Apr 2026 17:30:48 -0400 Subject: [PATCH 006/116] 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 25c388ecb3a2996771fcbd46da92d6e0c84dfb64 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Fri, 1 May 2026 14:00:42 -0400 Subject: [PATCH 007/116] [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 8dab4d7988fee7364820a3e910d042a054249b72 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Mon, 4 May 2026 10:19:27 -0400 Subject: [PATCH 008/116] 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 6d1e44efdda5e4f5f479bbf610c8340a567459ae Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 14 May 2026 18:42:28 -0400 Subject: [PATCH 009/116] 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 83d0e26b1f54ff8f57c53cf61f026d84117da8ea Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 14 May 2026 18:44:46 -0400 Subject: [PATCH 010/116] 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 28beeb72b22202af4ab784d77d98761866f7db33 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Fri, 15 May 2026 10:28:01 -0400 Subject: [PATCH 011/116] 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 73f026f53d3eb8728963dd28efff80430d0dac9c Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Mon, 22 Jun 2026 14:22:08 -0400 Subject: [PATCH 012/116] 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 cc6760e9b4077765d0261d6d75f05af4adfdd649 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Mon, 22 Jun 2026 15:00:01 -0400 Subject: [PATCH 013/116] 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 3bd6be08efa38ac6fee8645aa01e99504cce97e7 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Mon, 22 Jun 2026 15:05:09 -0400 Subject: [PATCH 014/116] 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 f4ead1ef82a5e74ffb478fab7100a781d16ad11d Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Mon, 22 Jun 2026 15:20:52 -0400 Subject: [PATCH 015/116] 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 4ec5846a87410f506e2d3df91727f6bfe5ae8722 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Mon, 22 Jun 2026 17:27:43 -0400 Subject: [PATCH 016/116] 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 ab626e4f1992c71ec83f99ab91b92a258e23e9c8 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Mon, 22 Jun 2026 17:30:27 -0400 Subject: [PATCH 017/116] 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 d806b80b9595316d85d2e2ab5c22635fe7aa5aaf Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Wed, 24 Jun 2026 10:48:03 -0400 Subject: [PATCH 018/116] 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 df39389f78258b995bd2a44b7eed5669c98c0217 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Wed, 24 Jun 2026 14:08:44 -0400 Subject: [PATCH 019/116] 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 44de99d79fd24a3c4f3447e1914f63d1851d95ea Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Wed, 24 Jun 2026 14:38:49 -0400 Subject: [PATCH 020/116] 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 0f3a3fa6a29dde3b35bbd010e5d7207d7ea96d11 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Wed, 24 Jun 2026 14:41:46 -0400 Subject: [PATCH 021/116] 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 50fb5fbf9fdd141ef3835f2b86a96dddb1e7c62c Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Wed, 24 Jun 2026 14:47:22 -0400 Subject: [PATCH 022/116] 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 720593cd7119fa574c26112d315978472cfaa124 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Wed, 24 Jun 2026 15:30:55 -0400 Subject: [PATCH 023/116] 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 f1e981a21b2b21e81aee1582cc86cfce637c426a Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 25 Jun 2026 11:10:31 -0400 Subject: [PATCH 024/116] 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 d3b0008d5fa6bef7d01a63aa76708f06a483c1a8 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 25 Jun 2026 11:14:28 -0400 Subject: [PATCH 025/116] 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 b772bc3ac72a14a6bf6963439a09f309410ac680 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 25 Jun 2026 11:28:00 -0400 Subject: [PATCH 026/116] 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 dc41ff1dcb9c191632123571df653f94db737e57 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 25 Jun 2026 12:20:27 -0400 Subject: [PATCH 027/116] 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 9e7c585c1541c8702253f38a070afbbfea920ca3 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 25 Jun 2026 12:38:13 -0400 Subject: [PATCH 028/116] 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 9459083244bb4582b6ad37217db426aaaf42e7f0 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 25 Jun 2026 12:47:32 -0400 Subject: [PATCH 029/116] 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 357fb1ff524bfef79b3f2d6024244ffd4604c3cd Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 25 Jun 2026 14:24:17 -0400 Subject: [PATCH 030/116] 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 e7e57b09..f0ce9f6a 100644 --- a/docs/source/tutorials.rst +++ b/docs/source/tutorials.rst @@ -6,5 +6,6 @@ Tutorials tutorials/README.md tutorials/simple-experiment.md + tutorials/gradient-optimization.md tutorials/queueserver.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 3b5e6936e1a5666462177bb26241a81f06f60d7a Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 25 Jun 2026 14:41:38 -0400 Subject: [PATCH 031/116] 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 601b5081e93df477024150740d27c68c00ffa6e8 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 25 Jun 2026 14:49:40 -0400 Subject: [PATCH 032/116] 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 0944010a538345e85ae394538626092ef657432f Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 25 Jun 2026 15:19:12 -0400 Subject: [PATCH 033/116] 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 3ba2135f6a5a96d4aa583ea1cdd1ff183672337d Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 25 Jun 2026 15:19:40 -0400 Subject: [PATCH 034/116] 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 38ed50d15e1dbcfc7181e103dc6e8d1ebe98466e Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 25 Jun 2026 16:54:58 -0400 Subject: [PATCH 035/116] 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 6e4d356d539fbf06a212f87909b499893cedba74 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 25 Jun 2026 16:55:51 -0400 Subject: [PATCH 036/116] 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 35d7245044a035f0c66732d977dcef51823e1f95 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 25 Jun 2026 17:05:54 -0400 Subject: [PATCH 037/116] 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 c09b7ba148fd1bd136259ce678389599f25fb4d7 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Wed, 22 Jul 2026 13:05:14 -0400 Subject: [PATCH 038/116] 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 9d861f20b3e7fc80c2e28ad1904b2c5326d76516 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Wed, 22 Jul 2026 16:46:35 -0400 Subject: [PATCH 039/116] 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 1024c3882a5bffb5958d24056b728f5f460f9333 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Wed, 22 Jul 2026 17:26:31 -0400 Subject: [PATCH 040/116] 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 4a607e33ff7ec5b743dd4bbb9894b68043c4b1dc Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Wed, 22 Jul 2026 17:30:55 -0400 Subject: [PATCH 041/116] 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 33d77ef9e614d96fd3651394473bab00365e10f1 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 042/116] 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 8a2b8878faab7c69692aaf921e4ad069219338c3 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Wed, 29 Jul 2026 12:12:13 -0400 Subject: [PATCH 043/116] 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 From e0693da4bff0dcfef38cf4d2271cd0c7705133a9 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 30 Jul 2026 00:11:32 -0400 Subject: [PATCH 044/116] first protocol prototype --- src/blop/scipy/inverter.py | 50 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 src/blop/scipy/inverter.py diff --git a/src/blop/scipy/inverter.py b/src/blop/scipy/inverter.py new file mode 100644 index 00000000..01b9bb09 --- /dev/null +++ b/src/blop/scipy/inverter.py @@ -0,0 +1,50 @@ +from dataclasses import asdict + +from scipy.optimize import OptimizeResult, dual_annealing, minimize, shgo + +from blop.scipy.configs import SCP, ScipyCFG +from blop.scipy.optimizer import ScipyOptimizer + + +class InnerOptimizer(): + def call(self, cost, callback, kws=None) -> ScipyOptimizer.Result | OptimizeResult: + raise NotImplementedError("Optimizer spec not Provided") + + +class Optimize(InnerOptimizer): + """ + Parameter Normalized implementation of scipy Optimize to be passed to a loop inversion. + + derives from inner optimizer protocol class + """ + + def __init__(self, optimizer: SCP, config: ScipyCFG) -> None: + self.optimizer = optimizer + self.config = config + + def call(self, cost, callback, kws=None) -> ScipyOptimizer.Result: + bounds = kws.pop("bounds", self.config.dofs) if kws else self.config.dofs + x0 = kws.pop("x0", self.config.initial) if kws else self.config.initial + return minimize( + fun=cost, + x0=x0, + method=self.config.optimizer if self.config.optimizer != SCP.Default else None, + bounds=bounds, + callback=callback, + options=kws, + ) + +class DualAnnealing(InnerOptimizer): + + def __init__(self, optimizer: SCP, config: ScipyCFG) -> None: + self.optimizer = optimizer + self.config = config + + def call(self, cost, callback, kws=None): + self.final = dual_annealing( + func=cost, + x0=_x, + bounds=self._bounds, + callback=dual_callback, + minimizer_kwargs={"callback": default_callback, "bounds": self._bounds, "options": kws}, + ) From e8010a4388f7c42627857452c0c5a64c937f17d7 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 30 Jul 2026 16:09:02 -0400 Subject: [PATCH 045/116] stable refactor, reorder next --- src/blop/scipy/__init__.py | 20 ++- src/blop/scipy/configs.py | 2 +- src/blop/scipy/inverter.py | 225 +++++++++++++++++++++----- src/blop/scipy/normalized.py | 234 +++++++++++++++++++++++++++ src/blop/scipy/scipy_v2.py | 304 +++++++++++++++++++++++++++++++++++ 5 files changed, 740 insertions(+), 45 deletions(-) create mode 100644 src/blop/scipy/normalized.py create mode 100644 src/blop/scipy/scipy_v2.py diff --git a/src/blop/scipy/__init__.py b/src/blop/scipy/__init__.py index 1ddbaebb..1ea47c8b 100644 --- a/src/blop/scipy/__init__.py +++ b/src/blop/scipy/__init__.py @@ -1,6 +1,20 @@ """Scipy Backend for Pertubative gradient and in house global optimizers.""" -from .optimizer import SCP, ScipyCFG, ScipyOptimizer -from .scipy import Scipy +from .configs import SCP, Objective, RangeDOF, ScipyCFG +from .inverter import OuterOptimizer +from .normalized import SHGO, DualAnnealing, Optimize +from .optimizer import ScipyOptimizer +from .scipy_v2 import Scipy -__all__ = ["SCP", "ScipyCFG", "Scipy", "ScipyOptimizer"] +__all__ = [ + "SCP", + "ScipyCFG", + "Scipy", + "ScipyOptimizer", + "DualAnnealing", + "Optimize", + "SHGO", + "OuterOptimizer", + "Objective", + "RangeDOF", +] diff --git a/src/blop/scipy/configs.py b/src/blop/scipy/configs.py index 3bcb31a9..8f1ec889 100644 --- a/src/blop/scipy/configs.py +++ b/src/blop/scipy/configs.py @@ -109,7 +109,7 @@ class SCP(StrEnum): # a usage defined addition. """ - Default = "Default" + Default = "L-BFGS-B" Nelder_Mead = "Nelder-Mead" Powell = "Powell" diff --git a/src/blop/scipy/inverter.py b/src/blop/scipy/inverter.py index 01b9bb09..c57cc10b 100644 --- a/src/blop/scipy/inverter.py +++ b/src/blop/scipy/inverter.py @@ -1,50 +1,193 @@ -from dataclasses import asdict +"""Core Scipy optimizer porting scipy algorithms.""" -from scipy.optimize import OptimizeResult, dual_annealing, minimize, shgo +from collections import OrderedDict +from collections.abc import Mapping +from concurrent.futures import Future, ThreadPoolExecutor +from threading import Thread +from typing import Any, cast -from blop.scipy.configs import SCP, ScipyCFG +import numpy as np +from scipy.optimize import OptimizeResult + +from blop.protocols import ID_KEY, Optimizer +from blop.scipy.configs import SCP, Objective, ScipyCFG +from blop.scipy.normalized import InnerOptimizer from blop.scipy.optimizer import ScipyOptimizer +ScipyResult = ScipyOptimizer.Result +_Request = ScipyOptimizer._Request -class InnerOptimizer(): - def call(self, cost, callback, kws=None) -> ScipyOptimizer.Result | OptimizeResult: - raise NotImplementedError("Optimizer spec not Provided") +class OuterOptimizer(Optimizer): + """An optimizer object to supply an interactive interface for the scipy optimizers, with some caveats.""" -class Optimize(InnerOptimizer): - """ - Parameter Normalized implementation of scipy Optimize to be passed to a loop inversion. + def __init__(self, optimizer: InnerOptimizer, config: ScipyCFG | None = None, timeout: int | None = 200): + self.optimizer = optimizer + self.session(config=config if config else optimizer.config, timeout=timeout) - derives from inner optimizer protocol class - """ + def session(self, config: ScipyCFG, timeout: int | None = None): + """ + Through path for initialization and stateful reinitialization of optimization. - def __init__(self, optimizer: SCP, config: ScipyCFG) -> None: - self.optimizer = optimizer - self.config = config - - def call(self, cost, callback, kws=None) -> ScipyOptimizer.Result: - bounds = kws.pop("bounds", self.config.dofs) if kws else self.config.dofs - x0 = kws.pop("x0", self.config.initial) if kws else self.config.initial - return minimize( - fun=cost, - x0=x0, - method=self.config.optimizer if self.config.optimizer != SCP.Default else None, - bounds=bounds, - callback=callback, - options=kws, - ) - -class DualAnnealing(InnerOptimizer): - - def __init__(self, optimizer: SCP, config: ScipyCFG) -> None: - self.optimizer = optimizer - self.config = config - - def call(self, cost, callback, kws=None): - self.final = dual_annealing( - func=cost, - x0=_x, - bounds=self._bounds, - callback=dual_callback, - minimizer_kwargs={"callback": default_callback, "bounds": self._bounds, "options": kws}, - ) + 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] = [dof.parameter_name for dof in config.dofs] + 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 + + def cost(x): # thread safety needs timeout so there is not infinite hang on programs + """Cooperative thread that defers evaluation of cost call by scipy to the run engine.""" + req = _Request(args=x, future=Future()) + self._active[self._increment] = req + self._increment += 1 + res = req.future.result(timeout=self.SUGGESTION_TIMEOUT) + if res is None: + raise ValueError("return value is not present") + return res + + kw: dict = {} + self._thread_pool = None + if config.max_iter is not None: + 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 + + 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 mini_worker(): + try: + if config.threads: + with ThreadPoolExecutor(max_workers=config.threads) as pool: + kw["workers"] = pool.map + self.optimizer.call(cost, default_callback, kws=kw) + else: + self.optimizer.call(cost, default_callback, kws=kw) + + except (KeyboardInterrupt, TimeoutError): + # have to have timeout, so made it 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 = ScipyResult(list(self.optimizer.x0), np.nan, nit=self._increment) + + self._t = Thread(target=mini_worker, name="optimizer") + self._t.start() + 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]: + """ + 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. + + 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.name: result.fun}), + ] + 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")) diff --git a/src/blop/scipy/normalized.py b/src/blop/scipy/normalized.py new file mode 100644 index 00000000..33ad9473 --- /dev/null +++ b/src/blop/scipy/normalized.py @@ -0,0 +1,234 @@ +"""Normalized SciPy optimizer wrappers used by the cooperative optimization loop.""" +from typing import Any + +import numpy as np +from scipy.optimize import OptimizeResult, dual_annealing, minimize, shgo + +from blop.scipy.configs import SCP, ScipyCFG +from blop.scipy.optimizer import ScipyOptimizer + +ScipyResult = ScipyOptimizer.Result + + +class InnerOptimizer: + """Protocol for SciPy optimizer wrappers used by the suggest/ingest loop. + + Subclasses adapt optimizer-specific call signatures into a shared + ``call(cost, callback, kws)`` interface. This keeps optimizer internals + decoupled from the cooperative optimization loop that requests suggestions, + evaluates them externally, and ingests outcomes. + """ + + def __init__(self, config: ScipyCFG, base_args: dict | None = None) -> None: + """Store normalized configuration varaibles. + + Parameters + ---------- + config : ScipyCFG + Normalized optimizer configuration, including default bounds, + initial values, and selected method. + base_args : dict, optional + Extra keyword arguments always forwarded to wrapped optimizer. + """ + self.config = config + self.base_args = base_args + self._bounds: list[tuple[Any, Any]] = [] + scale = np.ones(len(config.dofs)) + + if config.rescale is not None: + if isinstance(config.rescale, list): + scale = config.rescale + else: + scale *= config.rescale + + for ind, dof in enumerate(config.dofs): + self._bounds.append(tuple(np.array(dof.bounds) / scale[ind])) + + self.x0 = np.mean(self._bounds, axis=1) + if config.initial is not None: + self.x0 = np.array(config.initial) / scale + + def call(self, cost, callback, kws=None) -> ScipyResult | OptimizeResult: + """Run the wrapped optimizer. + + Parameters + ---------- + cost : callable + Objective function evaluated by the optimizer. + callback : callable + Progress callback invoked by the underlying optimizer. + kws : dict, optional + Optimizer-specific options and temporary overrides. + + Returns + ------- + ScipyResult | OptimizeResult + Result object from the wrapped SciPy optimizer. + + Raises + ------ + NotImplementedError + Raised by the base protocol class when no implementation is + provided. + """ + raise NotImplementedError("Optimizer implementation not provided") + + +class Optimize(InnerOptimizer): + """Normalized wrapper around ``scipy.optimize.minimize``. + + This adapter reads default bounds and initial conditions from ``ScipyCFG`` + and forwards them to ``minimize`` using the common ``InnerOptimizer`` + interface. + """ + + def call(self, cost, callback, kws=None) -> ScipyResult: + """Execute ``scipy.optimize.minimize`` with normalized defaults. + + Parameters + ---------- + cost : callable + Objective function consumed by SciPy. + callback : callable + Callback passed directly to ``minimize``. + kws : dict, optional + Temporary call-time overrides. ``bounds`` and ``x0`` are extracted + from this dictionary when present; remaining values are passed as + ``options``. + + Returns + ------- + ScipyResult + SciPy optimization result (runtime type is ``OptimizeResult``). + """ + bounds = kws.pop("bounds", self._bounds) if kws else self._bounds + x0 = kws.pop("x0", self.x0) if kws else self.x0 + return minimize( + fun=cost, + x0=x0, + method=self.config.optimizer if self.config.optimizer != SCP.Default else None, + bounds=bounds, + callback=callback, + options=kws, + **self.base_args if self.base_args else {}, + ) + + +class DualAnnealing(InnerOptimizer): + """Normalized wrapper around ``scipy.optimize.dual_annealing``. + + ``dual_annealing`` uses a callback signature different from + ``scipy.optimize.minimize``. This adapter normalizes callback payloads so + the outer loop can handle intermediate results consistently. + """ + + def __init__(self, config: ScipyCFG, base_args: dict | None = None, inner_args: dict | None = None) -> None: + """Store normalized configuration for ``dual_annealing``. + + Parameters + ---------- + config : ScipyCFG + Normalized optimizer configuration, including bounds and initial + values. + base_args : dict, optional + Extra keyword arguments forwarded directly to + ``scipy.optimize.dual_annealing``. + inner_args : dict, optional + Additional values merged into ``minimizer_kwargs`` for the local + minimizer stage. + """ + self.inner_args = inner_args + super().__init__(config=config, base_args=base_args) + + def dual_callback(self, x, f, context): + """Convert dual-annealing callback values into a unified result type.""" + return ScipyResult(x, f, -1, context) + + def call(self, cost, callback, kws=None): + """Execute ``dual_annealing`` with normalized bounds and callbacks. + + Parameters + ---------- + cost : callable + Objective function consumed by SciPy. + callback : callable + Outer-loop callback expecting a normalized result object. + kws : dict, optional + Temporary call-time overrides. ``bounds`` and ``x0`` are extracted + when present; remaining keys are forwarded as local-minimizer + ``options``. + + Returns + ------- + OptimizeResult + Final SciPy result from ``dual_annealing``. + """ + bounds = kws.pop("bounds", self._bounds) if kws else self._bounds + x0 = kws.pop("x0", self.x0) if kws else self.x0 + opt = self.inner_args["options"] if self.inner_args else {} + return dual_annealing( + func=cost, + x0=x0, + bounds=bounds, + # Adapt SciPy's (x, f, context) callback to the outer callback + # contract that expects a normalized result object. + callback=lambda x, f, c: callback(self.dual_callback(x, f, c)), + minimizer_kwargs=self.inner_args if self.inner_args else {} | { + "callback": callback, "bounds": bounds, "options": opt | kws if kws else {}}, + **self.base_args if self.base_args else {}, + ) + + +class SHGO(InnerOptimizer): + """Normalized wrapper around ``scipy.optimize.shgo``. + + This adapter forwards globally optimized search settings while preserving + the shared ``InnerOptimizer`` call contract. + """ + + def __init__(self, config: ScipyCFG, base_args: dict | None = None, inner_args: dict | None = None) -> None: + """Store normalized configuration for ``scipy.optimize.shgo``. + + Parameters + ---------- + config : ScipyCFG + Normalized optimizer configuration, including bounds. + base_args : dict, optional + Extra keyword arguments forwarded directly to ``shgo``. + inner_args : dict, optional + Additional values merged into ``minimizer_kwargs`` for the local + minimizer phase. + """ + self.inner_args = inner_args + super().__init__(config=config, base_args=base_args) + + def call(self, cost, callback, kws=None): + """Execute ``shgo`` with normalized bounds and minimizer options. + + Parameters + ---------- + cost : callable + Objective function consumed by SciPy. + callback : callable + Callback forwarded to the local minimizer configuration. + kws : dict, optional + Temporary call-time overrides. ``bounds`` and ``workers`` are + extracted when present; remaining keys are forwarded as + local-minimizer ``options``. + + Returns + ------- + OptimizeResult + Final SciPy result from ``shgo``. + """ + bounds = kws.pop("bounds", self._bounds) if kws else self._bounds + workers = kws.pop("workers", 1) if kws else 1 + opt = self.inner_args["options"] if self.inner_args else {} + return shgo( + func=cost, + bounds=bounds, + minimizer_kwargs=self.inner_args if self.inner_args else {} | { + "callback": callback, "bounds": bounds, "options": opt | kws if kws else {}}, + **self.base_args if self.base_args else {}, + workers=workers, + ) diff --git a/src/blop/scipy/scipy_v2.py b/src/blop/scipy/scipy_v2.py new file mode 100644 index 00000000..404afea2 --- /dev/null +++ b/src/blop/scipy/scipy_v2.py @@ -0,0 +1,304 @@ +"""Scipy optimization power class for fast start QOL and Ax like agent behavior.""" + +from collections.abc import Mapping, Sequence +from typing import Any, cast + +import bluesky.preprocessors as bpp +from bluesky.callbacks import CallbackBase + +from blop.callbacks.logger import OptimizationLogger +from blop.callbacks.router import OptimizationCallbackRouter +from blop.plans import optimize +from blop.protocols import ( + AcquisitionPlan, + Actuator, + EvaluationFunction, + OptimizationProblem, + Sensor, +) +from blop.scipy.configs import SCP, Objective, RangeDOF, ScipyCFG +from blop.scipy.inverter import OuterOptimizer +from blop.scipy.normalized import SHGO, DualAnnealing, Optimize +from blop.utils import InferredReadable + +from .optimizer import ScipyOptimizer + + +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. + """ # ruff: ignore[missing-blank-line-after-summary] + + def __init__( + self, + sensors: Sequence[Sensor], + config: ScipyCFG, + evaluation_function: EvaluationFunction, + acquisition_plan: AcquisitionPlan | None = None, + **kwargs: Any, + ): + try: + if config.optimizer not in SCP: + raise ValueError(f"optimizer {config.optimizer} not in supported optimizers:{list(SCP)}") + except TypeError: + ... + + match config.optimizer: + case SCP.Dual_Annealing: + inner = DualAnnealing(config) + case SCP.SHGO: + inner = SHGO(config) + case _: + inner = Optimize(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.timeout = kwargs.pop("timeout", 200) + self._optimizer = OuterOptimizer(inner, 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) + self.sessioning = kwargs.pop("sessioning", True) + + @classmethod + def Agent( + cls, + sensors: Sequence[Sensor], + dofs: Sequence[RangeDOF], + objectives: Sequence[Objective], + evaluation_function: EvaluationFunction, + acquisition_plan: AcquisitionPlan | None = None, + 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, + ): + """ + An 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. + + See Also + -------- + blop.ax.Agent + + Notes + ----- + This is a nearly drop in replacement for Ax agent sans dof + outcome constraints and checkpointing + + + """ # noqa: D401 + if len(objectives) > 1: + 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), + rescale=kwargs.get("scale", None), + ) + return cls(sensors, config, evaluation_function, acquisition_plan, **kwargs) + + @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 + + @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. + + 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 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, 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) + self._optimizer.force_resiliance = self.resiliance + optimize_plan = optimize( + self.to_optimization_problem(), + iterations=iterations, + n_points=n_points, + readable_cache=self._readable_cache, + ) + + if self._callbacks: + optimize_plan = bpp.subs_wrapper( + optimize_plan, + self._callback_router, + ) + if self.sessioning: + with self._optimizer: + 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() From eaa0c8bbbf63b5b1e374b22ada039f4ffc6ea5be Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 30 Jul 2026 16:51:09 -0400 Subject: [PATCH 046/116] minor fixes with tests and demo --- .../source/tutorials/gradient-optimization.md | 6 ++--- src/blop/scipy/normalized.py | 11 +++++---- src/blop/scipy/scipy_v2.py | 24 +++++++++---------- src/blop/tests/gradient/test_integration.py | 8 +++---- 4 files changed, 25 insertions(+), 24 deletions(-) diff --git a/docs/source/tutorials/gradient-optimization.md b/docs/source/tutorials/gradient-optimization.md index 40487091..8eda2b09 100644 --- a/docs/source/tutorials/gradient-optimization.md +++ b/docs/source/tutorials/gradient-optimization.md @@ -22,7 +22,6 @@ First, let's import what we need and start the data infrastructure: ```{code-cell} ipython3 import logging import time -import warnings from typing import Any from bluesky.protocols import HasHints, HasParent, Hints, NamedMovable, Readable, Status @@ -32,10 +31,9 @@ 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 +from blop.scipy import SCP, ScipyCFG, Objective, RangeDOF, Scipy -# Suppress noisy logs from httpx +# Suppress noisy logs from httpx logging.getLogger("httpx").setLevel(logging.WARNING) ``` diff --git a/src/blop/scipy/normalized.py b/src/blop/scipy/normalized.py index 33ad9473..ece842dd 100644 --- a/src/blop/scipy/normalized.py +++ b/src/blop/scipy/normalized.py @@ -1,4 +1,5 @@ """Normalized SciPy optimizer wrappers used by the cooperative optimization loop.""" + from typing import Any import numpy as np @@ -173,8 +174,9 @@ def call(self, cost, callback, kws=None): # Adapt SciPy's (x, f, context) callback to the outer callback # contract that expects a normalized result object. callback=lambda x, f, c: callback(self.dual_callback(x, f, c)), - minimizer_kwargs=self.inner_args if self.inner_args else {} | { - "callback": callback, "bounds": bounds, "options": opt | kws if kws else {}}, + minimizer_kwargs=self.inner_args + if self.inner_args + else {} | {"callback": callback, "bounds": bounds, "options": opt | kws if kws else {}}, **self.base_args if self.base_args else {}, ) @@ -227,8 +229,9 @@ def call(self, cost, callback, kws=None): return shgo( func=cost, bounds=bounds, - minimizer_kwargs=self.inner_args if self.inner_args else {} | { - "callback": callback, "bounds": bounds, "options": opt | kws if kws else {}}, + minimizer_kwargs=self.inner_args + if self.inner_args + else {} | {"callback": callback, "bounds": bounds, "options": opt | kws if kws else {}}, **self.base_args if self.base_args else {}, workers=workers, ) diff --git a/src/blop/scipy/scipy_v2.py b/src/blop/scipy/scipy_v2.py index 404afea2..6c60c351 100644 --- a/src/blop/scipy/scipy_v2.py +++ b/src/blop/scipy/scipy_v2.py @@ -30,7 +30,7 @@ class Scipy: (allowing drop in swapping as much as possible). Useful as a cover in for all the QOL provided by the Agent object. - """ # ruff: ignore[missing-blank-line-after-summary] + """ # noqa: D205 def __init__( self, @@ -60,8 +60,8 @@ def __init__( self._evaluation_function = evaluation_function self._acquisition_plan = acquisition_plan self.timeout = kwargs.pop("timeout", 200) - self._optimizer = OuterOptimizer(inner, timeout=self.timeout) - self._optimizer.force_resiliance = self.resiliance = kwargs.pop("resiliance", True) + self.optimizer = OuterOptimizer(inner, 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) @@ -206,7 +206,7 @@ def to_optimization_problem(self) -> OptimizationProblem: blop.plans.optimize : Uses the optimization problem to run optimization. """ return OptimizationProblem( - optimizer=self._optimizer, + optimizer=self.optimizer, actuators=self._actuators, sensors=self._sensors, evaluation_function=self._evaluation_function, @@ -233,7 +233,7 @@ def suggest(self, num_points: int = 1) -> 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) + return self.optimizer.suggest(num_points) def ingest(self, points: list[dict]) -> None: """ @@ -256,14 +256,14 @@ def ingest(self, points: list[dict]) -> None: For complete examples, see :doc:`/how-to-guides/attach-data-to-experiments`. """ - self._optimizer.ingest(points) + 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) - self._optimizer.force_resiliance = self.resiliance + 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, @@ -277,7 +277,7 @@ def optimize(self, iterations=10, n_points=1): self._callback_router, ) if self.sessioning: - with self._optimizer: + with self.optimizer: yield from optimize_plan else: yield from optimize_plan @@ -301,4 +301,4 @@ def get_best_points(self) -> list[tuple[Any, Mapping, Mapping]]: -------- navigate_to_best : Plan stub to move actuators to a best point. """ - return self._optimizer.get_best_points() + return self.optimizer.get_best_points() diff --git a/src/blop/tests/gradient/test_integration.py b/src/blop/tests/gradient/test_integration.py index 90e55f15..936601f9 100644 --- a/src/blop/tests/gradient/test_integration.py +++ b/src/blop/tests/gradient/test_integration.py @@ -29,12 +29,12 @@ def __call__(self, uid, suggestions): evaluation_function=deflating_evaluation(), timeout=5, ) - agent._optimizer.force_resiliance = True + agent.optimizer.force_resiliance = True RE = RunEngine({}) RE(agent.optimize(40)) # time.sleep(0.1) - assert agent._optimizer.intermediate is not None - assert not agent._optimizer._active + 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.optimizer.final is not None assert agent.get_best_points() is not None From 00d37bcb2680878c75c5487f8e947584388f0ba8 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 30 Jul 2026 17:23:41 -0400 Subject: [PATCH 047/116] test case name fixes --- src/blop/tests/{gradient => scipy}/__init__.py | 0 src/blop/tests/{gradient => scipy}/test_integration.py | 0 src/blop/tests/{gradient => scipy}/test_optimizer.py | 0 src/blop/tests/{gradient => scipy}/test_scipy.py | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename src/blop/tests/{gradient => scipy}/__init__.py (100%) rename src/blop/tests/{gradient => scipy}/test_integration.py (100%) rename src/blop/tests/{gradient => scipy}/test_optimizer.py (100%) rename src/blop/tests/{gradient => scipy}/test_scipy.py (100%) diff --git a/src/blop/tests/gradient/__init__.py b/src/blop/tests/scipy/__init__.py similarity index 100% rename from src/blop/tests/gradient/__init__.py rename to src/blop/tests/scipy/__init__.py diff --git a/src/blop/tests/gradient/test_integration.py b/src/blop/tests/scipy/test_integration.py similarity index 100% rename from src/blop/tests/gradient/test_integration.py rename to src/blop/tests/scipy/test_integration.py diff --git a/src/blop/tests/gradient/test_optimizer.py b/src/blop/tests/scipy/test_optimizer.py similarity index 100% rename from src/blop/tests/gradient/test_optimizer.py rename to src/blop/tests/scipy/test_optimizer.py diff --git a/src/blop/tests/gradient/test_scipy.py b/src/blop/tests/scipy/test_scipy.py similarity index 100% rename from src/blop/tests/gradient/test_scipy.py rename to src/blop/tests/scipy/test_scipy.py From b6dd65502d431ce07fd9913d41115d60b0657ecd Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Fri, 31 Jul 2026 11:04:11 -0400 Subject: [PATCH 048/116] package reorder, unit test conversion and bug fixes --- src/blop/scipy/__init__.py | 12 +- src/blop/scipy/inverter.py | 19 +- .../scipy/{normalized.py => normalizers.py} | 14 +- src/blop/scipy/optimizer.py | 259 --------------- src/blop/scipy/scipy.py | 44 +-- src/blop/scipy/scipy_v2.py | 304 ------------------ src/blop/tests/scipy/test_optimizer.py | 55 +++- src/blop/tests/scipy/test_scipy.py | 55 ++-- 8 files changed, 122 insertions(+), 640 deletions(-) rename src/blop/scipy/{normalized.py => normalizers.py} (97%) delete mode 100644 src/blop/scipy/optimizer.py delete mode 100644 src/blop/scipy/scipy_v2.py diff --git a/src/blop/scipy/__init__.py b/src/blop/scipy/__init__.py index 1ea47c8b..6b0c7389 100644 --- a/src/blop/scipy/__init__.py +++ b/src/blop/scipy/__init__.py @@ -1,20 +1,18 @@ """Scipy Backend for Pertubative gradient and in house global optimizers.""" from .configs import SCP, Objective, RangeDOF, ScipyCFG -from .inverter import OuterOptimizer -from .normalized import SHGO, DualAnnealing, Optimize -from .optimizer import ScipyOptimizer -from .scipy_v2 import Scipy +from .inverter import InteractiveOptimizer +from .normalizers import SHGO, DualAnnealing, Minimize +from .scipy import Scipy __all__ = [ "SCP", "ScipyCFG", "Scipy", - "ScipyOptimizer", "DualAnnealing", - "Optimize", + "Minimize", "SHGO", - "OuterOptimizer", + "InteractiveOptimizer", "Objective", "RangeDOF", ] diff --git a/src/blop/scipy/inverter.py b/src/blop/scipy/inverter.py index c57cc10b..6e4a9366 100644 --- a/src/blop/scipy/inverter.py +++ b/src/blop/scipy/inverter.py @@ -3,6 +3,7 @@ from collections import OrderedDict from collections.abc import Mapping from concurrent.futures import Future, ThreadPoolExecutor +from dataclasses import dataclass from threading import Thread from typing import Any, cast @@ -11,14 +12,16 @@ from blop.protocols import ID_KEY, Optimizer from blop.scipy.configs import SCP, Objective, ScipyCFG -from blop.scipy.normalized import InnerOptimizer -from blop.scipy.optimizer import ScipyOptimizer +from blop.scipy.normalizers import InnerOptimizer, ScipyResult -ScipyResult = ScipyOptimizer.Result -_Request = ScipyOptimizer._Request +@dataclass +class _Request: + args: tuple + future: Future -class OuterOptimizer(Optimizer): + +class InteractiveOptimizer(Optimizer): """An optimizer object to supply an interactive interface for the scipy optimizers, with some caveats.""" def __init__(self, optimizer: InnerOptimizer, config: ScipyCFG | None = None, timeout: int | None = 200): @@ -37,9 +40,9 @@ def session(self, config: ScipyCFG, timeout: int | None = None): 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._active: dict[int, _Request] = OrderedDict() + self.intermediate: OptimizeResult | ScipyResult | None = None + self.final: OptimizeResult | ScipyResult | None = None self.SUGGESTION_TIMEOUT = timeout if config.rescale is not None: diff --git a/src/blop/scipy/normalized.py b/src/blop/scipy/normalizers.py similarity index 97% rename from src/blop/scipy/normalized.py rename to src/blop/scipy/normalizers.py index ece842dd..08779835 100644 --- a/src/blop/scipy/normalized.py +++ b/src/blop/scipy/normalizers.py @@ -1,14 +1,22 @@ """Normalized SciPy optimizer wrappers used by the cooperative optimization loop.""" +from dataclasses import dataclass from typing import Any import numpy as np from scipy.optimize import OptimizeResult, dual_annealing, minimize, shgo from blop.scipy.configs import SCP, ScipyCFG -from blop.scipy.optimizer import ScipyOptimizer -ScipyResult = ScipyOptimizer.Result + +@dataclass +class ScipyResult: + """Class to unify Optimize Result and other Scipy Results.""" + + x: list[float | int] + fun: float + nit: int + status: int = 2 class InnerOptimizer: @@ -75,7 +83,7 @@ def call(self, cost, callback, kws=None) -> ScipyResult | OptimizeResult: raise NotImplementedError("Optimizer implementation not provided") -class Optimize(InnerOptimizer): +class Minimize(InnerOptimizer): """Normalized wrapper around ``scipy.optimize.minimize``. This adapter reads default bounds and initial conditions from ``ScipyCFG`` diff --git a/src/blop/scipy/optimizer.py b/src/blop/scipy/optimizer.py deleted file mode 100644 index fca8fdf2..00000000 --- a/src/blop/scipy/optimizer.py +++ /dev/null @@ -1,259 +0,0 @@ -"""Core Scipy optimizer porting scipy algorithms.""" - -from collections import OrderedDict -from collections.abc import Mapping -from concurrent.futures import Future, ThreadPoolExecutor -from dataclasses import dataclass -from threading import Thread -from typing import Any, cast - -import numpy as np -from scipy.optimize import OptimizeResult, dual_annealing, minimize, shgo - -from blop.protocols import ID_KEY, Optimizer -from blop.scipy.configs import SCP, Objective, ScipyCFG - - -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: - """Class to unify Optimize Result and Scipy Result.""" - - x: list[float | int] - fun: float - nit: int - status: int = 2 - - 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.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 - """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) - if res is None: - raise ValueError("return value is not present") - return res - - kw = {} - self._thread_pool = None - if config.max_iter is not None: - 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 - - 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 - - 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}") - if self.intermediate and self.intermediate.fun < f: - return - 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={"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 list(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)}") - - def mini_worker(): - try: - if config.threads: - with ThreadPoolExecutor(max_workers=config.threads) as pool: - kw["workers"] = pool.map - 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): - """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]: - """ - 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. - - 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.name: result.fun}), - ] - 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")) diff --git a/src/blop/scipy/scipy.py b/src/blop/scipy/scipy.py index b769985c..f1e78b9d 100644 --- a/src/blop/scipy/scipy.py +++ b/src/blop/scipy/scipy.py @@ -17,10 +17,10 @@ Sensor, ) from blop.scipy.configs import SCP, Objective, RangeDOF, ScipyCFG +from blop.scipy.inverter import InteractiveOptimizer +from blop.scipy.normalizers import SHGO, DualAnnealing, Minimize from blop.utils import InferredReadable -from .optimizer import ScipyOptimizer - class Scipy: """ @@ -38,6 +38,19 @@ def __init__( acquisition_plan: AcquisitionPlan | None = None, **kwargs: Any, ): + try: + if config.optimizer not in SCP: + raise ValueError(f"optimizer {config.optimizer} not in supported optimizers:{list(SCP)}") + except TypeError: + ... + + match config.optimizer: + case SCP.Dual_Annealing: + self.inner = DualAnnealing(config) + case SCP.SHGO: + self.inner = SHGO(config) + case _: + self.inner = Minimize(config) self.config = config self._sensors = sensors @@ -45,8 +58,8 @@ def __init__( self._evaluation_function = evaluation_function 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.optimizer = InteractiveOptimizer(self.inner, 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) @@ -95,11 +108,6 @@ def Agent( """ # noqa: D401 - 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( @@ -196,7 +204,7 @@ def to_optimization_problem(self) -> OptimizationProblem: blop.plans.optimize : Uses the optimization problem to run optimization. """ return OptimizationProblem( - optimizer=self._optimizer, + optimizer=self.optimizer, actuators=self._actuators, sensors=self._sensors, evaluation_function=self._evaluation_function, @@ -223,7 +231,7 @@ def suggest(self, num_points: int = 1) -> 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) + return self.optimizer.suggest(num_points) def ingest(self, points: list[dict]) -> None: """ @@ -246,14 +254,14 @@ def ingest(self, points: list[dict]) -> None: For complete examples, see :doc:`/how-to-guides/attach-data-to-experiments`. """ - self._optimizer.ingest(points) + 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) - self._optimizer.force_resiliance = self.resiliance + if self.optimizer.final is not None: + self.config.initial = self.optimizer.final.x + self.optimizer = InteractiveOptimizer(self.inner, timeout=self.timeout) + self.optimizer.force_resiliance = self.resiliance optimize_plan = optimize( self.to_optimization_problem(), iterations=iterations, @@ -267,7 +275,7 @@ def optimize(self, iterations=10, n_points=1): self._callback_router, ) if self.sessioning: - with self._optimizer: + with self.optimizer: yield from optimize_plan else: yield from optimize_plan @@ -291,4 +299,4 @@ def get_best_points(self) -> list[tuple[Any, Mapping, Mapping]]: -------- navigate_to_best : Plan stub to move actuators to a best point. """ - return self._optimizer.get_best_points() + return self.optimizer.get_best_points() diff --git a/src/blop/scipy/scipy_v2.py b/src/blop/scipy/scipy_v2.py deleted file mode 100644 index 6c60c351..00000000 --- a/src/blop/scipy/scipy_v2.py +++ /dev/null @@ -1,304 +0,0 @@ -"""Scipy optimization power class for fast start QOL and Ax like agent behavior.""" - -from collections.abc import Mapping, Sequence -from typing import Any, cast - -import bluesky.preprocessors as bpp -from bluesky.callbacks import CallbackBase - -from blop.callbacks.logger import OptimizationLogger -from blop.callbacks.router import OptimizationCallbackRouter -from blop.plans import optimize -from blop.protocols import ( - AcquisitionPlan, - Actuator, - EvaluationFunction, - OptimizationProblem, - Sensor, -) -from blop.scipy.configs import SCP, Objective, RangeDOF, ScipyCFG -from blop.scipy.inverter import OuterOptimizer -from blop.scipy.normalized import SHGO, DualAnnealing, Optimize -from blop.utils import InferredReadable - -from .optimizer import ScipyOptimizer - - -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. - """ # noqa: D205 - - def __init__( - self, - sensors: Sequence[Sensor], - config: ScipyCFG, - evaluation_function: EvaluationFunction, - acquisition_plan: AcquisitionPlan | None = None, - **kwargs: Any, - ): - try: - if config.optimizer not in SCP: - raise ValueError(f"optimizer {config.optimizer} not in supported optimizers:{list(SCP)}") - except TypeError: - ... - - match config.optimizer: - case SCP.Dual_Annealing: - inner = DualAnnealing(config) - case SCP.SHGO: - inner = SHGO(config) - case _: - inner = Optimize(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.timeout = kwargs.pop("timeout", 200) - self.optimizer = OuterOptimizer(inner, 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) - self.sessioning = kwargs.pop("sessioning", True) - - @classmethod - def Agent( - cls, - sensors: Sequence[Sensor], - dofs: Sequence[RangeDOF], - objectives: Sequence[Objective], - evaluation_function: EvaluationFunction, - acquisition_plan: AcquisitionPlan | None = None, - 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, - ): - """ - An 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. - - See Also - -------- - blop.ax.Agent - - Notes - ----- - This is a nearly drop in replacement for Ax agent sans dof + outcome constraints and checkpointing - - - """ # noqa: D401 - if len(objectives) > 1: - 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), - rescale=kwargs.get("scale", None), - ) - return cls(sensors, config, evaluation_function, acquisition_plan, **kwargs) - - @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 - - @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. - - 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 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, 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) - self.optimizer.force_resiliance = self.resiliance - optimize_plan = optimize( - self.to_optimization_problem(), - iterations=iterations, - n_points=n_points, - readable_cache=self._readable_cache, - ) - - if self._callbacks: - optimize_plan = bpp.subs_wrapper( - optimize_plan, - self._callback_router, - ) - if self.sessioning: - with self.optimizer: - 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/tests/scipy/test_optimizer.py b/src/blop/tests/scipy/test_optimizer.py index cfd0bfab..8ac3c565 100644 --- a/src/blop/tests/scipy/test_optimizer.py +++ b/src/blop/tests/scipy/test_optimizer.py @@ -5,8 +5,9 @@ from blop.ax import Objective, RangeDOF from blop.protocols import ID_KEY, AcquisitionPlan, EvaluationFunction -from blop.scipy import ScipyOptimizer from blop.scipy.configs import SCP, ScipyCFG +from blop.scipy.inverter import InteractiveOptimizer +from blop.scipy.normalizers import SHGO, DualAnnealing, Minimize, ScipyResult from ..conftest import MovableSignal @@ -34,7 +35,8 @@ def optimizer_prep(): threads=4, rescale=[2.0, 3.0], ) - return ScipyOptimizer(config, timeout=5) + inner = Minimize(config) + return InteractiveOptimizer(inner, timeout=5) # ============================================================================ @@ -42,7 +44,7 @@ def optimizer_prep(): # ============================================================================ -@pytest.mark.parametrize("optimizer", list(SCP)) +@pytest.mark.parametrize("optimizer", list(SCP)[:10]) def test_scipy_optimizer_algorithms(mock_evaluation_function, mock_acquisition_plan, optimizer): """Test ScipyOptimizer with different SCP algorithms.""" movable1 = MovableSignal(name="test_movable1") @@ -58,7 +60,8 @@ def test_scipy_optimizer_algorithms(mock_evaluation_function, mock_acquisition_p max_iter=10, ) - opt = ScipyOptimizer(config, timeout=5) + inner = Minimize(config) + opt = InteractiveOptimizer(inner, timeout=5) assert opt._active is not None opt.close() @@ -78,7 +81,8 @@ def test_scipy_optimizer_bfgs_specific(mock_evaluation_function, mock_acquisitio max_iter=10, ) - opt = ScipyOptimizer(config, timeout=5) + inner = Minimize(config) + opt = InteractiveOptimizer(inner, timeout=5) assert opt.final is None # No optimization run yet opt.close() @@ -95,7 +99,26 @@ def test_scipy_optimizer_dual_annealing_specific(mock_evaluation_function, mock_ optimizer=SCP.Dual_Annealing, ) - opt = ScipyOptimizer(config, timeout=5) + inner = DualAnnealing(config) + opt = InteractiveOptimizer(inner, timeout=5) + assert opt.final is None # No optimization run yet + opt.close() + + +def test_scipy_optimizer_SHGO_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, + ) + + inner = SHGO(config) + opt = InteractiveOptimizer(inner, timeout=5) assert opt.final is None # No optimization run yet opt.close() @@ -112,7 +135,8 @@ def test_scipy_optimizer_threads_none(mock_evaluation_function, mock_acquisition threads=None, ) - opt = ScipyOptimizer(config, timeout=5) + inner = Minimize(config) + opt = InteractiveOptimizer(inner, timeout=5) assert opt._thread_pool is None # No thread pool when threads=None opt.close() @@ -129,7 +153,8 @@ def test_scipy_optimizer_threads_multiple(mock_evaluation_function, mock_acquisi threads=2, ) - opt = ScipyOptimizer(config, timeout=5) + inner = Minimize(config) + opt = InteractiveOptimizer(inner, timeout=5) # Configuration accepted opt.close() @@ -163,7 +188,7 @@ def test_rescaling_ingest_parameters(optimizer_prep): 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( + optimizer_prep.final = ScipyResult( x=[2.5, 3.0], # Scaled values fun=0.85, nit=15, @@ -252,7 +277,8 @@ def test_scipy_optimizer_context_manager(mock_evaluation_function, mock_acquisit config = ScipyCFG(dofs=[dof], objective=objective) - with ScipyOptimizer(config, timeout=5) as opt: + inner = Minimize(config) + with InteractiveOptimizer(inner, timeout=5) as opt: assert opt is not None time.sleep(0.1) suggestions = opt.suggest(1) @@ -267,7 +293,8 @@ def test_scipy_optimizer_session_reinit(mock_evaluation_function, mock_acquisiti config = ScipyCFG(dofs=[dof], objective=objective) - opt = ScipyOptimizer(config, timeout=5) + inner = Minimize(config) + opt = InteractiveOptimizer(inner, timeout=5) opt.suggest(1) # Call session to reinitialize @@ -282,7 +309,7 @@ def test_scipy_optimizer_session_reinit(mock_evaluation_function, mock_acquisiti 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( + optimizer_prep.intermediate = ScipyResult( x=[5.0, -5.0], fun=0.7, nit=5, @@ -298,13 +325,13 @@ def test_get_best_points_intermediate_only(optimizer_prep): 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( + optimizer_prep.intermediate = ScipyResult( x=[5.0, -5.0], fun=0.7, nit=5, status=0, ) - optimizer_prep.final = ScipyOptimizer.Result( + optimizer_prep.final = ScipyResult( x=[7.0, -5.0], fun=0.9, nit=10, diff --git a/src/blop/tests/scipy/test_scipy.py b/src/blop/tests/scipy/test_scipy.py index d90c2361..475f8a88 100644 --- a/src/blop/tests/scipy/test_scipy.py +++ b/src/blop/tests/scipy/test_scipy.py @@ -5,8 +5,9 @@ from blop.ax import Objective, RangeDOF from blop.protocols import ID_KEY, AcquisitionPlan, EvaluationFunction -from blop.scipy import ScipyOptimizer from blop.scipy.configs import ScipyCFG +from blop.scipy.inverter import InteractiveOptimizer +from blop.scipy.normalizers import ScipyResult from blop.scipy.scipy import Scipy from ..conftest import MovableSignal, ReadableSignal @@ -22,7 +23,7 @@ def mock_acquisition_plan(): return MagicMock(spec=AcquisitionPlan) -# agent._optimizer.close() is called so the standard timeout doesnt make the testing take forever +# agent.optimizer.close() is called so the standard timeout doesnt make the testing take forever @pytest.fixture(scope="function") @@ -88,7 +89,7 @@ def test_general_init(mock_evaluation_function, mock_acquisition_plan): assert agent.actuators == [dof1.actuator, dof2.actuator] assert agent.evaluation_function == mock_evaluation_function assert agent.acquisition_plan == mock_acquisition_plan - agent._optimizer.close() + agent.optimizer.close() def test_agent_init(mock_evaluation_function, mock_acquisition_plan): @@ -112,7 +113,7 @@ def test_agent_init(mock_evaluation_function, mock_acquisition_plan): assert agent.actuators == [dof1.actuator, dof2.actuator] assert agent.evaluation_function == mock_evaluation_function assert agent.acquisition_plan == mock_acquisition_plan - agent._optimizer.close() + agent.optimizer.close() def test_agent_to_optimization_problem(mock_evaluation_function): @@ -129,9 +130,9 @@ 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, ScipyOptimizer) + assert isinstance(optimization_problem.optimizer, InteractiveOptimizer) assert optimization_problem.acquisition_plan is None - agent._optimizer.close() + agent.optimizer.close() def test_agent_suggest(agent_prep): @@ -142,13 +143,13 @@ 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() + agent_prep.optimizer.close() 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() + agent_prep.optimizer.close() def test_agent_multithread(agent_prep): @@ -156,9 +157,9 @@ def test_agent_multithread(agent_prep): agent_prep.ingest([{"test_movable1": 0.1, "test_movable2": 0.2, "test_objective": 0.3, ID_KEY: 0}]) time.sleep(0.1) params = agent_prep.suggest(4) - print(agent_prep._optimizer._active) + print(agent_prep.optimizer._active) assert len(params) > 1 - agent_prep._optimizer.close() + agent_prep.optimizer.close() # ============================================================================ @@ -187,8 +188,8 @@ def test_scipy_cfg_rescaling_scalar(mock_evaluation_function, mock_acquisition_p ) # Verify rescaling was applied - assert agent._optimizer._scale[0] == 2.0 - agent._optimizer.close() + assert agent.optimizer._scale[0] == 2.0 + agent.optimizer.close() def test_scipy_cfg_rescaling_list(mock_evaluation_function, mock_acquisition_plan): @@ -214,9 +215,9 @@ def test_scipy_cfg_rescaling_list(mock_evaluation_function, mock_acquisition_pla ) # Verify rescaling per DOF - assert agent._optimizer._scale[0] == 2.0 - assert agent._optimizer._scale[1] == 3.0 - agent._optimizer.close() + 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): @@ -243,7 +244,7 @@ def test_scipy_cfg_initial_parameters(mock_evaluation_function, mock_acquisition ) # Verify initial parameters are set - agent._optimizer.close() + agent.optimizer.close() def test_scipy_cfg_max_iter_and_eps(mock_evaluation_function, mock_acquisition_plan): @@ -263,7 +264,7 @@ def test_scipy_cfg_max_iter_and_eps(mock_evaluation_function, mock_acquisition_p assert config.eps == 1e-6 -def test_agent_invalid_optimizer_enum(mock_evaluation_function, mock_acquisition_plan): +def test_agent_invalidoptimizer_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") @@ -276,7 +277,7 @@ def test_agent_invalid_optimizer_enum(mock_evaluation_function, mock_acquisition dofs=[dof], objectives=[objective], evaluation_function=mock_evaluation_function, - optimizer="invalid_optimizer", + optimizer="invalidoptimizer", ) @@ -310,7 +311,7 @@ def test_subscribe_callback(secoundary_agent_prep): assert len(secoundary_agent_prep.callbacks) == initial_count + 1 assert callback in secoundary_agent_prep.callbacks - secoundary_agent_prep._optimizer.close() + secoundary_agent_prep.optimizer.close() def test_subscribe_duplicate_raises(secoundary_agent_prep): @@ -321,7 +322,7 @@ def test_subscribe_duplicate_raises(secoundary_agent_prep): with pytest.raises(ValueError, match="already subscribed"): secoundary_agent_prep.subscribe(callback) - secoundary_agent_prep._optimizer.close() + secoundary_agent_prep.optimizer.close() def test_unsubscribe_callback(secoundary_agent_prep): @@ -332,7 +333,7 @@ def test_unsubscribe_callback(secoundary_agent_prep): secoundary_agent_prep.unsubscribe(callback) assert callback not in secoundary_agent_prep.callbacks - secoundary_agent_prep._optimizer.close() + secoundary_agent_prep.optimizer.close() def test_unsubscribe_not_subscribed_raises(secoundary_agent_prep): @@ -342,7 +343,7 @@ def test_unsubscribe_not_subscribed_raises(secoundary_agent_prep): with pytest.raises(ValueError): secoundary_agent_prep.unsubscribe(callback) - secoundary_agent_prep._optimizer.close() + secoundary_agent_prep.optimizer.close() # ============================================================================ @@ -355,7 +356,7 @@ def test_scipy_secoundary(secoundary_agent_prep): suggestions = secoundary_agent_prep.suggest(1) assert len(suggestions) == 1 assert "test_movable" in suggestions[0] - secoundary_agent_prep._optimizer.close() + secoundary_agent_prep.optimizer.close() def test_scipy_large_rescale_factors(mock_evaluation_function, mock_acquisition_plan): @@ -387,21 +388,21 @@ def test_scipy_large_rescale_factors(mock_evaluation_function, mock_acquisition_ # Values should still be in original bounds assert 0 <= suggestions[0]["test_movable1"] <= 10 assert 0 <= suggestions[0]["test_movable2"] <= 10 - agent._optimizer.close() + agent.optimizer.close() def test_suggest_after_final_optimization(secoundary_agent_prep): """Test suggest() after final optimization returns final result parameterization.""" # Set final optimization result - secoundary_agent_prep._optimizer.final = ScipyOptimizer.Result( + secoundary_agent_prep.optimizer.final = ScipyResult( x=[7.0], fun=0.95, nit=20, status=0, ) - suggestions = secoundary_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 - secoundary_agent_prep._optimizer.close() + secoundary_agent_prep.optimizer.close() From 41a374c8f0aa00d69192e8b8a514c41ed304f648 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Fri, 31 Jul 2026 11:18:37 -0400 Subject: [PATCH 049/116] had to re add the sleep statement. final has a race condition making slow update but is not important to the core api, more of a verif of poss. convergence --- src/blop/tests/scipy/test_integration.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/blop/tests/scipy/test_integration.py b/src/blop/tests/scipy/test_integration.py index 936601f9..63d3f5ff 100644 --- a/src/blop/tests/scipy/test_integration.py +++ b/src/blop/tests/scipy/test_integration.py @@ -1,3 +1,5 @@ +import time + from bluesky import RunEngine from blop.protocols import EvaluationFunction @@ -32,9 +34,9 @@ def __call__(self, uid, suggestions): agent.optimizer.force_resiliance = True RE = RunEngine({}) 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 + RE(agent.optimize(40)) assert agent.get_best_points() is not None + time.sleep(.1) + assert agent.optimizer.final is not None From c47ead4a69a2b3e11d749748e42a84446185fd41 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Fri, 31 Jul 2026 11:21:34 -0400 Subject: [PATCH 050/116] ruff AAAAAAAAAAAAAAAAAAAA --- src/blop/tests/scipy/test_integration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/blop/tests/scipy/test_integration.py b/src/blop/tests/scipy/test_integration.py index 63d3f5ff..ab9419f6 100644 --- a/src/blop/tests/scipy/test_integration.py +++ b/src/blop/tests/scipy/test_integration.py @@ -38,5 +38,5 @@ def __call__(self, uid, suggestions): assert not agent.optimizer._active RE(agent.optimize(40)) assert agent.get_best_points() is not None - time.sleep(.1) + time.sleep(0.1) assert agent.optimizer.final is not None From 9bd04e295a422527dfdf8a5da1ad363252b2207a Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Fri, 31 Jul 2026 11:30:57 -0400 Subject: [PATCH 051/116] 3.11 fix --- src/blop/scipy/scipy.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/blop/scipy/scipy.py b/src/blop/scipy/scipy.py index f1e78b9d..71fe24df 100644 --- a/src/blop/scipy/scipy.py +++ b/src/blop/scipy/scipy.py @@ -38,11 +38,8 @@ def __init__( acquisition_plan: AcquisitionPlan | None = None, **kwargs: Any, ): - try: - if config.optimizer not in SCP: - raise ValueError(f"optimizer {config.optimizer} not in supported optimizers:{list(SCP)}") - except TypeError: - ... + if config.optimizer not in list(SCP): + raise ValueError(f"optimizer {config.optimizer} not in supported optimizers:{list(SCP)}") match config.optimizer: case SCP.Dual_Annealing: From 979cfb9433e8a856201d1edf158b9b31d7d694f6 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Fri, 31 Jul 2026 11:35:40 -0400 Subject: [PATCH 052/116] Enum name improvements --- src/blop/scipy/configs.py | 8 ++++---- src/blop/scipy/inverter.py | 2 +- src/blop/scipy/scipy.py | 2 +- src/blop/tests/scipy/test_integration.py | 2 +- src/blop/tests/scipy/test_optimizer.py | 4 ++-- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/blop/scipy/configs.py b/src/blop/scipy/configs.py index 8f1ec889..b7f27586 100644 --- a/src/blop/scipy/configs.py +++ b/src/blop/scipy/configs.py @@ -111,8 +111,8 @@ class SCP(StrEnum): Default = "L-BFGS-B" - Nelder_Mead = "Nelder-Mead" - Powell = "Powell" + NELDER_MEAD = "Nelder-Mead" + PoWELL = "Powell" CG = "CG" BFGS = "BFGS" # Newton_CG = "Newton-CG" @@ -121,13 +121,13 @@ class SCP(StrEnum): COBYLA = "COBYLA" COBYQA = "COBYQA" SLSQP = "SLSQP" - Trust_Constr = "trust-constr" + TRUST_CONSTR = "trust-constr" # Dogleg = "dogleg" # Trust_NCG = "trust-ncg" # Trust_Exact = "trust-exact" # Trust_Krylov = "trust-krylov" - Dual_Annealing = "dual annealing" + DUAL_ANNEALING = "dual annealing" SHGO = "SHGO" diff --git a/src/blop/scipy/inverter.py b/src/blop/scipy/inverter.py index 6e4a9366..cc764365 100644 --- a/src/blop/scipy/inverter.py +++ b/src/blop/scipy/inverter.py @@ -64,7 +64,7 @@ def cost(x): # thread safety needs timeout so there is not infinite hang on pro kw: dict = {} self._thread_pool = None if config.max_iter is not None: - if config.optimizer is not SCP.Trust_Constr: + if config.optimizer is not SCP.TRUST_CONSTR: kw["max_iter"] = config.max_iter else: kw["maxiter"] = config.max_iter diff --git a/src/blop/scipy/scipy.py b/src/blop/scipy/scipy.py index 71fe24df..85143609 100644 --- a/src/blop/scipy/scipy.py +++ b/src/blop/scipy/scipy.py @@ -42,7 +42,7 @@ def __init__( raise ValueError(f"optimizer {config.optimizer} not in supported optimizers:{list(SCP)}") match config.optimizer: - case SCP.Dual_Annealing: + case SCP.DUAL_ANNEALING: self.inner = DualAnnealing(config) case SCP.SHGO: self.inner = SHGO(config) diff --git a/src/blop/tests/scipy/test_integration.py b/src/blop/tests/scipy/test_integration.py index ab9419f6..348bbbe6 100644 --- a/src/blop/tests/scipy/test_integration.py +++ b/src/blop/tests/scipy/test_integration.py @@ -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, optimizer=SCP.Dual_Annealing) + config = ScipyCFG(dofs=[dof], objective=objective, optimizer=SCP.DUAL_ANNEALING) class deflating_evaluation(EvaluationFunction): def __init__(self): diff --git a/src/blop/tests/scipy/test_optimizer.py b/src/blop/tests/scipy/test_optimizer.py index 8ac3c565..223d7d44 100644 --- a/src/blop/tests/scipy/test_optimizer.py +++ b/src/blop/tests/scipy/test_optimizer.py @@ -96,7 +96,7 @@ def test_scipy_optimizer_dual_annealing_specific(mock_evaluation_function, mock_ config = ScipyCFG( dofs=[dof], objective=objective, - optimizer=SCP.Dual_Annealing, + optimizer=SCP.DUAL_ANNEALING, ) inner = DualAnnealing(config) @@ -114,7 +114,7 @@ def test_scipy_optimizer_SHGO_specific(mock_evaluation_function, mock_acquisitio config = ScipyCFG( dofs=[dof], objective=objective, - optimizer=SCP.Dual_Annealing, + optimizer=SCP.SHGO, ) inner = SHGO(config) From f776ec80ec82a306cad1b9c40fee0852e712641f Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Fri, 31 Jul 2026 11:59:56 -0400 Subject: [PATCH 053/116] fixed doc enum error --- docs/source/tutorials/gradient-optimization.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/tutorials/gradient-optimization.md b/docs/source/tutorials/gradient-optimization.md index 8eda2b09..15ba7807 100644 --- a/docs/source/tutorials/gradient-optimization.md +++ b/docs/source/tutorials/gradient-optimization.md @@ -176,7 +176,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). ```{code-cell} ipython3 -config = ScipyCFG(dofs=dofs, objective=objectives[0], optimizer=SCP.Dual_Annealing, threads=4, max_iter=2, 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, From e00478add86121cf1e313a52f0b1e62fb37e465a Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Wed, 29 Apr 2026 17:19:59 -0400 Subject: [PATCH 054/116] 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 04bbcc8fc4eb91a94f562ee023eabc27e967f677 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 30 Apr 2026 16:20:44 -0400 Subject: [PATCH 055/116] [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 ee5e1fef8de9277178ca3d728f39be094d14971b Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 30 Apr 2026 16:54:02 -0400 Subject: [PATCH 056/116] 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 3dbbd4125a349e31e94b16d486e82b4c7e54070b Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 30 Apr 2026 16:58:01 -0400 Subject: [PATCH 057/116] 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 0b22fdef82ce09c3e9f617c65371c539405bc891 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 30 Apr 2026 17:26:50 -0400 Subject: [PATCH 058/116] 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 1dfd6ca44a12175dec3b497d860d3bb18b970316 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 30 Apr 2026 17:30:48 -0400 Subject: [PATCH 059/116] 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 e812a523789f836c3d9ce2695467da40ba91ecd2 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Fri, 1 May 2026 14:00:42 -0400 Subject: [PATCH 060/116] [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 7c660da8358bed1f206e43ac5c5178d68ffad1f1 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Mon, 4 May 2026 10:19:27 -0400 Subject: [PATCH 061/116] 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 d72008538efdb5053f5ad37526a22584e7192a3b Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 14 May 2026 18:42:28 -0400 Subject: [PATCH 062/116] 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 49b63088625b2744a35c28c9ba8f48ac83285c2f Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 14 May 2026 18:44:46 -0400 Subject: [PATCH 063/116] 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 47b95ce87cfd449fdaf56f4200c391cc1276c2c1 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Fri, 15 May 2026 10:28:01 -0400 Subject: [PATCH 064/116] 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 f4c95f6cd01805503065102bd53e87164510a8c4 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Mon, 22 Jun 2026 14:22:08 -0400 Subject: [PATCH 065/116] 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 7bc1eec0c634c66f7dfac821a99c73379c57104a Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Mon, 22 Jun 2026 15:00:01 -0400 Subject: [PATCH 066/116] 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 2d351f23ed1f5513281f9b40490ec733e572dff6 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Mon, 22 Jun 2026 15:05:09 -0400 Subject: [PATCH 067/116] 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 8dde2bef5acec298b1403c8ddbed924685c987dd Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Mon, 22 Jun 2026 15:20:52 -0400 Subject: [PATCH 068/116] 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 36aae71b8d735b3e21c199cb135ff8b305dcdaf8 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Mon, 22 Jun 2026 17:27:43 -0400 Subject: [PATCH 069/116] 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 3b0b0b8f201036fb7ac7bddf0509a160b27a821d Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Mon, 22 Jun 2026 17:30:27 -0400 Subject: [PATCH 070/116] 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 71bcafab7faf2f294bf505105dbad3c0807d7dc8 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Wed, 24 Jun 2026 10:48:03 -0400 Subject: [PATCH 071/116] 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 dec9ba98e6de10fae8ca516f5aa08adfd1936a88 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Wed, 24 Jun 2026 14:08:44 -0400 Subject: [PATCH 072/116] 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 fb2776ed93e3e56307e10cc9698e77d8101c8360 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Wed, 24 Jun 2026 14:41:46 -0400 Subject: [PATCH 073/116] 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 a693e072b009e51147cfd07793a970c02345059e Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Wed, 24 Jun 2026 14:38:49 -0400 Subject: [PATCH 074/116] 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 4d1585dac975d873840beb5f76ee8b38c4a21341 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Wed, 24 Jun 2026 14:47:22 -0400 Subject: [PATCH 075/116] 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 404bd60c732723dfab5b66763b8dd48d7ef9b287 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Wed, 24 Jun 2026 15:30:55 -0400 Subject: [PATCH 076/116] 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 93b41c80502ad2c27dfb7b2711b80a930e125f8b Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 25 Jun 2026 11:10:31 -0400 Subject: [PATCH 077/116] 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 8499a664c6e32e11d63b21a5d2bfefe44f2889d5 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 25 Jun 2026 11:14:28 -0400 Subject: [PATCH 078/116] 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 5705c012b70899d0b978e6f4ee63d10862b3ebb8 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 25 Jun 2026 11:28:00 -0400 Subject: [PATCH 079/116] 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 e3294255ca6dfb9b56c09b588bc1457489d96cb7 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 25 Jun 2026 12:20:27 -0400 Subject: [PATCH 080/116] 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 73bd6eea1125637e0b69cb5a2d05e1d575cef552 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 25 Jun 2026 12:38:13 -0400 Subject: [PATCH 081/116] 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 a4be441d8cda5472a9df17fe512e11e187c03b4d Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 25 Jun 2026 12:47:32 -0400 Subject: [PATCH 082/116] 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 e1c9e1db616f318fa4aa63698d54719e0cb06111 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 25 Jun 2026 14:24:17 -0400 Subject: [PATCH 083/116] 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 4a89a1c701e919af22da863d4e577d8e446f6cf0 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 25 Jun 2026 14:41:38 -0400 Subject: [PATCH 084/116] 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 a8dcb35d4ff96653acfdbfba6fc1b263625d4c69 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 25 Jun 2026 14:49:40 -0400 Subject: [PATCH 085/116] 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 89166a822e4d177df455aec4bb5653529c4affce Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 25 Jun 2026 15:19:12 -0400 Subject: [PATCH 086/116] 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 235ce7b2de0cb518906b201575fa28cc0c256b37 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 25 Jun 2026 15:19:40 -0400 Subject: [PATCH 087/116] 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 23f336fc19f81d84ce4fc24261f52cd63aaca461 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 25 Jun 2026 16:54:58 -0400 Subject: [PATCH 088/116] 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 afc6777923254e1acb0a00bb8a74dbc938c3c6c3 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 25 Jun 2026 16:55:51 -0400 Subject: [PATCH 089/116] 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 1e6c9b3bf7fc9c2eb972a035799f0e5a0a276885 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 25 Jun 2026 17:05:54 -0400 Subject: [PATCH 090/116] 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 f6648b1a7611cfbf358ccfd1d2a6f643acbdae10 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Wed, 22 Jul 2026 13:05:14 -0400 Subject: [PATCH 091/116] 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 30576b3ede0cc9ea176a498af6f7743acbeb4fb1 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Wed, 22 Jul 2026 16:46:35 -0400 Subject: [PATCH 092/116] 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 7e5c607d36e771cfa2b7c944225916a7354cfe8e Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Wed, 22 Jul 2026 17:26:31 -0400 Subject: [PATCH 093/116] 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 69f8535bb5fea30e4c0036aac664ddee73c6e61d Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Wed, 22 Jul 2026 17:30:55 -0400 Subject: [PATCH 094/116] 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 58e865606c70a43b6cf530f295bd4ba6d5e87535 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 095/116] 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 171ca3de536ed2627601c8942edb1521de16c299 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Wed, 29 Jul 2026 12:12:13 -0400 Subject: [PATCH 096/116] 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 From 214bfc3464d91b1f4a8ffbc0ea41c211aafbc584 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 30 Jul 2026 00:11:32 -0400 Subject: [PATCH 097/116] first protocol prototype --- src/blop/scipy/inverter.py | 50 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 src/blop/scipy/inverter.py diff --git a/src/blop/scipy/inverter.py b/src/blop/scipy/inverter.py new file mode 100644 index 00000000..01b9bb09 --- /dev/null +++ b/src/blop/scipy/inverter.py @@ -0,0 +1,50 @@ +from dataclasses import asdict + +from scipy.optimize import OptimizeResult, dual_annealing, minimize, shgo + +from blop.scipy.configs import SCP, ScipyCFG +from blop.scipy.optimizer import ScipyOptimizer + + +class InnerOptimizer(): + def call(self, cost, callback, kws=None) -> ScipyOptimizer.Result | OptimizeResult: + raise NotImplementedError("Optimizer spec not Provided") + + +class Optimize(InnerOptimizer): + """ + Parameter Normalized implementation of scipy Optimize to be passed to a loop inversion. + + derives from inner optimizer protocol class + """ + + def __init__(self, optimizer: SCP, config: ScipyCFG) -> None: + self.optimizer = optimizer + self.config = config + + def call(self, cost, callback, kws=None) -> ScipyOptimizer.Result: + bounds = kws.pop("bounds", self.config.dofs) if kws else self.config.dofs + x0 = kws.pop("x0", self.config.initial) if kws else self.config.initial + return minimize( + fun=cost, + x0=x0, + method=self.config.optimizer if self.config.optimizer != SCP.Default else None, + bounds=bounds, + callback=callback, + options=kws, + ) + +class DualAnnealing(InnerOptimizer): + + def __init__(self, optimizer: SCP, config: ScipyCFG) -> None: + self.optimizer = optimizer + self.config = config + + def call(self, cost, callback, kws=None): + self.final = dual_annealing( + func=cost, + x0=_x, + bounds=self._bounds, + callback=dual_callback, + minimizer_kwargs={"callback": default_callback, "bounds": self._bounds, "options": kws}, + ) From 6e00f823331be17e9b9a9b3c36f0fab1231b7a9f Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 30 Jul 2026 16:09:02 -0400 Subject: [PATCH 098/116] stable refactor, reorder next --- src/blop/scipy/__init__.py | 20 ++- src/blop/scipy/configs.py | 2 +- src/blop/scipy/inverter.py | 225 +++++++++++++++++++++----- src/blop/scipy/normalized.py | 234 +++++++++++++++++++++++++++ src/blop/scipy/scipy_v2.py | 304 +++++++++++++++++++++++++++++++++++ 5 files changed, 740 insertions(+), 45 deletions(-) create mode 100644 src/blop/scipy/normalized.py create mode 100644 src/blop/scipy/scipy_v2.py diff --git a/src/blop/scipy/__init__.py b/src/blop/scipy/__init__.py index 1ddbaebb..1ea47c8b 100644 --- a/src/blop/scipy/__init__.py +++ b/src/blop/scipy/__init__.py @@ -1,6 +1,20 @@ """Scipy Backend for Pertubative gradient and in house global optimizers.""" -from .optimizer import SCP, ScipyCFG, ScipyOptimizer -from .scipy import Scipy +from .configs import SCP, Objective, RangeDOF, ScipyCFG +from .inverter import OuterOptimizer +from .normalized import SHGO, DualAnnealing, Optimize +from .optimizer import ScipyOptimizer +from .scipy_v2 import Scipy -__all__ = ["SCP", "ScipyCFG", "Scipy", "ScipyOptimizer"] +__all__ = [ + "SCP", + "ScipyCFG", + "Scipy", + "ScipyOptimizer", + "DualAnnealing", + "Optimize", + "SHGO", + "OuterOptimizer", + "Objective", + "RangeDOF", +] diff --git a/src/blop/scipy/configs.py b/src/blop/scipy/configs.py index 3bcb31a9..8f1ec889 100644 --- a/src/blop/scipy/configs.py +++ b/src/blop/scipy/configs.py @@ -109,7 +109,7 @@ class SCP(StrEnum): # a usage defined addition. """ - Default = "Default" + Default = "L-BFGS-B" Nelder_Mead = "Nelder-Mead" Powell = "Powell" diff --git a/src/blop/scipy/inverter.py b/src/blop/scipy/inverter.py index 01b9bb09..c57cc10b 100644 --- a/src/blop/scipy/inverter.py +++ b/src/blop/scipy/inverter.py @@ -1,50 +1,193 @@ -from dataclasses import asdict +"""Core Scipy optimizer porting scipy algorithms.""" -from scipy.optimize import OptimizeResult, dual_annealing, minimize, shgo +from collections import OrderedDict +from collections.abc import Mapping +from concurrent.futures import Future, ThreadPoolExecutor +from threading import Thread +from typing import Any, cast -from blop.scipy.configs import SCP, ScipyCFG +import numpy as np +from scipy.optimize import OptimizeResult + +from blop.protocols import ID_KEY, Optimizer +from blop.scipy.configs import SCP, Objective, ScipyCFG +from blop.scipy.normalized import InnerOptimizer from blop.scipy.optimizer import ScipyOptimizer +ScipyResult = ScipyOptimizer.Result +_Request = ScipyOptimizer._Request -class InnerOptimizer(): - def call(self, cost, callback, kws=None) -> ScipyOptimizer.Result | OptimizeResult: - raise NotImplementedError("Optimizer spec not Provided") +class OuterOptimizer(Optimizer): + """An optimizer object to supply an interactive interface for the scipy optimizers, with some caveats.""" -class Optimize(InnerOptimizer): - """ - Parameter Normalized implementation of scipy Optimize to be passed to a loop inversion. + def __init__(self, optimizer: InnerOptimizer, config: ScipyCFG | None = None, timeout: int | None = 200): + self.optimizer = optimizer + self.session(config=config if config else optimizer.config, timeout=timeout) - derives from inner optimizer protocol class - """ + def session(self, config: ScipyCFG, timeout: int | None = None): + """ + Through path for initialization and stateful reinitialization of optimization. - def __init__(self, optimizer: SCP, config: ScipyCFG) -> None: - self.optimizer = optimizer - self.config = config - - def call(self, cost, callback, kws=None) -> ScipyOptimizer.Result: - bounds = kws.pop("bounds", self.config.dofs) if kws else self.config.dofs - x0 = kws.pop("x0", self.config.initial) if kws else self.config.initial - return minimize( - fun=cost, - x0=x0, - method=self.config.optimizer if self.config.optimizer != SCP.Default else None, - bounds=bounds, - callback=callback, - options=kws, - ) - -class DualAnnealing(InnerOptimizer): - - def __init__(self, optimizer: SCP, config: ScipyCFG) -> None: - self.optimizer = optimizer - self.config = config - - def call(self, cost, callback, kws=None): - self.final = dual_annealing( - func=cost, - x0=_x, - bounds=self._bounds, - callback=dual_callback, - minimizer_kwargs={"callback": default_callback, "bounds": self._bounds, "options": kws}, - ) + 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] = [dof.parameter_name for dof in config.dofs] + 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 + + def cost(x): # thread safety needs timeout so there is not infinite hang on programs + """Cooperative thread that defers evaluation of cost call by scipy to the run engine.""" + req = _Request(args=x, future=Future()) + self._active[self._increment] = req + self._increment += 1 + res = req.future.result(timeout=self.SUGGESTION_TIMEOUT) + if res is None: + raise ValueError("return value is not present") + return res + + kw: dict = {} + self._thread_pool = None + if config.max_iter is not None: + 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 + + 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 mini_worker(): + try: + if config.threads: + with ThreadPoolExecutor(max_workers=config.threads) as pool: + kw["workers"] = pool.map + self.optimizer.call(cost, default_callback, kws=kw) + else: + self.optimizer.call(cost, default_callback, kws=kw) + + except (KeyboardInterrupt, TimeoutError): + # have to have timeout, so made it 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 = ScipyResult(list(self.optimizer.x0), np.nan, nit=self._increment) + + self._t = Thread(target=mini_worker, name="optimizer") + self._t.start() + 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]: + """ + 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. + + 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.name: result.fun}), + ] + 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")) diff --git a/src/blop/scipy/normalized.py b/src/blop/scipy/normalized.py new file mode 100644 index 00000000..33ad9473 --- /dev/null +++ b/src/blop/scipy/normalized.py @@ -0,0 +1,234 @@ +"""Normalized SciPy optimizer wrappers used by the cooperative optimization loop.""" +from typing import Any + +import numpy as np +from scipy.optimize import OptimizeResult, dual_annealing, minimize, shgo + +from blop.scipy.configs import SCP, ScipyCFG +from blop.scipy.optimizer import ScipyOptimizer + +ScipyResult = ScipyOptimizer.Result + + +class InnerOptimizer: + """Protocol for SciPy optimizer wrappers used by the suggest/ingest loop. + + Subclasses adapt optimizer-specific call signatures into a shared + ``call(cost, callback, kws)`` interface. This keeps optimizer internals + decoupled from the cooperative optimization loop that requests suggestions, + evaluates them externally, and ingests outcomes. + """ + + def __init__(self, config: ScipyCFG, base_args: dict | None = None) -> None: + """Store normalized configuration varaibles. + + Parameters + ---------- + config : ScipyCFG + Normalized optimizer configuration, including default bounds, + initial values, and selected method. + base_args : dict, optional + Extra keyword arguments always forwarded to wrapped optimizer. + """ + self.config = config + self.base_args = base_args + self._bounds: list[tuple[Any, Any]] = [] + scale = np.ones(len(config.dofs)) + + if config.rescale is not None: + if isinstance(config.rescale, list): + scale = config.rescale + else: + scale *= config.rescale + + for ind, dof in enumerate(config.dofs): + self._bounds.append(tuple(np.array(dof.bounds) / scale[ind])) + + self.x0 = np.mean(self._bounds, axis=1) + if config.initial is not None: + self.x0 = np.array(config.initial) / scale + + def call(self, cost, callback, kws=None) -> ScipyResult | OptimizeResult: + """Run the wrapped optimizer. + + Parameters + ---------- + cost : callable + Objective function evaluated by the optimizer. + callback : callable + Progress callback invoked by the underlying optimizer. + kws : dict, optional + Optimizer-specific options and temporary overrides. + + Returns + ------- + ScipyResult | OptimizeResult + Result object from the wrapped SciPy optimizer. + + Raises + ------ + NotImplementedError + Raised by the base protocol class when no implementation is + provided. + """ + raise NotImplementedError("Optimizer implementation not provided") + + +class Optimize(InnerOptimizer): + """Normalized wrapper around ``scipy.optimize.minimize``. + + This adapter reads default bounds and initial conditions from ``ScipyCFG`` + and forwards them to ``minimize`` using the common ``InnerOptimizer`` + interface. + """ + + def call(self, cost, callback, kws=None) -> ScipyResult: + """Execute ``scipy.optimize.minimize`` with normalized defaults. + + Parameters + ---------- + cost : callable + Objective function consumed by SciPy. + callback : callable + Callback passed directly to ``minimize``. + kws : dict, optional + Temporary call-time overrides. ``bounds`` and ``x0`` are extracted + from this dictionary when present; remaining values are passed as + ``options``. + + Returns + ------- + ScipyResult + SciPy optimization result (runtime type is ``OptimizeResult``). + """ + bounds = kws.pop("bounds", self._bounds) if kws else self._bounds + x0 = kws.pop("x0", self.x0) if kws else self.x0 + return minimize( + fun=cost, + x0=x0, + method=self.config.optimizer if self.config.optimizer != SCP.Default else None, + bounds=bounds, + callback=callback, + options=kws, + **self.base_args if self.base_args else {}, + ) + + +class DualAnnealing(InnerOptimizer): + """Normalized wrapper around ``scipy.optimize.dual_annealing``. + + ``dual_annealing`` uses a callback signature different from + ``scipy.optimize.minimize``. This adapter normalizes callback payloads so + the outer loop can handle intermediate results consistently. + """ + + def __init__(self, config: ScipyCFG, base_args: dict | None = None, inner_args: dict | None = None) -> None: + """Store normalized configuration for ``dual_annealing``. + + Parameters + ---------- + config : ScipyCFG + Normalized optimizer configuration, including bounds and initial + values. + base_args : dict, optional + Extra keyword arguments forwarded directly to + ``scipy.optimize.dual_annealing``. + inner_args : dict, optional + Additional values merged into ``minimizer_kwargs`` for the local + minimizer stage. + """ + self.inner_args = inner_args + super().__init__(config=config, base_args=base_args) + + def dual_callback(self, x, f, context): + """Convert dual-annealing callback values into a unified result type.""" + return ScipyResult(x, f, -1, context) + + def call(self, cost, callback, kws=None): + """Execute ``dual_annealing`` with normalized bounds and callbacks. + + Parameters + ---------- + cost : callable + Objective function consumed by SciPy. + callback : callable + Outer-loop callback expecting a normalized result object. + kws : dict, optional + Temporary call-time overrides. ``bounds`` and ``x0`` are extracted + when present; remaining keys are forwarded as local-minimizer + ``options``. + + Returns + ------- + OptimizeResult + Final SciPy result from ``dual_annealing``. + """ + bounds = kws.pop("bounds", self._bounds) if kws else self._bounds + x0 = kws.pop("x0", self.x0) if kws else self.x0 + opt = self.inner_args["options"] if self.inner_args else {} + return dual_annealing( + func=cost, + x0=x0, + bounds=bounds, + # Adapt SciPy's (x, f, context) callback to the outer callback + # contract that expects a normalized result object. + callback=lambda x, f, c: callback(self.dual_callback(x, f, c)), + minimizer_kwargs=self.inner_args if self.inner_args else {} | { + "callback": callback, "bounds": bounds, "options": opt | kws if kws else {}}, + **self.base_args if self.base_args else {}, + ) + + +class SHGO(InnerOptimizer): + """Normalized wrapper around ``scipy.optimize.shgo``. + + This adapter forwards globally optimized search settings while preserving + the shared ``InnerOptimizer`` call contract. + """ + + def __init__(self, config: ScipyCFG, base_args: dict | None = None, inner_args: dict | None = None) -> None: + """Store normalized configuration for ``scipy.optimize.shgo``. + + Parameters + ---------- + config : ScipyCFG + Normalized optimizer configuration, including bounds. + base_args : dict, optional + Extra keyword arguments forwarded directly to ``shgo``. + inner_args : dict, optional + Additional values merged into ``minimizer_kwargs`` for the local + minimizer phase. + """ + self.inner_args = inner_args + super().__init__(config=config, base_args=base_args) + + def call(self, cost, callback, kws=None): + """Execute ``shgo`` with normalized bounds and minimizer options. + + Parameters + ---------- + cost : callable + Objective function consumed by SciPy. + callback : callable + Callback forwarded to the local minimizer configuration. + kws : dict, optional + Temporary call-time overrides. ``bounds`` and ``workers`` are + extracted when present; remaining keys are forwarded as + local-minimizer ``options``. + + Returns + ------- + OptimizeResult + Final SciPy result from ``shgo``. + """ + bounds = kws.pop("bounds", self._bounds) if kws else self._bounds + workers = kws.pop("workers", 1) if kws else 1 + opt = self.inner_args["options"] if self.inner_args else {} + return shgo( + func=cost, + bounds=bounds, + minimizer_kwargs=self.inner_args if self.inner_args else {} | { + "callback": callback, "bounds": bounds, "options": opt | kws if kws else {}}, + **self.base_args if self.base_args else {}, + workers=workers, + ) diff --git a/src/blop/scipy/scipy_v2.py b/src/blop/scipy/scipy_v2.py new file mode 100644 index 00000000..404afea2 --- /dev/null +++ b/src/blop/scipy/scipy_v2.py @@ -0,0 +1,304 @@ +"""Scipy optimization power class for fast start QOL and Ax like agent behavior.""" + +from collections.abc import Mapping, Sequence +from typing import Any, cast + +import bluesky.preprocessors as bpp +from bluesky.callbacks import CallbackBase + +from blop.callbacks.logger import OptimizationLogger +from blop.callbacks.router import OptimizationCallbackRouter +from blop.plans import optimize +from blop.protocols import ( + AcquisitionPlan, + Actuator, + EvaluationFunction, + OptimizationProblem, + Sensor, +) +from blop.scipy.configs import SCP, Objective, RangeDOF, ScipyCFG +from blop.scipy.inverter import OuterOptimizer +from blop.scipy.normalized import SHGO, DualAnnealing, Optimize +from blop.utils import InferredReadable + +from .optimizer import ScipyOptimizer + + +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. + """ # ruff: ignore[missing-blank-line-after-summary] + + def __init__( + self, + sensors: Sequence[Sensor], + config: ScipyCFG, + evaluation_function: EvaluationFunction, + acquisition_plan: AcquisitionPlan | None = None, + **kwargs: Any, + ): + try: + if config.optimizer not in SCP: + raise ValueError(f"optimizer {config.optimizer} not in supported optimizers:{list(SCP)}") + except TypeError: + ... + + match config.optimizer: + case SCP.Dual_Annealing: + inner = DualAnnealing(config) + case SCP.SHGO: + inner = SHGO(config) + case _: + inner = Optimize(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.timeout = kwargs.pop("timeout", 200) + self._optimizer = OuterOptimizer(inner, 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) + self.sessioning = kwargs.pop("sessioning", True) + + @classmethod + def Agent( + cls, + sensors: Sequence[Sensor], + dofs: Sequence[RangeDOF], + objectives: Sequence[Objective], + evaluation_function: EvaluationFunction, + acquisition_plan: AcquisitionPlan | None = None, + 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, + ): + """ + An 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. + + See Also + -------- + blop.ax.Agent + + Notes + ----- + This is a nearly drop in replacement for Ax agent sans dof + outcome constraints and checkpointing + + + """ # noqa: D401 + if len(objectives) > 1: + 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), + rescale=kwargs.get("scale", None), + ) + return cls(sensors, config, evaluation_function, acquisition_plan, **kwargs) + + @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 + + @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. + + 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 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, 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) + self._optimizer.force_resiliance = self.resiliance + optimize_plan = optimize( + self.to_optimization_problem(), + iterations=iterations, + n_points=n_points, + readable_cache=self._readable_cache, + ) + + if self._callbacks: + optimize_plan = bpp.subs_wrapper( + optimize_plan, + self._callback_router, + ) + if self.sessioning: + with self._optimizer: + 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() From 960d8d746cd4b5f5e65413e4ca1efdccd5960257 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 30 Jul 2026 16:51:09 -0400 Subject: [PATCH 099/116] minor fixes with tests and demo --- .../source/tutorials/gradient-optimization.md | 6 ++--- src/blop/scipy/normalized.py | 11 +++++---- src/blop/scipy/scipy_v2.py | 24 +++++++++---------- src/blop/tests/gradient/test_integration.py | 8 +++---- 4 files changed, 25 insertions(+), 24 deletions(-) diff --git a/docs/source/tutorials/gradient-optimization.md b/docs/source/tutorials/gradient-optimization.md index 40487091..8eda2b09 100644 --- a/docs/source/tutorials/gradient-optimization.md +++ b/docs/source/tutorials/gradient-optimization.md @@ -22,7 +22,6 @@ First, let's import what we need and start the data infrastructure: ```{code-cell} ipython3 import logging import time -import warnings from typing import Any from bluesky.protocols import HasHints, HasParent, Hints, NamedMovable, Readable, Status @@ -32,10 +31,9 @@ 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 +from blop.scipy import SCP, ScipyCFG, Objective, RangeDOF, Scipy -# Suppress noisy logs from httpx +# Suppress noisy logs from httpx logging.getLogger("httpx").setLevel(logging.WARNING) ``` diff --git a/src/blop/scipy/normalized.py b/src/blop/scipy/normalized.py index 33ad9473..ece842dd 100644 --- a/src/blop/scipy/normalized.py +++ b/src/blop/scipy/normalized.py @@ -1,4 +1,5 @@ """Normalized SciPy optimizer wrappers used by the cooperative optimization loop.""" + from typing import Any import numpy as np @@ -173,8 +174,9 @@ def call(self, cost, callback, kws=None): # Adapt SciPy's (x, f, context) callback to the outer callback # contract that expects a normalized result object. callback=lambda x, f, c: callback(self.dual_callback(x, f, c)), - minimizer_kwargs=self.inner_args if self.inner_args else {} | { - "callback": callback, "bounds": bounds, "options": opt | kws if kws else {}}, + minimizer_kwargs=self.inner_args + if self.inner_args + else {} | {"callback": callback, "bounds": bounds, "options": opt | kws if kws else {}}, **self.base_args if self.base_args else {}, ) @@ -227,8 +229,9 @@ def call(self, cost, callback, kws=None): return shgo( func=cost, bounds=bounds, - minimizer_kwargs=self.inner_args if self.inner_args else {} | { - "callback": callback, "bounds": bounds, "options": opt | kws if kws else {}}, + minimizer_kwargs=self.inner_args + if self.inner_args + else {} | {"callback": callback, "bounds": bounds, "options": opt | kws if kws else {}}, **self.base_args if self.base_args else {}, workers=workers, ) diff --git a/src/blop/scipy/scipy_v2.py b/src/blop/scipy/scipy_v2.py index 404afea2..6c60c351 100644 --- a/src/blop/scipy/scipy_v2.py +++ b/src/blop/scipy/scipy_v2.py @@ -30,7 +30,7 @@ class Scipy: (allowing drop in swapping as much as possible). Useful as a cover in for all the QOL provided by the Agent object. - """ # ruff: ignore[missing-blank-line-after-summary] + """ # noqa: D205 def __init__( self, @@ -60,8 +60,8 @@ def __init__( self._evaluation_function = evaluation_function self._acquisition_plan = acquisition_plan self.timeout = kwargs.pop("timeout", 200) - self._optimizer = OuterOptimizer(inner, timeout=self.timeout) - self._optimizer.force_resiliance = self.resiliance = kwargs.pop("resiliance", True) + self.optimizer = OuterOptimizer(inner, 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) @@ -206,7 +206,7 @@ def to_optimization_problem(self) -> OptimizationProblem: blop.plans.optimize : Uses the optimization problem to run optimization. """ return OptimizationProblem( - optimizer=self._optimizer, + optimizer=self.optimizer, actuators=self._actuators, sensors=self._sensors, evaluation_function=self._evaluation_function, @@ -233,7 +233,7 @@ def suggest(self, num_points: int = 1) -> 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) + return self.optimizer.suggest(num_points) def ingest(self, points: list[dict]) -> None: """ @@ -256,14 +256,14 @@ def ingest(self, points: list[dict]) -> None: For complete examples, see :doc:`/how-to-guides/attach-data-to-experiments`. """ - self._optimizer.ingest(points) + 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) - self._optimizer.force_resiliance = self.resiliance + 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, @@ -277,7 +277,7 @@ def optimize(self, iterations=10, n_points=1): self._callback_router, ) if self.sessioning: - with self._optimizer: + with self.optimizer: yield from optimize_plan else: yield from optimize_plan @@ -301,4 +301,4 @@ def get_best_points(self) -> list[tuple[Any, Mapping, Mapping]]: -------- navigate_to_best : Plan stub to move actuators to a best point. """ - return self._optimizer.get_best_points() + return self.optimizer.get_best_points() diff --git a/src/blop/tests/gradient/test_integration.py b/src/blop/tests/gradient/test_integration.py index 90e55f15..936601f9 100644 --- a/src/blop/tests/gradient/test_integration.py +++ b/src/blop/tests/gradient/test_integration.py @@ -29,12 +29,12 @@ def __call__(self, uid, suggestions): evaluation_function=deflating_evaluation(), timeout=5, ) - agent._optimizer.force_resiliance = True + agent.optimizer.force_resiliance = True RE = RunEngine({}) RE(agent.optimize(40)) # time.sleep(0.1) - assert agent._optimizer.intermediate is not None - assert not agent._optimizer._active + 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.optimizer.final is not None assert agent.get_best_points() is not None From de1e7c965b4007279e760abfc7a5ad765b683b37 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Thu, 30 Jul 2026 17:23:41 -0400 Subject: [PATCH 100/116] test case name fixes --- src/blop/tests/{gradient => scipy}/__init__.py | 0 src/blop/tests/{gradient => scipy}/test_integration.py | 0 src/blop/tests/{gradient => scipy}/test_optimizer.py | 0 src/blop/tests/{gradient => scipy}/test_scipy.py | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename src/blop/tests/{gradient => scipy}/__init__.py (100%) rename src/blop/tests/{gradient => scipy}/test_integration.py (100%) rename src/blop/tests/{gradient => scipy}/test_optimizer.py (100%) rename src/blop/tests/{gradient => scipy}/test_scipy.py (100%) diff --git a/src/blop/tests/gradient/__init__.py b/src/blop/tests/scipy/__init__.py similarity index 100% rename from src/blop/tests/gradient/__init__.py rename to src/blop/tests/scipy/__init__.py diff --git a/src/blop/tests/gradient/test_integration.py b/src/blop/tests/scipy/test_integration.py similarity index 100% rename from src/blop/tests/gradient/test_integration.py rename to src/blop/tests/scipy/test_integration.py diff --git a/src/blop/tests/gradient/test_optimizer.py b/src/blop/tests/scipy/test_optimizer.py similarity index 100% rename from src/blop/tests/gradient/test_optimizer.py rename to src/blop/tests/scipy/test_optimizer.py diff --git a/src/blop/tests/gradient/test_scipy.py b/src/blop/tests/scipy/test_scipy.py similarity index 100% rename from src/blop/tests/gradient/test_scipy.py rename to src/blop/tests/scipy/test_scipy.py From 6a039a592b4a4394a85a8cc41471a3c229815cbb Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Fri, 31 Jul 2026 11:04:11 -0400 Subject: [PATCH 101/116] package reorder, unit test conversion and bug fixes --- src/blop/scipy/__init__.py | 12 +- src/blop/scipy/inverter.py | 19 +- .../scipy/{normalized.py => normalizers.py} | 14 +- src/blop/scipy/optimizer.py | 259 --------------- src/blop/scipy/scipy.py | 44 +-- src/blop/scipy/scipy_v2.py | 304 ------------------ src/blop/tests/scipy/test_optimizer.py | 55 +++- src/blop/tests/scipy/test_scipy.py | 55 ++-- 8 files changed, 122 insertions(+), 640 deletions(-) rename src/blop/scipy/{normalized.py => normalizers.py} (97%) delete mode 100644 src/blop/scipy/optimizer.py delete mode 100644 src/blop/scipy/scipy_v2.py diff --git a/src/blop/scipy/__init__.py b/src/blop/scipy/__init__.py index 1ea47c8b..6b0c7389 100644 --- a/src/blop/scipy/__init__.py +++ b/src/blop/scipy/__init__.py @@ -1,20 +1,18 @@ """Scipy Backend for Pertubative gradient and in house global optimizers.""" from .configs import SCP, Objective, RangeDOF, ScipyCFG -from .inverter import OuterOptimizer -from .normalized import SHGO, DualAnnealing, Optimize -from .optimizer import ScipyOptimizer -from .scipy_v2 import Scipy +from .inverter import InteractiveOptimizer +from .normalizers import SHGO, DualAnnealing, Minimize +from .scipy import Scipy __all__ = [ "SCP", "ScipyCFG", "Scipy", - "ScipyOptimizer", "DualAnnealing", - "Optimize", + "Minimize", "SHGO", - "OuterOptimizer", + "InteractiveOptimizer", "Objective", "RangeDOF", ] diff --git a/src/blop/scipy/inverter.py b/src/blop/scipy/inverter.py index c57cc10b..6e4a9366 100644 --- a/src/blop/scipy/inverter.py +++ b/src/blop/scipy/inverter.py @@ -3,6 +3,7 @@ from collections import OrderedDict from collections.abc import Mapping from concurrent.futures import Future, ThreadPoolExecutor +from dataclasses import dataclass from threading import Thread from typing import Any, cast @@ -11,14 +12,16 @@ from blop.protocols import ID_KEY, Optimizer from blop.scipy.configs import SCP, Objective, ScipyCFG -from blop.scipy.normalized import InnerOptimizer -from blop.scipy.optimizer import ScipyOptimizer +from blop.scipy.normalizers import InnerOptimizer, ScipyResult -ScipyResult = ScipyOptimizer.Result -_Request = ScipyOptimizer._Request +@dataclass +class _Request: + args: tuple + future: Future -class OuterOptimizer(Optimizer): + +class InteractiveOptimizer(Optimizer): """An optimizer object to supply an interactive interface for the scipy optimizers, with some caveats.""" def __init__(self, optimizer: InnerOptimizer, config: ScipyCFG | None = None, timeout: int | None = 200): @@ -37,9 +40,9 @@ def session(self, config: ScipyCFG, timeout: int | None = None): 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._active: dict[int, _Request] = OrderedDict() + self.intermediate: OptimizeResult | ScipyResult | None = None + self.final: OptimizeResult | ScipyResult | None = None self.SUGGESTION_TIMEOUT = timeout if config.rescale is not None: diff --git a/src/blop/scipy/normalized.py b/src/blop/scipy/normalizers.py similarity index 97% rename from src/blop/scipy/normalized.py rename to src/blop/scipy/normalizers.py index ece842dd..08779835 100644 --- a/src/blop/scipy/normalized.py +++ b/src/blop/scipy/normalizers.py @@ -1,14 +1,22 @@ """Normalized SciPy optimizer wrappers used by the cooperative optimization loop.""" +from dataclasses import dataclass from typing import Any import numpy as np from scipy.optimize import OptimizeResult, dual_annealing, minimize, shgo from blop.scipy.configs import SCP, ScipyCFG -from blop.scipy.optimizer import ScipyOptimizer -ScipyResult = ScipyOptimizer.Result + +@dataclass +class ScipyResult: + """Class to unify Optimize Result and other Scipy Results.""" + + x: list[float | int] + fun: float + nit: int + status: int = 2 class InnerOptimizer: @@ -75,7 +83,7 @@ def call(self, cost, callback, kws=None) -> ScipyResult | OptimizeResult: raise NotImplementedError("Optimizer implementation not provided") -class Optimize(InnerOptimizer): +class Minimize(InnerOptimizer): """Normalized wrapper around ``scipy.optimize.minimize``. This adapter reads default bounds and initial conditions from ``ScipyCFG`` diff --git a/src/blop/scipy/optimizer.py b/src/blop/scipy/optimizer.py deleted file mode 100644 index fca8fdf2..00000000 --- a/src/blop/scipy/optimizer.py +++ /dev/null @@ -1,259 +0,0 @@ -"""Core Scipy optimizer porting scipy algorithms.""" - -from collections import OrderedDict -from collections.abc import Mapping -from concurrent.futures import Future, ThreadPoolExecutor -from dataclasses import dataclass -from threading import Thread -from typing import Any, cast - -import numpy as np -from scipy.optimize import OptimizeResult, dual_annealing, minimize, shgo - -from blop.protocols import ID_KEY, Optimizer -from blop.scipy.configs import SCP, Objective, ScipyCFG - - -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: - """Class to unify Optimize Result and Scipy Result.""" - - x: list[float | int] - fun: float - nit: int - status: int = 2 - - 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.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 - """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) - if res is None: - raise ValueError("return value is not present") - return res - - kw = {} - self._thread_pool = None - if config.max_iter is not None: - 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 - - 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 - - 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}") - if self.intermediate and self.intermediate.fun < f: - return - 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={"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 list(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)}") - - def mini_worker(): - try: - if config.threads: - with ThreadPoolExecutor(max_workers=config.threads) as pool: - kw["workers"] = pool.map - 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): - """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]: - """ - 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. - - 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.name: result.fun}), - ] - 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")) diff --git a/src/blop/scipy/scipy.py b/src/blop/scipy/scipy.py index b769985c..f1e78b9d 100644 --- a/src/blop/scipy/scipy.py +++ b/src/blop/scipy/scipy.py @@ -17,10 +17,10 @@ Sensor, ) from blop.scipy.configs import SCP, Objective, RangeDOF, ScipyCFG +from blop.scipy.inverter import InteractiveOptimizer +from blop.scipy.normalizers import SHGO, DualAnnealing, Minimize from blop.utils import InferredReadable -from .optimizer import ScipyOptimizer - class Scipy: """ @@ -38,6 +38,19 @@ def __init__( acquisition_plan: AcquisitionPlan | None = None, **kwargs: Any, ): + try: + if config.optimizer not in SCP: + raise ValueError(f"optimizer {config.optimizer} not in supported optimizers:{list(SCP)}") + except TypeError: + ... + + match config.optimizer: + case SCP.Dual_Annealing: + self.inner = DualAnnealing(config) + case SCP.SHGO: + self.inner = SHGO(config) + case _: + self.inner = Minimize(config) self.config = config self._sensors = sensors @@ -45,8 +58,8 @@ def __init__( self._evaluation_function = evaluation_function 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.optimizer = InteractiveOptimizer(self.inner, 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) @@ -95,11 +108,6 @@ def Agent( """ # noqa: D401 - 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( @@ -196,7 +204,7 @@ def to_optimization_problem(self) -> OptimizationProblem: blop.plans.optimize : Uses the optimization problem to run optimization. """ return OptimizationProblem( - optimizer=self._optimizer, + optimizer=self.optimizer, actuators=self._actuators, sensors=self._sensors, evaluation_function=self._evaluation_function, @@ -223,7 +231,7 @@ def suggest(self, num_points: int = 1) -> 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) + return self.optimizer.suggest(num_points) def ingest(self, points: list[dict]) -> None: """ @@ -246,14 +254,14 @@ def ingest(self, points: list[dict]) -> None: For complete examples, see :doc:`/how-to-guides/attach-data-to-experiments`. """ - self._optimizer.ingest(points) + 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) - self._optimizer.force_resiliance = self.resiliance + if self.optimizer.final is not None: + self.config.initial = self.optimizer.final.x + self.optimizer = InteractiveOptimizer(self.inner, timeout=self.timeout) + self.optimizer.force_resiliance = self.resiliance optimize_plan = optimize( self.to_optimization_problem(), iterations=iterations, @@ -267,7 +275,7 @@ def optimize(self, iterations=10, n_points=1): self._callback_router, ) if self.sessioning: - with self._optimizer: + with self.optimizer: yield from optimize_plan else: yield from optimize_plan @@ -291,4 +299,4 @@ def get_best_points(self) -> list[tuple[Any, Mapping, Mapping]]: -------- navigate_to_best : Plan stub to move actuators to a best point. """ - return self._optimizer.get_best_points() + return self.optimizer.get_best_points() diff --git a/src/blop/scipy/scipy_v2.py b/src/blop/scipy/scipy_v2.py deleted file mode 100644 index 6c60c351..00000000 --- a/src/blop/scipy/scipy_v2.py +++ /dev/null @@ -1,304 +0,0 @@ -"""Scipy optimization power class for fast start QOL and Ax like agent behavior.""" - -from collections.abc import Mapping, Sequence -from typing import Any, cast - -import bluesky.preprocessors as bpp -from bluesky.callbacks import CallbackBase - -from blop.callbacks.logger import OptimizationLogger -from blop.callbacks.router import OptimizationCallbackRouter -from blop.plans import optimize -from blop.protocols import ( - AcquisitionPlan, - Actuator, - EvaluationFunction, - OptimizationProblem, - Sensor, -) -from blop.scipy.configs import SCP, Objective, RangeDOF, ScipyCFG -from blop.scipy.inverter import OuterOptimizer -from blop.scipy.normalized import SHGO, DualAnnealing, Optimize -from blop.utils import InferredReadable - -from .optimizer import ScipyOptimizer - - -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. - """ # noqa: D205 - - def __init__( - self, - sensors: Sequence[Sensor], - config: ScipyCFG, - evaluation_function: EvaluationFunction, - acquisition_plan: AcquisitionPlan | None = None, - **kwargs: Any, - ): - try: - if config.optimizer not in SCP: - raise ValueError(f"optimizer {config.optimizer} not in supported optimizers:{list(SCP)}") - except TypeError: - ... - - match config.optimizer: - case SCP.Dual_Annealing: - inner = DualAnnealing(config) - case SCP.SHGO: - inner = SHGO(config) - case _: - inner = Optimize(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.timeout = kwargs.pop("timeout", 200) - self.optimizer = OuterOptimizer(inner, 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) - self.sessioning = kwargs.pop("sessioning", True) - - @classmethod - def Agent( - cls, - sensors: Sequence[Sensor], - dofs: Sequence[RangeDOF], - objectives: Sequence[Objective], - evaluation_function: EvaluationFunction, - acquisition_plan: AcquisitionPlan | None = None, - 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, - ): - """ - An 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. - - See Also - -------- - blop.ax.Agent - - Notes - ----- - This is a nearly drop in replacement for Ax agent sans dof + outcome constraints and checkpointing - - - """ # noqa: D401 - if len(objectives) > 1: - 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), - rescale=kwargs.get("scale", None), - ) - return cls(sensors, config, evaluation_function, acquisition_plan, **kwargs) - - @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 - - @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. - - 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 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, 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) - self.optimizer.force_resiliance = self.resiliance - optimize_plan = optimize( - self.to_optimization_problem(), - iterations=iterations, - n_points=n_points, - readable_cache=self._readable_cache, - ) - - if self._callbacks: - optimize_plan = bpp.subs_wrapper( - optimize_plan, - self._callback_router, - ) - if self.sessioning: - with self.optimizer: - 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/tests/scipy/test_optimizer.py b/src/blop/tests/scipy/test_optimizer.py index cfd0bfab..8ac3c565 100644 --- a/src/blop/tests/scipy/test_optimizer.py +++ b/src/blop/tests/scipy/test_optimizer.py @@ -5,8 +5,9 @@ from blop.ax import Objective, RangeDOF from blop.protocols import ID_KEY, AcquisitionPlan, EvaluationFunction -from blop.scipy import ScipyOptimizer from blop.scipy.configs import SCP, ScipyCFG +from blop.scipy.inverter import InteractiveOptimizer +from blop.scipy.normalizers import SHGO, DualAnnealing, Minimize, ScipyResult from ..conftest import MovableSignal @@ -34,7 +35,8 @@ def optimizer_prep(): threads=4, rescale=[2.0, 3.0], ) - return ScipyOptimizer(config, timeout=5) + inner = Minimize(config) + return InteractiveOptimizer(inner, timeout=5) # ============================================================================ @@ -42,7 +44,7 @@ def optimizer_prep(): # ============================================================================ -@pytest.mark.parametrize("optimizer", list(SCP)) +@pytest.mark.parametrize("optimizer", list(SCP)[:10]) def test_scipy_optimizer_algorithms(mock_evaluation_function, mock_acquisition_plan, optimizer): """Test ScipyOptimizer with different SCP algorithms.""" movable1 = MovableSignal(name="test_movable1") @@ -58,7 +60,8 @@ def test_scipy_optimizer_algorithms(mock_evaluation_function, mock_acquisition_p max_iter=10, ) - opt = ScipyOptimizer(config, timeout=5) + inner = Minimize(config) + opt = InteractiveOptimizer(inner, timeout=5) assert opt._active is not None opt.close() @@ -78,7 +81,8 @@ def test_scipy_optimizer_bfgs_specific(mock_evaluation_function, mock_acquisitio max_iter=10, ) - opt = ScipyOptimizer(config, timeout=5) + inner = Minimize(config) + opt = InteractiveOptimizer(inner, timeout=5) assert opt.final is None # No optimization run yet opt.close() @@ -95,7 +99,26 @@ def test_scipy_optimizer_dual_annealing_specific(mock_evaluation_function, mock_ optimizer=SCP.Dual_Annealing, ) - opt = ScipyOptimizer(config, timeout=5) + inner = DualAnnealing(config) + opt = InteractiveOptimizer(inner, timeout=5) + assert opt.final is None # No optimization run yet + opt.close() + + +def test_scipy_optimizer_SHGO_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, + ) + + inner = SHGO(config) + opt = InteractiveOptimizer(inner, timeout=5) assert opt.final is None # No optimization run yet opt.close() @@ -112,7 +135,8 @@ def test_scipy_optimizer_threads_none(mock_evaluation_function, mock_acquisition threads=None, ) - opt = ScipyOptimizer(config, timeout=5) + inner = Minimize(config) + opt = InteractiveOptimizer(inner, timeout=5) assert opt._thread_pool is None # No thread pool when threads=None opt.close() @@ -129,7 +153,8 @@ def test_scipy_optimizer_threads_multiple(mock_evaluation_function, mock_acquisi threads=2, ) - opt = ScipyOptimizer(config, timeout=5) + inner = Minimize(config) + opt = InteractiveOptimizer(inner, timeout=5) # Configuration accepted opt.close() @@ -163,7 +188,7 @@ def test_rescaling_ingest_parameters(optimizer_prep): 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( + optimizer_prep.final = ScipyResult( x=[2.5, 3.0], # Scaled values fun=0.85, nit=15, @@ -252,7 +277,8 @@ def test_scipy_optimizer_context_manager(mock_evaluation_function, mock_acquisit config = ScipyCFG(dofs=[dof], objective=objective) - with ScipyOptimizer(config, timeout=5) as opt: + inner = Minimize(config) + with InteractiveOptimizer(inner, timeout=5) as opt: assert opt is not None time.sleep(0.1) suggestions = opt.suggest(1) @@ -267,7 +293,8 @@ def test_scipy_optimizer_session_reinit(mock_evaluation_function, mock_acquisiti config = ScipyCFG(dofs=[dof], objective=objective) - opt = ScipyOptimizer(config, timeout=5) + inner = Minimize(config) + opt = InteractiveOptimizer(inner, timeout=5) opt.suggest(1) # Call session to reinitialize @@ -282,7 +309,7 @@ def test_scipy_optimizer_session_reinit(mock_evaluation_function, mock_acquisiti 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( + optimizer_prep.intermediate = ScipyResult( x=[5.0, -5.0], fun=0.7, nit=5, @@ -298,13 +325,13 @@ def test_get_best_points_intermediate_only(optimizer_prep): 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( + optimizer_prep.intermediate = ScipyResult( x=[5.0, -5.0], fun=0.7, nit=5, status=0, ) - optimizer_prep.final = ScipyOptimizer.Result( + optimizer_prep.final = ScipyResult( x=[7.0, -5.0], fun=0.9, nit=10, diff --git a/src/blop/tests/scipy/test_scipy.py b/src/blop/tests/scipy/test_scipy.py index d90c2361..475f8a88 100644 --- a/src/blop/tests/scipy/test_scipy.py +++ b/src/blop/tests/scipy/test_scipy.py @@ -5,8 +5,9 @@ from blop.ax import Objective, RangeDOF from blop.protocols import ID_KEY, AcquisitionPlan, EvaluationFunction -from blop.scipy import ScipyOptimizer from blop.scipy.configs import ScipyCFG +from blop.scipy.inverter import InteractiveOptimizer +from blop.scipy.normalizers import ScipyResult from blop.scipy.scipy import Scipy from ..conftest import MovableSignal, ReadableSignal @@ -22,7 +23,7 @@ def mock_acquisition_plan(): return MagicMock(spec=AcquisitionPlan) -# agent._optimizer.close() is called so the standard timeout doesnt make the testing take forever +# agent.optimizer.close() is called so the standard timeout doesnt make the testing take forever @pytest.fixture(scope="function") @@ -88,7 +89,7 @@ def test_general_init(mock_evaluation_function, mock_acquisition_plan): assert agent.actuators == [dof1.actuator, dof2.actuator] assert agent.evaluation_function == mock_evaluation_function assert agent.acquisition_plan == mock_acquisition_plan - agent._optimizer.close() + agent.optimizer.close() def test_agent_init(mock_evaluation_function, mock_acquisition_plan): @@ -112,7 +113,7 @@ def test_agent_init(mock_evaluation_function, mock_acquisition_plan): assert agent.actuators == [dof1.actuator, dof2.actuator] assert agent.evaluation_function == mock_evaluation_function assert agent.acquisition_plan == mock_acquisition_plan - agent._optimizer.close() + agent.optimizer.close() def test_agent_to_optimization_problem(mock_evaluation_function): @@ -129,9 +130,9 @@ 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, ScipyOptimizer) + assert isinstance(optimization_problem.optimizer, InteractiveOptimizer) assert optimization_problem.acquisition_plan is None - agent._optimizer.close() + agent.optimizer.close() def test_agent_suggest(agent_prep): @@ -142,13 +143,13 @@ 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() + agent_prep.optimizer.close() 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() + agent_prep.optimizer.close() def test_agent_multithread(agent_prep): @@ -156,9 +157,9 @@ def test_agent_multithread(agent_prep): agent_prep.ingest([{"test_movable1": 0.1, "test_movable2": 0.2, "test_objective": 0.3, ID_KEY: 0}]) time.sleep(0.1) params = agent_prep.suggest(4) - print(agent_prep._optimizer._active) + print(agent_prep.optimizer._active) assert len(params) > 1 - agent_prep._optimizer.close() + agent_prep.optimizer.close() # ============================================================================ @@ -187,8 +188,8 @@ def test_scipy_cfg_rescaling_scalar(mock_evaluation_function, mock_acquisition_p ) # Verify rescaling was applied - assert agent._optimizer._scale[0] == 2.0 - agent._optimizer.close() + assert agent.optimizer._scale[0] == 2.0 + agent.optimizer.close() def test_scipy_cfg_rescaling_list(mock_evaluation_function, mock_acquisition_plan): @@ -214,9 +215,9 @@ def test_scipy_cfg_rescaling_list(mock_evaluation_function, mock_acquisition_pla ) # Verify rescaling per DOF - assert agent._optimizer._scale[0] == 2.0 - assert agent._optimizer._scale[1] == 3.0 - agent._optimizer.close() + 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): @@ -243,7 +244,7 @@ def test_scipy_cfg_initial_parameters(mock_evaluation_function, mock_acquisition ) # Verify initial parameters are set - agent._optimizer.close() + agent.optimizer.close() def test_scipy_cfg_max_iter_and_eps(mock_evaluation_function, mock_acquisition_plan): @@ -263,7 +264,7 @@ def test_scipy_cfg_max_iter_and_eps(mock_evaluation_function, mock_acquisition_p assert config.eps == 1e-6 -def test_agent_invalid_optimizer_enum(mock_evaluation_function, mock_acquisition_plan): +def test_agent_invalidoptimizer_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") @@ -276,7 +277,7 @@ def test_agent_invalid_optimizer_enum(mock_evaluation_function, mock_acquisition dofs=[dof], objectives=[objective], evaluation_function=mock_evaluation_function, - optimizer="invalid_optimizer", + optimizer="invalidoptimizer", ) @@ -310,7 +311,7 @@ def test_subscribe_callback(secoundary_agent_prep): assert len(secoundary_agent_prep.callbacks) == initial_count + 1 assert callback in secoundary_agent_prep.callbacks - secoundary_agent_prep._optimizer.close() + secoundary_agent_prep.optimizer.close() def test_subscribe_duplicate_raises(secoundary_agent_prep): @@ -321,7 +322,7 @@ def test_subscribe_duplicate_raises(secoundary_agent_prep): with pytest.raises(ValueError, match="already subscribed"): secoundary_agent_prep.subscribe(callback) - secoundary_agent_prep._optimizer.close() + secoundary_agent_prep.optimizer.close() def test_unsubscribe_callback(secoundary_agent_prep): @@ -332,7 +333,7 @@ def test_unsubscribe_callback(secoundary_agent_prep): secoundary_agent_prep.unsubscribe(callback) assert callback not in secoundary_agent_prep.callbacks - secoundary_agent_prep._optimizer.close() + secoundary_agent_prep.optimizer.close() def test_unsubscribe_not_subscribed_raises(secoundary_agent_prep): @@ -342,7 +343,7 @@ def test_unsubscribe_not_subscribed_raises(secoundary_agent_prep): with pytest.raises(ValueError): secoundary_agent_prep.unsubscribe(callback) - secoundary_agent_prep._optimizer.close() + secoundary_agent_prep.optimizer.close() # ============================================================================ @@ -355,7 +356,7 @@ def test_scipy_secoundary(secoundary_agent_prep): suggestions = secoundary_agent_prep.suggest(1) assert len(suggestions) == 1 assert "test_movable" in suggestions[0] - secoundary_agent_prep._optimizer.close() + secoundary_agent_prep.optimizer.close() def test_scipy_large_rescale_factors(mock_evaluation_function, mock_acquisition_plan): @@ -387,21 +388,21 @@ def test_scipy_large_rescale_factors(mock_evaluation_function, mock_acquisition_ # Values should still be in original bounds assert 0 <= suggestions[0]["test_movable1"] <= 10 assert 0 <= suggestions[0]["test_movable2"] <= 10 - agent._optimizer.close() + agent.optimizer.close() def test_suggest_after_final_optimization(secoundary_agent_prep): """Test suggest() after final optimization returns final result parameterization.""" # Set final optimization result - secoundary_agent_prep._optimizer.final = ScipyOptimizer.Result( + secoundary_agent_prep.optimizer.final = ScipyResult( x=[7.0], fun=0.95, nit=20, status=0, ) - suggestions = secoundary_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 - secoundary_agent_prep._optimizer.close() + secoundary_agent_prep.optimizer.close() From 38694671a8cabab69344bbf3070a4cafc3717477 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Fri, 31 Jul 2026 11:18:37 -0400 Subject: [PATCH 102/116] had to re add the sleep statement. final has a race condition making slow update but is not important to the core api, more of a verif of poss. convergence --- src/blop/tests/scipy/test_integration.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/blop/tests/scipy/test_integration.py b/src/blop/tests/scipy/test_integration.py index 936601f9..63d3f5ff 100644 --- a/src/blop/tests/scipy/test_integration.py +++ b/src/blop/tests/scipy/test_integration.py @@ -1,3 +1,5 @@ +import time + from bluesky import RunEngine from blop.protocols import EvaluationFunction @@ -32,9 +34,9 @@ def __call__(self, uid, suggestions): agent.optimizer.force_resiliance = True RE = RunEngine({}) 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 + RE(agent.optimize(40)) assert agent.get_best_points() is not None + time.sleep(.1) + assert agent.optimizer.final is not None From 70141b0937055d29ca2b3021fd239d4df3b88d59 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Fri, 31 Jul 2026 11:21:34 -0400 Subject: [PATCH 103/116] ruff AAAAAAAAAAAAAAAAAAAA --- src/blop/tests/scipy/test_integration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/blop/tests/scipy/test_integration.py b/src/blop/tests/scipy/test_integration.py index 63d3f5ff..ab9419f6 100644 --- a/src/blop/tests/scipy/test_integration.py +++ b/src/blop/tests/scipy/test_integration.py @@ -38,5 +38,5 @@ def __call__(self, uid, suggestions): assert not agent.optimizer._active RE(agent.optimize(40)) assert agent.get_best_points() is not None - time.sleep(.1) + time.sleep(0.1) assert agent.optimizer.final is not None From d7a7ddd9208babce4d6b856341f72b513b9caf39 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Fri, 31 Jul 2026 11:30:57 -0400 Subject: [PATCH 104/116] 3.11 fix --- src/blop/scipy/scipy.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/blop/scipy/scipy.py b/src/blop/scipy/scipy.py index f1e78b9d..71fe24df 100644 --- a/src/blop/scipy/scipy.py +++ b/src/blop/scipy/scipy.py @@ -38,11 +38,8 @@ def __init__( acquisition_plan: AcquisitionPlan | None = None, **kwargs: Any, ): - try: - if config.optimizer not in SCP: - raise ValueError(f"optimizer {config.optimizer} not in supported optimizers:{list(SCP)}") - except TypeError: - ... + if config.optimizer not in list(SCP): + raise ValueError(f"optimizer {config.optimizer} not in supported optimizers:{list(SCP)}") match config.optimizer: case SCP.Dual_Annealing: From 185600da39c28a497dfafdb262a91ac3e73a17bf Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Fri, 31 Jul 2026 11:35:40 -0400 Subject: [PATCH 105/116] Enum name improvements --- src/blop/scipy/configs.py | 8 ++++---- src/blop/scipy/inverter.py | 2 +- src/blop/scipy/scipy.py | 2 +- src/blop/tests/scipy/test_integration.py | 2 +- src/blop/tests/scipy/test_optimizer.py | 4 ++-- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/blop/scipy/configs.py b/src/blop/scipy/configs.py index 8f1ec889..b7f27586 100644 --- a/src/blop/scipy/configs.py +++ b/src/blop/scipy/configs.py @@ -111,8 +111,8 @@ class SCP(StrEnum): Default = "L-BFGS-B" - Nelder_Mead = "Nelder-Mead" - Powell = "Powell" + NELDER_MEAD = "Nelder-Mead" + PoWELL = "Powell" CG = "CG" BFGS = "BFGS" # Newton_CG = "Newton-CG" @@ -121,13 +121,13 @@ class SCP(StrEnum): COBYLA = "COBYLA" COBYQA = "COBYQA" SLSQP = "SLSQP" - Trust_Constr = "trust-constr" + TRUST_CONSTR = "trust-constr" # Dogleg = "dogleg" # Trust_NCG = "trust-ncg" # Trust_Exact = "trust-exact" # Trust_Krylov = "trust-krylov" - Dual_Annealing = "dual annealing" + DUAL_ANNEALING = "dual annealing" SHGO = "SHGO" diff --git a/src/blop/scipy/inverter.py b/src/blop/scipy/inverter.py index 6e4a9366..cc764365 100644 --- a/src/blop/scipy/inverter.py +++ b/src/blop/scipy/inverter.py @@ -64,7 +64,7 @@ def cost(x): # thread safety needs timeout so there is not infinite hang on pro kw: dict = {} self._thread_pool = None if config.max_iter is not None: - if config.optimizer is not SCP.Trust_Constr: + if config.optimizer is not SCP.TRUST_CONSTR: kw["max_iter"] = config.max_iter else: kw["maxiter"] = config.max_iter diff --git a/src/blop/scipy/scipy.py b/src/blop/scipy/scipy.py index 71fe24df..85143609 100644 --- a/src/blop/scipy/scipy.py +++ b/src/blop/scipy/scipy.py @@ -42,7 +42,7 @@ def __init__( raise ValueError(f"optimizer {config.optimizer} not in supported optimizers:{list(SCP)}") match config.optimizer: - case SCP.Dual_Annealing: + case SCP.DUAL_ANNEALING: self.inner = DualAnnealing(config) case SCP.SHGO: self.inner = SHGO(config) diff --git a/src/blop/tests/scipy/test_integration.py b/src/blop/tests/scipy/test_integration.py index ab9419f6..348bbbe6 100644 --- a/src/blop/tests/scipy/test_integration.py +++ b/src/blop/tests/scipy/test_integration.py @@ -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, optimizer=SCP.Dual_Annealing) + config = ScipyCFG(dofs=[dof], objective=objective, optimizer=SCP.DUAL_ANNEALING) class deflating_evaluation(EvaluationFunction): def __init__(self): diff --git a/src/blop/tests/scipy/test_optimizer.py b/src/blop/tests/scipy/test_optimizer.py index 8ac3c565..223d7d44 100644 --- a/src/blop/tests/scipy/test_optimizer.py +++ b/src/blop/tests/scipy/test_optimizer.py @@ -96,7 +96,7 @@ def test_scipy_optimizer_dual_annealing_specific(mock_evaluation_function, mock_ config = ScipyCFG( dofs=[dof], objective=objective, - optimizer=SCP.Dual_Annealing, + optimizer=SCP.DUAL_ANNEALING, ) inner = DualAnnealing(config) @@ -114,7 +114,7 @@ def test_scipy_optimizer_SHGO_specific(mock_evaluation_function, mock_acquisitio config = ScipyCFG( dofs=[dof], objective=objective, - optimizer=SCP.Dual_Annealing, + optimizer=SCP.SHGO, ) inner = SHGO(config) From 30ce1cd1e3f2a0bb8ed5a1a757e59c68a690582a Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Fri, 31 Jul 2026 11:59:56 -0400 Subject: [PATCH 106/116] fixed doc enum error --- docs/source/tutorials/gradient-optimization.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/tutorials/gradient-optimization.md b/docs/source/tutorials/gradient-optimization.md index 8eda2b09..15ba7807 100644 --- a/docs/source/tutorials/gradient-optimization.md +++ b/docs/source/tutorials/gradient-optimization.md @@ -176,7 +176,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). ```{code-cell} ipython3 -config = ScipyCFG(dofs=dofs, objective=objectives[0], optimizer=SCP.Dual_Annealing, threads=4, max_iter=2, 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, From 4edcb4dc9b8ac745967d6910054e3b689c804dd9 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Tue, 4 Aug 2026 11:58:54 -0400 Subject: [PATCH 107/116] removed assertion around unguarenteed object data member with race condition --- src/blop/tests/scipy/test_integration.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/blop/tests/scipy/test_integration.py b/src/blop/tests/scipy/test_integration.py index 348bbbe6..e85b64f1 100644 --- a/src/blop/tests/scipy/test_integration.py +++ b/src/blop/tests/scipy/test_integration.py @@ -38,5 +38,3 @@ def __call__(self, uid, suggestions): assert not agent.optimizer._active RE(agent.optimize(40)) assert agent.get_best_points() is not None - time.sleep(0.1) - assert agent.optimizer.final is not None From 11894aa4be260bb9b973871a92d0293154e1ba01 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Wed, 5 Aug 2026 12:03:34 -0400 Subject: [PATCH 108/116] .........................ruff --- src/blop/tests/scipy/test_integration.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/blop/tests/scipy/test_integration.py b/src/blop/tests/scipy/test_integration.py index e85b64f1..6f5338cb 100644 --- a/src/blop/tests/scipy/test_integration.py +++ b/src/blop/tests/scipy/test_integration.py @@ -1,4 +1,3 @@ -import time from bluesky import RunEngine From 44c4d905407e25f5c4944538274ea826abc28034 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Wed, 5 Aug 2026 12:09:45 -0400 Subject: [PATCH 109/116] __ruff__ --- src/blop/tests/scipy/test_integration.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/blop/tests/scipy/test_integration.py b/src/blop/tests/scipy/test_integration.py index 6f5338cb..82b99644 100644 --- a/src/blop/tests/scipy/test_integration.py +++ b/src/blop/tests/scipy/test_integration.py @@ -1,4 +1,3 @@ - from bluesky import RunEngine from blop.protocols import EvaluationFunction From 7579a3b3cefb6e77e23e29035e4b55cbb1aa68e8 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Wed, 5 Aug 2026 12:37:10 -0400 Subject: [PATCH 110/116] another enum fix --- src/blop/scipy/configs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/blop/scipy/configs.py b/src/blop/scipy/configs.py index b7f27586..9c251221 100644 --- a/src/blop/scipy/configs.py +++ b/src/blop/scipy/configs.py @@ -112,7 +112,7 @@ class SCP(StrEnum): Default = "L-BFGS-B" NELDER_MEAD = "Nelder-Mead" - PoWELL = "Powell" + POWELL = "Powell" CG = "CG" BFGS = "BFGS" # Newton_CG = "Newton-CG" From d04a998f1b2add7b9f4f99b32bc31e31bb687cbd Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Mon, 10 Aug 2026 12:48:43 -0400 Subject: [PATCH 111/116] removed waits and integration test, made better startup and error raising on main thread. added error enum for tests --- src/blop/scipy/configs.py | 2 +- src/blop/scipy/inverter.py | 25 +++++++++++++--- src/blop/tests/scipy/test_integration.py | 38 ------------------------ src/blop/tests/scipy/test_optimizer.py | 25 ++++++++++++++-- 4 files changed, 44 insertions(+), 46 deletions(-) delete mode 100644 src/blop/tests/scipy/test_integration.py diff --git a/src/blop/scipy/configs.py b/src/blop/scipy/configs.py index 9c251221..f251bdfb 100644 --- a/src/blop/scipy/configs.py +++ b/src/blop/scipy/configs.py @@ -122,7 +122,7 @@ class SCP(StrEnum): COBYQA = "COBYQA" SLSQP = "SLSQP" TRUST_CONSTR = "trust-constr" - # Dogleg = "dogleg" + ERROR = "dogleg" # Trust_NCG = "trust-ncg" # Trust_Exact = "trust-exact" # Trust_Krylov = "trust-krylov" diff --git a/src/blop/scipy/inverter.py b/src/blop/scipy/inverter.py index cc764365..95711f62 100644 --- a/src/blop/scipy/inverter.py +++ b/src/blop/scipy/inverter.py @@ -4,7 +4,7 @@ from collections.abc import Mapping from concurrent.futures import Future, ThreadPoolExecutor from dataclasses import dataclass -from threading import Thread +from threading import Event, Thread from typing import Any, cast import numpy as np @@ -44,6 +44,8 @@ def session(self, config: ScipyCFG, timeout: int | None = None): self.intermediate: OptimizeResult | ScipyResult | None = None self.final: OptimizeResult | ScipyResult | None = None self.SUGGESTION_TIMEOUT = timeout + self.thread_monitor = Future() + self.thread_start = Event() if config.rescale is not None: if isinstance(config.rescale, list): @@ -55,6 +57,7 @@ def cost(x): # thread safety needs timeout so there is not infinite hang on pro """Cooperative thread that defers evaluation of cost call by scipy to the run engine.""" req = _Request(args=x, future=Future()) self._active[self._increment] = req + self.thread_start.set() self._increment += 1 res = req.future.result(timeout=self.SUGGESTION_TIMEOUT) if res is None: @@ -82,21 +85,27 @@ def mini_worker(): if config.threads: with ThreadPoolExecutor(max_workers=config.threads) as pool: kw["workers"] = pool.map - self.optimizer.call(cost, default_callback, kws=kw) + res = self.optimizer.call(cost, default_callback, kws=kw) else: - self.optimizer.call(cost, default_callback, kws=kw) + res = self.optimizer.call(cost, default_callback, kws=kw) + self.thread_monitor.set_result(res) except (KeyboardInterrupt, TimeoutError): # have to have timeout, so made it 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 = ScipyResult(list(self.optimizer.x0), np.nan, nit=self._increment) + # self.thread_monitor.set_result(self.final) + return + except Exception as e: + self.thread_monitor.set_exception(e) self._t = Thread(target=mini_worker, name="optimizer") self._t.start() + self.thread_start.wait(timeout=10) return self def __enter__(self): @@ -125,6 +134,14 @@ 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. """ + try: + self.final = self.thread_monitor.result(timeout=0.01) + if not self.force_resiliance: + print(self.final) + raise RuntimeError("The optimizer has suspended or reached convergence") + except TimeoutError: + ... + 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)) diff --git a/src/blop/tests/scipy/test_integration.py b/src/blop/tests/scipy/test_integration.py deleted file mode 100644 index 82b99644..00000000 --- a/src/blop/tests/scipy/test_integration.py +++ /dev/null @@ -1,38 +0,0 @@ -from bluesky import RunEngine - -from blop.protocols import EvaluationFunction -from blop.scipy import Scipy -from blop.scipy.configs import SCP, Objective, RangeDOF, ScipyCFG - -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, optimizer=SCP.DUAL_ANNEALING) - - 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 ** (-0.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(40)) - assert agent.optimizer.intermediate is not None - assert not agent.optimizer._active - RE(agent.optimize(40)) - assert agent.get_best_points() is not None diff --git a/src/blop/tests/scipy/test_optimizer.py b/src/blop/tests/scipy/test_optimizer.py index 223d7d44..20a00c00 100644 --- a/src/blop/tests/scipy/test_optimizer.py +++ b/src/blop/tests/scipy/test_optimizer.py @@ -1,4 +1,3 @@ -import time from unittest.mock import MagicMock import pytest @@ -66,6 +65,28 @@ def test_scipy_optimizer_algorithms(mock_evaluation_function, mock_acquisition_p opt.close() +def test_scipy_optimizer_configuration_error(mock_evaluation_function, mock_acquisition_plan): + """Test ScipyOptimizer throws error due to bad internal configuration of scipy (no jacobian provided in result)""" + 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.ERROR, + max_iter=10, + ) + + inner = Minimize(config) + opt = InteractiveOptimizer(inner, timeout=5) + with pytest.raises(ValueError): + opt.suggest() + opt.close() + + def test_scipy_optimizer_bfgs_specific(mock_evaluation_function, mock_acquisition_plan): """Test ScipyOptimizer explicitly with BFGS.""" movable1 = MovableSignal(name="test_movable1") @@ -280,7 +301,6 @@ def test_scipy_optimizer_context_manager(mock_evaluation_function, mock_acquisit inner = Minimize(config) with InteractiveOptimizer(inner, timeout=5) as opt: assert opt is not None - time.sleep(0.1) suggestions = opt.suggest(1) assert len(suggestions) == 1 @@ -299,7 +319,6 @@ def test_scipy_optimizer_session_reinit(mock_evaluation_function, mock_acquisiti # 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 From 6c5c045760bdbb3619a090e072864a9b6cfab12b Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Mon, 10 Aug 2026 14:40:04 -0400 Subject: [PATCH 112/116] further improvements to startup and testing of runtime state --- src/blop/scipy/configs.py | 2 +- src/blop/scipy/inverter.py | 8 +++- src/blop/tests/scipy/test_optimizer.py | 59 ++++++++++++++++++-------- src/blop/tests/scipy/test_scipy.py | 23 ++++++++++ 4 files changed, 72 insertions(+), 20 deletions(-) diff --git a/src/blop/scipy/configs.py b/src/blop/scipy/configs.py index f251bdfb..d8b6ca4a 100644 --- a/src/blop/scipy/configs.py +++ b/src/blop/scipy/configs.py @@ -122,7 +122,7 @@ class SCP(StrEnum): COBYQA = "COBYQA" SLSQP = "SLSQP" TRUST_CONSTR = "trust-constr" - ERROR = "dogleg" + # DOGLEG = "dogleg" # Trust_NCG = "trust-ncg" # Trust_Exact = "trust-exact" # Trust_Krylov = "trust-krylov" diff --git a/src/blop/scipy/inverter.py b/src/blop/scipy/inverter.py index 95711f62..ef1b5a83 100644 --- a/src/blop/scipy/inverter.py +++ b/src/blop/scipy/inverter.py @@ -105,7 +105,13 @@ def mini_worker(): self._t = Thread(target=mini_worker, name="optimizer") self._t.start() - self.thread_start.wait(timeout=10) + if not self.thread_start.wait(timeout=1): + try: + err = self.thread_monitor.exception(timeout=.1) + if err: + raise err + except TimeoutError: + ... return self def __enter__(self): diff --git a/src/blop/tests/scipy/test_optimizer.py b/src/blop/tests/scipy/test_optimizer.py index 20a00c00..09f58212 100644 --- a/src/blop/tests/scipy/test_optimizer.py +++ b/src/blop/tests/scipy/test_optimizer.py @@ -1,12 +1,13 @@ from unittest.mock import MagicMock import pytest +from scipy.optimize import OptimizeResult from blop.ax import Objective, RangeDOF from blop.protocols import ID_KEY, AcquisitionPlan, EvaluationFunction from blop.scipy.configs import SCP, ScipyCFG from blop.scipy.inverter import InteractiveOptimizer -from blop.scipy.normalizers import SHGO, DualAnnealing, Minimize, ScipyResult +from blop.scipy.normalizers import SHGO, DualAnnealing, InnerOptimizer, Minimize, ScipyResult from ..conftest import MovableSignal @@ -35,7 +36,7 @@ def optimizer_prep(): rescale=[2.0, 3.0], ) inner = Minimize(config) - return InteractiveOptimizer(inner, timeout=5) + return InteractiveOptimizer(inner, timeout=1) # ============================================================================ @@ -60,13 +61,13 @@ def test_scipy_optimizer_algorithms(mock_evaluation_function, mock_acquisition_p ) inner = Minimize(config) - opt = InteractiveOptimizer(inner, timeout=5) + opt = InteractiveOptimizer(inner, timeout=1) assert opt._active is not None opt.close() -def test_scipy_optimizer_configuration_error(mock_evaluation_function, mock_acquisition_plan): - """Test ScipyOptimizer throws error due to bad internal configuration of scipy (no jacobian provided in result)""" +def test_scipy_optimizer_internal_startup_error(mock_evaluation_function, mock_acquisition_plan): + """Test ScipyOptimizer passes error from scipy internal (optimizer not defined)""" movable1 = MovableSignal(name="test_movable1") movable2 = MovableSignal(name="test_movable2") dof1 = RangeDOF(actuator=movable1, bounds=(0, 10), parameter_type="float") @@ -76,15 +77,37 @@ def test_scipy_optimizer_configuration_error(mock_evaluation_function, mock_acqu config = ScipyCFG( dofs=[dof1, dof2], objective=objective, - optimizer=SCP.ERROR, max_iter=10, ) + inner = InnerOptimizer(config) + with pytest.raises(NotImplementedError): + InteractiveOptimizer(inner, timeout=1) - inner = Minimize(config) - opt = InteractiveOptimizer(inner, timeout=5) - with pytest.raises(ValueError): + +def test_scipy_optimizer_internal_runtime_error(mock_evaluation_function, mock_acquisition_plan): + """Test ScipyOptimizer passes error from scipy internal (optimizer not defined)""" + 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, + max_iter=10, + ) + + class ErrFun(InnerOptimizer): + def call(self, cost, callback, kws=None) -> ScipyResult | OptimizeResult: + cost([1, 2]) + raise RuntimeError("This should be caught by main thread") + inner = ErrFun(config) + opt = InteractiveOptimizer(inner, timeout=1) + with pytest.raises(RuntimeError): + sg = opt.suggest() + opt.ingest([sg[0] | {objective.name: -1}]) opt.suggest() - opt.close() def test_scipy_optimizer_bfgs_specific(mock_evaluation_function, mock_acquisition_plan): @@ -103,7 +126,7 @@ def test_scipy_optimizer_bfgs_specific(mock_evaluation_function, mock_acquisitio ) inner = Minimize(config) - opt = InteractiveOptimizer(inner, timeout=5) + opt = InteractiveOptimizer(inner, timeout=1) assert opt.final is None # No optimization run yet opt.close() @@ -121,7 +144,7 @@ def test_scipy_optimizer_dual_annealing_specific(mock_evaluation_function, mock_ ) inner = DualAnnealing(config) - opt = InteractiveOptimizer(inner, timeout=5) + opt = InteractiveOptimizer(inner, timeout=1) assert opt.final is None # No optimization run yet opt.close() @@ -139,7 +162,7 @@ def test_scipy_optimizer_SHGO_specific(mock_evaluation_function, mock_acquisitio ) inner = SHGO(config) - opt = InteractiveOptimizer(inner, timeout=5) + opt = InteractiveOptimizer(inner, timeout=1) assert opt.final is None # No optimization run yet opt.close() @@ -157,7 +180,7 @@ def test_scipy_optimizer_threads_none(mock_evaluation_function, mock_acquisition ) inner = Minimize(config) - opt = InteractiveOptimizer(inner, timeout=5) + opt = InteractiveOptimizer(inner, timeout=1) assert opt._thread_pool is None # No thread pool when threads=None opt.close() @@ -175,7 +198,7 @@ def test_scipy_optimizer_threads_multiple(mock_evaluation_function, mock_acquisi ) inner = Minimize(config) - opt = InteractiveOptimizer(inner, timeout=5) + opt = InteractiveOptimizer(inner, timeout=1) # Configuration accepted opt.close() @@ -299,7 +322,7 @@ def test_scipy_optimizer_context_manager(mock_evaluation_function, mock_acquisit config = ScipyCFG(dofs=[dof], objective=objective) inner = Minimize(config) - with InteractiveOptimizer(inner, timeout=5) as opt: + with InteractiveOptimizer(inner, timeout=1) as opt: assert opt is not None suggestions = opt.suggest(1) assert len(suggestions) == 1 @@ -314,11 +337,11 @@ def test_scipy_optimizer_session_reinit(mock_evaluation_function, mock_acquisiti config = ScipyCFG(dofs=[dof], objective=objective) inner = Minimize(config) - opt = InteractiveOptimizer(inner, timeout=5) + opt = InteractiveOptimizer(inner, timeout=1) opt.suggest(1) # Call session to reinitialize - opt.session(config, timeout=5) + opt.session(config, timeout=1) # State should be reset assert opt._increment == 1 assert len(opt._active) == 1 diff --git a/src/blop/tests/scipy/test_scipy.py b/src/blop/tests/scipy/test_scipy.py index 475f8a88..6f8884cb 100644 --- a/src/blop/tests/scipy/test_scipy.py +++ b/src/blop/tests/scipy/test_scipy.py @@ -1,4 +1,5 @@ import time +import types from unittest.mock import MagicMock import pytest @@ -406,3 +407,25 @@ def test_suggest_after_final_optimization(secoundary_agent_prep): assert suggestions[0]["test_movable"] == 7.0 assert suggestions[0][ID_KEY] == 20 secoundary_agent_prep.optimizer.close() + + +def test_optimize_after_final_optimization(secoundary_agent_prep): + """Test agent setup of optimize plane""" + # Set final optimization result + + assert isinstance(secoundary_agent_prep.optimize(0), types.GeneratorType) + secoundary_agent_prep.optimizer.close() + + +def test_optimize_with_prev_final(secoundary_agent_prep): + """Test agent setup of optimize plane""" + # Set final optimization result + secoundary_agent_prep.optimizer.final = ScipyResult( + x=[7.0], + fun=0.95, + nit=20, + status=0, + ) + + assert isinstance(secoundary_agent_prep.optimize(0), types.GeneratorType) + secoundary_agent_prep.optimizer.close() From cc93914788febfb3b00067b8d9628d32ee56fbd2 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Mon, 10 Aug 2026 14:43:23 -0400 Subject: [PATCH 113/116] ruff takes no prisoners --- src/blop/scipy/inverter.py | 2 +- src/blop/tests/scipy/test_optimizer.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/blop/scipy/inverter.py b/src/blop/scipy/inverter.py index ef1b5a83..069093f7 100644 --- a/src/blop/scipy/inverter.py +++ b/src/blop/scipy/inverter.py @@ -107,7 +107,7 @@ def mini_worker(): self._t.start() if not self.thread_start.wait(timeout=1): try: - err = self.thread_monitor.exception(timeout=.1) + err = self.thread_monitor.exception(timeout=0.1) if err: raise err except TimeoutError: diff --git a/src/blop/tests/scipy/test_optimizer.py b/src/blop/tests/scipy/test_optimizer.py index 09f58212..7c8618f4 100644 --- a/src/blop/tests/scipy/test_optimizer.py +++ b/src/blop/tests/scipy/test_optimizer.py @@ -102,6 +102,7 @@ class ErrFun(InnerOptimizer): def call(self, cost, callback, kws=None) -> ScipyResult | OptimizeResult: cost([1, 2]) raise RuntimeError("This should be caught by main thread") + inner = ErrFun(config) opt = InteractiveOptimizer(inner, timeout=1) with pytest.raises(RuntimeError): From 5b2d0ff8e9d57d96327a5a61f673c70620d7b797 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Mon, 10 Aug 2026 14:48:02 -0400 Subject: [PATCH 114/116] removed more sleep statements --- src/blop/tests/scipy/test_scipy.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/blop/tests/scipy/test_scipy.py b/src/blop/tests/scipy/test_scipy.py index 6f8884cb..b59721ef 100644 --- a/src/blop/tests/scipy/test_scipy.py +++ b/src/blop/tests/scipy/test_scipy.py @@ -44,7 +44,6 @@ def agent_prep(mock_evaluation_function, mock_acquisition_plan): name="test_experiment", timeout=5, ) - time.sleep(0.1) return agent @@ -62,7 +61,6 @@ def secoundary_agent_prep(mock_evaluation_function, mock_acquisition_plan): acquisition_plan=mock_acquisition_plan, timeout=5, ) - time.sleep(0.1) return agent @@ -156,7 +154,6 @@ 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(0.1) params = agent_prep.suggest(4) print(agent_prep.optimizer._active) assert len(params) > 1 @@ -382,7 +379,6 @@ def test_scipy_large_rescale_factors(mock_evaluation_function, mock_acquisition_ acquisition_plan=mock_acquisition_plan, timeout=5, ) - time.sleep(0.1) suggestions = agent.suggest(1) assert len(suggestions) == 1 From 707c44ce4f764598e6b4732c7f52110bf3d1d8cc Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Mon, 10 Aug 2026 14:50:52 -0400 Subject: [PATCH 115/116] .......... --- src/blop/tests/scipy/test_scipy.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/blop/tests/scipy/test_scipy.py b/src/blop/tests/scipy/test_scipy.py index b59721ef..49e54a9c 100644 --- a/src/blop/tests/scipy/test_scipy.py +++ b/src/blop/tests/scipy/test_scipy.py @@ -1,4 +1,3 @@ -import time import types from unittest.mock import MagicMock From c04afb33a8740c44d99ea42191f72271096999f7 Mon Sep 17 00:00:00 2001 From: Rhys Takahashi <19mt01@gmail.com> Date: Mon, 10 Aug 2026 15:36:14 -0400 Subject: [PATCH 116/116] improved codecov --- src/blop/scipy/inverter.py | 4 +- src/blop/tests/scipy/test_optimizer.py | 134 +++++++++++++++---------- src/blop/tests/scipy/test_scipy.py | 10 +- 3 files changed, 92 insertions(+), 56 deletions(-) diff --git a/src/blop/scipy/inverter.py b/src/blop/scipy/inverter.py index 069093f7..a1a82ed6 100644 --- a/src/blop/scipy/inverter.py +++ b/src/blop/scipy/inverter.py @@ -105,9 +105,9 @@ def mini_worker(): self._t = Thread(target=mini_worker, name="optimizer") self._t.start() - if not self.thread_start.wait(timeout=1): + if not self.thread_start.wait(timeout=0.1): try: - err = self.thread_monitor.exception(timeout=0.1) + err = self.thread_monitor.exception(timeout=0.01) if err: raise err except TimeoutError: diff --git a/src/blop/tests/scipy/test_optimizer.py b/src/blop/tests/scipy/test_optimizer.py index 7c8618f4..95a928f6 100644 --- a/src/blop/tests/scipy/test_optimizer.py +++ b/src/blop/tests/scipy/test_optimizer.py @@ -29,12 +29,7 @@ def optimizer_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 = ScipyCFG( - dofs=[dof1, dof2], - objective=objective, - threads=4, - rescale=[2.0, 3.0], - ) + config = ScipyCFG(dofs=[dof1, dof2], objective=objective, threads=4, rescale=[2.0, 3.0], eps=0.5) inner = Minimize(config) return InteractiveOptimizer(inner, timeout=1) @@ -66,51 +61,6 @@ def test_scipy_optimizer_algorithms(mock_evaluation_function, mock_acquisition_p opt.close() -def test_scipy_optimizer_internal_startup_error(mock_evaluation_function, mock_acquisition_plan): - """Test ScipyOptimizer passes error from scipy internal (optimizer not defined)""" - 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, - max_iter=10, - ) - inner = InnerOptimizer(config) - with pytest.raises(NotImplementedError): - InteractiveOptimizer(inner, timeout=1) - - -def test_scipy_optimizer_internal_runtime_error(mock_evaluation_function, mock_acquisition_plan): - """Test ScipyOptimizer passes error from scipy internal (optimizer not defined)""" - 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, - max_iter=10, - ) - - class ErrFun(InnerOptimizer): - def call(self, cost, callback, kws=None) -> ScipyResult | OptimizeResult: - cost([1, 2]) - raise RuntimeError("This should be caught by main thread") - - inner = ErrFun(config) - opt = InteractiveOptimizer(inner, timeout=1) - with pytest.raises(RuntimeError): - sg = opt.suggest() - opt.ingest([sg[0] | {objective.name: -1}]) - opt.suggest() - - def test_scipy_optimizer_bfgs_specific(mock_evaluation_function, mock_acquisition_plan): """Test ScipyOptimizer explicitly with BFGS.""" movable1 = MovableSignal(name="test_movable1") @@ -254,6 +204,51 @@ def test_get_best_points_scaling(optimizer_prep): # ============================================================================ +def test_scipy_optimizer_internal_startup_error(mock_evaluation_function, mock_acquisition_plan): + """Test ScipyOptimizer passes error from scipy internal (optimizer not defined)""" + 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, + max_iter=10, + ) + inner = InnerOptimizer(config) + with pytest.raises(NotImplementedError): + InteractiveOptimizer(inner, timeout=1) + + +def test_scipy_optimizer_internal_runtime_error(mock_evaluation_function, mock_acquisition_plan): + """Test ScipyOptimizer passes error from scipy internal (optimizer raises operational error)""" + 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, + max_iter=10, + ) + + class ErrFun(InnerOptimizer): + def call(self, cost, callback, kws=None) -> ScipyResult | OptimizeResult: + cost([1, 2]) + raise RuntimeError("This should be caught by main thread") + + inner = ErrFun(config) + opt = InteractiveOptimizer(inner, timeout=1) + with pytest.raises(RuntimeError): + sg = opt.suggest() + opt.ingest([sg[0] | {objective.name: -1}]) + opt.suggest() + + 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 @@ -395,3 +390,40 @@ def test_get_best_points_no_optimization_raises(optimizer_prep): optimizer_prep.get_best_points() optimizer_prep.close() + + +def test_callback_updates(): + """Test ScipyOptimizer recovers optimization reporting from optimizer""" + 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, + max_iter=10, + ) + + first = ScipyResult([1, 1], 1, nit=99) + best = ScipyResult([1, 1], 0, nit=99) + worst = ScipyResult([1, 1], 2, nit=99) + + class CallbackFun(InnerOptimizer): + def call(self, cost, callback, kws=None) -> ScipyResult | OptimizeResult: + callback(first) + cost([1, 1]) + callback(best) + cost([1, 1]) + callback(worst) + cost([1, 1]) + + inner = CallbackFun(config) + opt = InteractiveOptimizer(inner, timeout=1) + assert opt.intermediate is first + opt.ingest([opt.suggest()[0] | {objective.name: -1}]) + opt.ingest([opt.suggest()[0] | {objective.name: -1}]) + opt.ingest([opt.suggest()[0] | {objective.name: -1}]) + assert opt.intermediate is best # make sure it only keeps the best + opt.close() diff --git a/src/blop/tests/scipy/test_scipy.py b/src/blop/tests/scipy/test_scipy.py index 49e54a9c..46b136d3 100644 --- a/src/blop/tests/scipy/test_scipy.py +++ b/src/blop/tests/scipy/test_scipy.py @@ -404,11 +404,13 @@ def test_suggest_after_final_optimization(secoundary_agent_prep): secoundary_agent_prep.optimizer.close() -def test_optimize_after_final_optimization(secoundary_agent_prep): +def test_optimize(secoundary_agent_prep): """Test agent setup of optimize plane""" # Set final optimization result - assert isinstance(secoundary_agent_prep.optimize(0), types.GeneratorType) + iter = secoundary_agent_prep.optimize(0) + assert isinstance(iter, types.GeneratorType) + next(iter) secoundary_agent_prep.optimizer.close() @@ -422,5 +424,7 @@ def test_optimize_with_prev_final(secoundary_agent_prep): status=0, ) - assert isinstance(secoundary_agent_prep.optimize(0), types.GeneratorType) + iter = secoundary_agent_prep.optimize(0) + assert isinstance(iter, types.GeneratorType) + next(iter) secoundary_agent_prep.optimizer.close()