diff --git a/docs/model_selection/spotoptim_intro.qmd b/docs/model_selection/spotoptim_intro.qmd index 16a61d99..5d502b45 100644 --- a/docs/model_selection/spotoptim_intro.qmd +++ b/docs/model_selection/spotoptim_intro.qmd @@ -145,3 +145,41 @@ print(results_adv[["n_estimators", "max_depth", "lags", "mean_absolute_error"]]. ``` This advanced setup illustrates how `spotoptim_search_forecaster` acts as an extremely flexible orchestration bridge, allowing you to efficiently explore your state space while capturing comprehensive performance metadata. + +## Live Tuning Visualization with TensorBoard + +SpotOptim can stream the ongoing search to TensorBoard: pass the knobs +through `kwargs_spotoptim`, or — when tuning via the `MultiTask` pipeline — +set them as plain attributes on the config (the `SpotOptimStrategy` +forwards them automatically; no config-class change is needed): + +```python +# Direct search call +results, optimizer = spotoptim_search_forecaster( + ..., + kwargs_spotoptim={ + "tensorboard_log": True, + "tensorboard_path": "runs/my_tuning_run", + }, +) + +# MultiTask pipeline +cfg = ConfigEntsoe(...) +cfg.tensorboard_log = True +cfg.tensorboard_path = "runs/my_tuning_run" # optional; defaults to runs/ +cfg.tensorboard_clean = False # True wipes old runs/ subdirs first +``` + +Then watch the run live: + +```bash +tensorboard --logdir runs +``` + +TensorBoard shows `y_values/min`, `y_values/last`, `X_best/` and +`success_rate` advancing as evaluations complete, plus one HParams entry +per evaluated configuration. This also works with parallel tuning +(`n_jobs_spotoptim != 1` / `kwargs_spotoptim={"n_jobs": -1}`): +evaluations run in worker processes and are logged parent-side as their +results arrive (requires `spotoptim >= 0.12.8`; older versions log only +the initial design in parallel mode). diff --git a/pyproject.toml b/pyproject.toml index db99f770..767e2bee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ dependencies = [ "scikit-learn>=1.8.0", "shap>=0.49.1", "spotforecast2-safe>=18.0.0,<19", - "spotoptim>=0.12.3", + "spotoptim>=0.12.8", "tqdm>=4.67.2", ] @@ -98,5 +98,6 @@ line-length = 88 target-version = "py313" # [tool.uv.sources] -# Local editable install — uses ../spotforecast2-safe instead of PyPI: +# Local editable installs — use the sibling checkouts instead of PyPI: # spotforecast2-safe = { path = "../spotforecast2-safe", editable = true } +# spotoptim = { path = "../spotoptim", editable = true } diff --git a/src/spotforecast2/model_selection/spotoptim_search.py b/src/spotforecast2/model_selection/spotoptim_search.py index eacf8c0d..fdf34c57 100644 --- a/src/spotforecast2/model_selection/spotoptim_search.py +++ b/src/spotforecast2/model_selection/spotoptim_search.py @@ -15,6 +15,7 @@ import ast import logging +import multiprocessing import warnings from copy import deepcopy from typing import Any, Callable, Dict @@ -287,6 +288,8 @@ def spotoptim_objective( all_lags: list, all_params: list[dict], n_trials: int | None = None, + config_counter: object | None = None, + config_counter_lock: object | None = None, ) -> np.ndarray: """SpotOptim objective function to evaluate hyperparameter sets. @@ -316,6 +319,17 @@ def spotoptim_objective( shown as a prefix on each per-fold progress bar (when ``show_progress`` is ``True``). When ``None``, the label omits the total and reads "config k". Does not affect the optimisation. + config_counter: Optional shared running count of started config + evaluations (a `multiprocessing.Manager().Value("i", 0)` proxy). + With SpotOptim ``n_jobs > 1`` each evaluation runs in a worker + process holding its own (empty) copy of ``all_params``, so + ``len(all_params) + 1`` is stuck at 1 there; the manager-backed + counter is the only cross-process source for the "config k/N" + label. When ``None``, the label falls back to + ``len(all_params) + 1`` (correct in sequential mode). + config_counter_lock: Lock proxy (`multiprocessing.Manager().Lock()`) + guarding ``config_counter`` increments. Required together with + ``config_counter``. Returns: np.ndarray: 1D array of results for the primary metric. @@ -360,12 +374,19 @@ def set_lags(self, lags): pass if cv_name == "TimeSeriesFold": # Coarse-grained progress: prefix the per-fold bar with the running - # count of evaluated candidate configurations. ``all_params`` has one - # entry per already-completed candidate, so this candidate is number - # ``len(all_params) + 1``. Built only when the bar is shown. + # count of evaluated candidate configurations. With a shared + # ``config_counter`` (parallel SpotOptim) the count is incremented + # atomically across worker processes; otherwise ``all_params`` has + # one entry per already-completed candidate, so this candidate is + # number ``len(all_params) + 1``. Built only when the bar is shown. progress_desc = None if show_progress: - config_idx = len(all_params) + 1 + if config_counter is not None and config_counter_lock is not None: + with config_counter_lock: + config_counter.value += 1 + config_idx = config_counter.value + else: + config_idx = len(all_params) + 1 progress_desc = ( f"config {config_idx}/{n_trials}" if n_trials is not None @@ -574,17 +595,36 @@ def spotoptim_search( all_lags: list = [] all_params: list[dict] = [] - # Single trial-level progress bar. Each entry in ``X`` is one trial - # (initial design point or sequential proposal), so we advance by - # ``len(X)`` per objective call. ``n_trials`` (== SpotOptim's - # ``max_iter``) is the total budget — it already includes the - # ``n_initial`` design points, so the total bar length is ``n_trials``. - # The inner backtesting fold loop is forced silent below to avoid the - # per-trial bar spam that used to stack dozens of fast "100% 35/35" - # bars in notebook output. + # With SpotOptim ``n_jobs != 1`` every objective evaluation runs in a + # worker process that receives a dill copy of the optimizer — including + # this closure and its accumulator lists — so parent-side state never + # reaches the workers and worker-side appends never come back. Two + # consequences for progress display: + # * the "config k/N" label cannot be derived from ``len(all_params)`` + # (stuck at 1 in every worker); a manager-backed shared counter is + # the only cross-process source for k. + # * a parent-side trial bar would never be updated (its ``update()`` + # runs on worker copies), so it is created in sequential mode only. + spotoptim_n_jobs = kwargs_spotoptim_.get("n_jobs", 1) + parallel_eval = isinstance(spotoptim_n_jobs, int) and spotoptim_n_jobs != 1 + + config_counter = None + config_counter_lock = None + counter_manager = None + if show_progress and parallel_eval: + counter_manager = multiprocessing.Manager() + config_counter = counter_manager.Value("i", 0) + config_counter_lock = counter_manager.Lock() + + # Single trial-level progress bar (sequential mode only, see above). + # Each entry in ``X`` is one trial (initial design point or sequential + # proposal), so we advance by ``len(X)`` per objective call. + # ``n_trials`` (== SpotOptim's ``max_iter``) is the total budget — it + # already includes the ``n_initial`` design points, so the total bar + # length is ``n_trials``. trial_bar = ( tqdm(total=n_trials, desc="SpotOptim trials", leave=True) - if show_progress + if show_progress and not parallel_eval else None ) @@ -609,6 +649,8 @@ def _objective_wrapper(X: np.ndarray) -> np.ndarray: all_lags=all_lags, all_params=all_params, n_trials=n_trials, + config_counter=config_counter, + config_counter_lock=config_counter_lock, ) if trial_bar is not None: trial_bar.update(len(X)) @@ -633,6 +675,8 @@ def _objective_wrapper(X: np.ndarray) -> np.ndarray: finally: if trial_bar is not None: trial_bar.close() + if counter_manager is not None: + counter_manager.shutdown() # --- Parallel-safe result recovery ------------------------------------ # With ``n_jobs > 1`` SpotOptim evaluates the objective in worker diff --git a/src/spotforecast2/multitask/strategies.py b/src/spotforecast2/multitask/strategies.py index 0931c496..0ffe6232 100644 --- a/src/spotforecast2/multitask/strategies.py +++ b/src/spotforecast2/multitask/strategies.py @@ -216,13 +216,24 @@ def prepare_forecaster( ) -> Any: """Run SpotOptim surrogate search and return a forecaster with best params. + Live tuning visualization: when the config carries the optional + attributes ``tensorboard_log`` (bool), ``tensorboard_path`` (str) + or ``tensorboard_clean`` (bool), they are forwarded to the + SpotOptim constructor and the ongoing search can be watched with + ``tensorboard --logdir `` (defaults to ``runs/``). Set them + as plain attributes, e.g. ``cfg.tensorboard_log = True`` — no + config-class change is required. Live per-eval scalars under + parallel tuning (``n_jobs_spotoptim != 1``) require + ``spotoptim >= 0.12.8``. + Args: task: A `BaseTask` (or compatible) instance that supplies ``cv_ts``, ``config``, ``logger``, ``save_tuning_results``, and ``create_forecaster``. The config must expose ``n_trials_spotoptim``, ``n_initial_spotoptim``, ``random_state``, ``warm_start_lags``, and optionally - ``lags_consider`` and ``n_jobs_spotoptim``. + ``lags_consider``, ``n_jobs_spotoptim``, and the + TensorBoard knobs described above. target: Target column name; forwarded to ``task.create_forecaster`` and ``task.save_tuning_results``. forecaster: An unfitted forecaster instance used as the search @@ -325,6 +336,21 @@ def prepare_forecaster( kwargs_spotoptim["n_jobs"] = n_jobs_spotoptim task.logger.info(" SpotOptim n_jobs: %s", n_jobs_spotoptim) + # Tuning visualization: forward TensorBoard knobs to SpotOptim. Users + # set these as plain attributes on the config (the safe-package config + # classes carry no __slots__, so no spotforecast2_safe change is + # needed). Unset attributes are skipped so SpotOptim's own defaults + # (tensorboard_log=False) stay in charge. Requires spotoptim >= 0.12.8 + # for live per-eval logging under n_jobs != 1. + tb_kwargs = { + key: getattr(task.config, key) + for key in ("tensorboard_log", "tensorboard_path", "tensorboard_clean") + if getattr(task.config, key, None) is not None + } + if tb_kwargs: + kwargs_spotoptim.update(tb_kwargs) + task.logger.info(" SpotOptim TensorBoard: %s", tb_kwargs) + tuning_results, _ = spotoptim_search_forecaster( forecaster=forecaster, y=y_train, diff --git a/src/spotforecast2/plots/plotter.py b/src/spotforecast2/plots/plotter.py index dfb2d4af..52dd486f 100644 --- a/src/spotforecast2/plots/plotter.py +++ b/src/spotforecast2/plots/plotter.py @@ -15,11 +15,25 @@ import pandas as pd import plotly.graph_objects as go +import plotly.io as pio from spotforecast2_safe.data.fetch_data import get_data_home logger = logging.getLogger(__name__) +def _iso_x(index: pd.DatetimeIndex) -> list[str]: + """ISO-8601 strings for a Plotly date axis. + + kaleido v1 serialises figures via orjson, which rejects the raw pandas + Timestamps a tz-aware ``DatetimeIndex`` leaves in trace x arrays (object + dtype) — direct ``fig.write_image()`` then fails with "Type is not JSON + serializable: Timestamp". Plotly date axes accept ISO-8601 strings + natively, so coercing at trace-build time keeps the figure JSON-safe + without changing how it renders. + """ + return [ts.isoformat() for ts in index] + + class PredictionFigure: """ Encapsulates the generation of an interactive Plotly figure for predictions. @@ -185,7 +199,7 @@ def make_plot(self) -> go.Figure: # --- Traces --- self.fig.add_trace( go.Scatter( - x=y_actual_visible.index, + x=_iso_x(y_actual_visible.index), y=y_actual_visible, mode="lines+markers", name=actual_legend, @@ -193,7 +207,7 @@ def make_plot(self) -> go.Figure: ) self.fig.add_trace( go.Scatter( - x=y_pred_visible.index, + x=_iso_x(y_pred_visible.index), y=y_pred_visible, mode="lines+markers", name=pred_legend, @@ -210,7 +224,7 @@ def make_plot(self) -> go.Figure: ) self.fig.add_trace( go.Scatter( - x=y_forecast.index, + x=_iso_x(y_forecast.index), y=y_forecast, mode="lines+markers", name=forecast_legend, @@ -220,7 +234,7 @@ def make_plot(self) -> go.Figure: if test_actual is not None and len(test_actual) > 0: self.fig.add_trace( go.Scatter( - x=test_actual.index, + x=_iso_x(test_actual.index), y=test_actual, mode="lines+markers", name="Actual (test / ground truth)", @@ -231,7 +245,7 @@ def make_plot(self) -> go.Figure: self.fig.add_trace( go.Scatter( - x=y_last_week_visible.index, + x=_iso_x(y_last_week_visible.index), y=y_last_week_visible, mode="lines+markers", line=dict(dash="dash"), @@ -260,18 +274,18 @@ def make_plot(self) -> go.Figure: xaxis=dict(title="Time (UTC)"), yaxis=dict(title="Load"), ) - self.fig.update_xaxes(range=[min_range, max_range]) + self.fig.update_xaxes(range=[min_range.isoformat(), max_range.isoformat()]) self.fig.update_yaxes(range=[y_min - y_margin, y_max + y_margin]) # Vertical marker at end of training self.fig.add_vline( - x=end_training, + x=end_training.isoformat(), line_width=2, line_color="black", line_dash="dash", ) self.fig.add_annotation( - x=end_training, + x=end_training.isoformat(), text="End of Training", showarrow=False, yref="paper", @@ -336,10 +350,9 @@ def save_to_file( if not path.parent.exists(): raise FileNotFoundError(f"Parent directory does not exist: {path.parent}") # kaleido v1 cannot serialise pandas Timestamp axis values directly - # (orjson rejects them). Round-trip through Plotly's own JSON - # encoder, which coerces Timestamps to ISO strings. - import plotly.io as pio - + # (orjson rejects them). make_plot() already returns a JSON-safe + # figure; the round-trip here keeps the method safe for traces + # added to self.fig after make_plot(). pio.from_json(self.fig.to_json()).write_image(str(path), **kwargs) return path diff --git a/tests/test_multitask_strategies.py b/tests/test_multitask_strategies.py index 42dbc2d8..f2e17a52 100644 --- a/tests/test_multitask_strategies.py +++ b/tests/test_multitask_strategies.py @@ -54,3 +54,97 @@ def test_defaults_strategy_returns_forecaster_unchanged(): exog_train=None, ) assert result is sentinel + + +class _FakeForecaster: + """Minimal stand-in accepting set_params/set_lags.""" + + def set_params(self, **kwargs): + self.params = kwargs + + def set_lags(self, lags): + self.lags = lags + + +def _make_fake_task(**config_extra): + """Fake task carrying just what SpotOptimStrategy.prepare_forecaster reads.""" + import logging + import types + + cfg = types.SimpleNamespace( + n_trials_spotoptim=2, + n_initial_spotoptim=1, + random_state=0, + warm_start_lags=False, + n_jobs_spotoptim=None, + **config_extra, + ) + return types.SimpleNamespace( + config=cfg, + logger=logging.getLogger("test-strategies"), + cv_ts=lambda y: None, + create_forecaster=lambda target=None: _FakeForecaster(), + save_tuning_results=lambda **kw: None, + _show_progress=False, + ) + + +def test_spotoptim_strategy_forwards_tensorboard_kwargs(monkeypatch): + """config.tensorboard_* attrs must reach kwargs_spotoptim unchanged. + + ``prepare_forecaster`` imports ``spotoptim_search_forecaster`` from + ``spotforecast2.model_selection`` at call time, so patching the module + attribute intercepts the call. + """ + import pandas as pd + + import spotforecast2.model_selection as ms + + captured = {} + + def fake_search_forecaster(*args, **kwargs): + captured["kwargs_spotoptim"] = kwargs.get("kwargs_spotoptim") + results = pd.DataFrame({"params": [{"alpha": 1.0}], "lags": [[1, 2]]}) + return results, object() + + monkeypatch.setattr(ms, "spotoptim_search_forecaster", fake_search_forecaster) + + task = _make_fake_task( + tensorboard_log=True, + tensorboard_path="runs/test-tb", + ) + tuned = SpotOptimStrategy(search_space={"alpha": (0.1, 1.0)}).prepare_forecaster( + task, "A", _FakeForecaster(), y_train=None + ) + + ks = captured["kwargs_spotoptim"] + assert ks["tensorboard_log"] is True + assert ks["tensorboard_path"] == "runs/test-tb" + # Unset attribute must be skipped so SpotOptim's default stays in charge. + assert "tensorboard_clean" not in ks + assert isinstance(tuned, _FakeForecaster) + assert tuned.params == {"alpha": 1.0} + + +def test_spotoptim_strategy_no_tensorboard_attrs_no_kwargs(monkeypatch): + """Without tensorboard config attrs, no tensorboard keys are forwarded.""" + import pandas as pd + + import spotforecast2.model_selection as ms + + captured = {} + + def fake_search_forecaster(*args, **kwargs): + captured["kwargs_spotoptim"] = kwargs.get("kwargs_spotoptim") + results = pd.DataFrame({"params": [{"alpha": 1.0}], "lags": [[1, 2]]}) + return results, object() + + monkeypatch.setattr(ms, "spotoptim_search_forecaster", fake_search_forecaster) + + task = _make_fake_task() + SpotOptimStrategy(search_space={"alpha": (0.1, 1.0)}).prepare_forecaster( + task, "A", _FakeForecaster(), y_train=None + ) + + # n_jobs_spotoptim=None and no tensorboard attrs -> empty dict -> None. + assert captured["kwargs_spotoptim"] is None diff --git a/tests/test_spotoptim_search.py b/tests/test_spotoptim_search.py index 264ed52f..f12feec3 100644 --- a/tests/test_spotoptim_search.py +++ b/tests/test_spotoptim_search.py @@ -408,6 +408,149 @@ def optuna_search_space(trial): assert isinstance(results_bay, pd.DataFrame) +# ------------------------------------------------------------------ +# "config k/N" progress label +# ------------------------------------------------------------------ + + +class TestConfigProgressLabel: + """The per-config progress label must count across worker processes.""" + + @staticmethod + def _record_descs(monkeypatch): + """Replace _backtesting_forecaster with a stub recording progress_desc.""" + descs = [] + + def fake_backtesting(**kwargs): + descs.append(kwargs["progress_desc"]) + return pd.DataFrame([[0.5]], columns=["mean_absolute_error"]), None + + monkeypatch.setattr( + "spotforecast2.model_selection.spotoptim_search._backtesting_forecaster", + fake_backtesting, + ) + return descs + + def _run_objective(self, y_series, cv, counter=None, lock=None): + from spotforecast2.model_selection.spotoptim_search import spotoptim_objective + + return spotoptim_objective( + X=np.array([[0.1], [0.2]]), + forecaster_search=ForecasterRecursive(estimator=Ridge(), lags=3), + cv_name="TimeSeriesFold", + cv=cv, + metric=[lambda y_true, y_pred: 0.5], + y=y_series, + exog=None, + n_jobs=1, + verbose=False, + show_progress=True, + suppress_warnings=False, + var_name=["alpha"], + var_type=["float"], + bounds=[(0.01, 10.0)], + all_metric_values=[], + all_lags=[], + all_params=[], + n_trials=10, + config_counter=counter, + config_counter_lock=lock, + ) + + def test_label_falls_back_to_local_count(self, y_series, cv, monkeypatch): + """Without a shared counter the label counts completed local configs.""" + descs = self._record_descs(monkeypatch) + self._run_objective(y_series, cv) + assert descs == ["config 1/10", "config 2/10"] + + def test_label_uses_shared_counter(self, y_series, cv, monkeypatch): + """With a shared counter the label continues the cross-process count.""" + import threading + + class FakeCounter: + value = 5 # five configs already started in other workers + + descs = self._record_descs(monkeypatch) + counter = FakeCounter() + self._run_objective(y_series, cv, counter=counter, lock=threading.Lock()) + assert descs == ["config 6/10", "config 7/10"] + assert counter.value == 7 + + def test_parallel_search_labels_increment(self, y_series, forecaster, cv, capfd): + """End to end: SpotOptim n_jobs=2 must not repeat 'config 1/N'. + + Each parallel evaluation runs in a worker process holding a dill + copy of the objective closure, so a label derived from local list + length is stuck at 1 (the bug this guards against). The + manager-backed counter must yield one distinct label per config. + """ + spotoptim_search( + forecaster=forecaster, + y=y_series, + cv=cv, + search_space={"alpha": (0.01, 10.0)}, + metric="mean_absolute_error", + n_trials=4, + n_initial=2, + return_best=False, + verbose=False, + show_progress=True, + kwargs_spotoptim={"n_jobs": 2}, + ) + err = capfd.readouterr().err + for k in range(1, 5): + assert f"config {k}/4" in err, f"missing 'config {k}/4' in:\n{err}" + + +# ------------------------------------------------------------------ +# TensorBoard kwargs pass-through +# ------------------------------------------------------------------ + + +class TestTensorboardPassThrough: + """tensorboard_* kwargs flow through kwargs_spotoptim into SpotOptim.""" + + @staticmethod + def _spotoptim_version(): + import importlib.metadata + + from packaging.version import Version + + return Version(importlib.metadata.version("spotoptim")) + + def test_parallel_run_writes_event_files(self, y_series, forecaster, cv, tmp_path): + """Real tiny parallel run with tensorboard_log=True creates event files. + + Live per-eval logging under n_jobs != 1 requires spotoptim>=0.12.8 + (older versions log only the initial design in parallel mode). + """ + import pytest as _pytest + + if self._spotoptim_version() < type(self._spotoptim_version())("0.12.8"): + _pytest.skip("parallel TensorBoard infill logging needs spotoptim>=0.12.8") + + tb_path = tmp_path / "tb" + spotoptim_search( + forecaster=forecaster, + y=y_series, + cv=cv, + search_space={"alpha": (0.01, 10.0)}, + metric="mean_absolute_error", + n_trials=4, + n_initial=2, + return_best=False, + verbose=False, + show_progress=False, + kwargs_spotoptim={ + "n_jobs": 2, + "tensorboard_log": True, + "tensorboard_path": str(tb_path), + }, + ) + event_files = list(tb_path.rglob("events.out.tfevents*")) + assert event_files, "tensorboard_log=True must produce event files" + + # ------------------------------------------------------------------ # Docstring examples # ------------------------------------------------------------------ diff --git a/uv.lock b/uv.lock index 11715ee6..7dd6068d 100644 --- a/uv.lock +++ b/uv.lock @@ -3684,7 +3684,7 @@ requires-dist = [ { name = "scikit-learn", specifier = ">=1.8.0" }, { name = "shap", specifier = ">=0.49.1" }, { name = "spotforecast2-safe", specifier = ">=18.0.0,<19" }, - { name = "spotoptim", specifier = ">=0.12.3" }, + { name = "spotoptim", specifier = ">=0.12.8" }, { name = "tqdm", specifier = ">=4.67.2" }, ] provides-extras = ["dev"] @@ -3724,7 +3724,7 @@ wheels = [ [[package]] name = "spotoptim" -version = "0.12.6" +version = "0.12.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "black" }, @@ -3747,9 +3747,9 @@ dependencies = [ { name = "ty" }, { name = "xgboost" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5d/a4/9a64a7a81b6e241c2284f805214b857c144fa4b53758945c741d842dfd02/spotoptim-0.12.6.tar.gz", hash = "sha256:f9cc01dc0a0426a6d61dc129d9849cea52f4732130ad06622385a26ddc18654f", size = 313433, upload-time = "2026-06-04T08:31:38.853Z" } +sdist = { url = "https://files.pythonhosted.org/packages/16/5a/a61ed4072c98c69de68854013e49856a241c5875a452b963e4c2d85ea8b3/spotoptim-0.12.8.tar.gz", hash = "sha256:215dc6e9c7718f181f47a0d16b54faea7818dfd26a300f4aaacd83156db28506", size = 316306, upload-time = "2026-06-07T12:46:19.632Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/64/02/13eea2a09f5eb70d9aea5d0eb4df6a8bf801d2ee12a0b38436bec9f61cfb/spotoptim-0.12.6-py3-none-any.whl", hash = "sha256:06c06b1d2d53cf89fa0ed3e625787e4a8553cd7ba720fddb510a3dcfff335231", size = 352991, upload-time = "2026-06-04T08:31:37.545Z" }, + { url = "https://files.pythonhosted.org/packages/83/aa/926c52ee29a5655b9d7f677d231e19fdcf1540558fa26169b0626736e302/spotoptim-0.12.8-py3-none-any.whl", hash = "sha256:9eb2c05232bd2bac16487ecf2f0ae437f8c7e4fca44b706551308be363604a03", size = 355920, upload-time = "2026-06-07T12:46:17.986Z" }, ] [[package]]