diff --git a/CHANGELOG.md b/CHANGELOG.md index 39301e87..5a0b0f66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,25 @@ +## [5.0.0-rc.1](https://github.com/sequential-parameter-optimization/spotforecast2/compare/v4.0.0...v5.0.0-rc.1) (2026-06-06) + +### ⚠ BREAKING CHANGES + +* **multitask:** spotforecast2 no longer reads start_download, +end_download, data_start, data_end, cov_start, cov_end, end_train_ts, +start_train_ts, or the resolved target list from the config; use +task.run_state. config.targets always holds the user input. + +NOTE: CI stays red against PyPI spotforecast2-safe < 18; green requires +the 18.0.0 pin bump follow-up. + +Co-Authored-By: Claude Opus 4.8 (1M context) + +### Features + +* **multitask:** read derived pipeline state from task.run_state ([1f36cec](https://github.com/sequential-parameter-optimization/spotforecast2/commit/1f36cec2335ec9feea19e9addf984017d3c5e814)) + +### Documentation + +* migrate multitask tutorial off make_demo10_config to explicit ConfigMulti ([3399699](https://github.com/sequential-parameter-optimization/spotforecast2/commit/3399699188cb13a9088b24bbd9dfd3e0856a4f6a)) + ## [4.0.0](https://github.com/sequential-parameter-optimization/spotforecast2/compare/v3.9.0...v4.0.0) (2026-06-06) ### ⚠ BREAKING CHANGES diff --git a/pyproject.toml b/pyproject.toml index 75f6b34d..cd18145d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "spotforecast2" -version = "4.0.0" +version = "5.0.0-rc.1" description = "Forecasting with spot" readme = "README.md" license = { text = "AGPL-3.0-or-later" } @@ -32,7 +32,7 @@ dependencies = [ "ruff>=0.15.6", "scikit-learn>=1.8.0", "shap>=0.49.1", - "spotforecast2-safe>=16.3.0,<17", + "spotforecast2-safe>=18.0.0,<19", "spotoptim>=0.12.3", "tqdm>=4.67.2", ] diff --git a/src/spotforecast2/multitask/base.py b/src/spotforecast2/multitask/base.py index 67c923dd..d3127e91 100644 --- a/src/spotforecast2/multitask/base.py +++ b/src/spotforecast2/multitask/base.py @@ -25,10 +25,8 @@ agg_predictor, ) -from spotforecast2.plots.plotter import ( - make_plot, - plot_with_outliers as _plot_with_outliers, -) +from spotforecast2.plots.plotter import make_plot +from spotforecast2.plots.plotter import plot_with_outliers as _plot_with_outliers __all__ = [ "SafeBaseTask", @@ -63,6 +61,7 @@ def plot_with_outliers(self) -> None: df_pipeline=self.df_pipeline, # type: ignore[attr-defined] df_pipeline_original=self.df_pipeline_original, # type: ignore[attr-defined] config=self.config, # type: ignore[attr-defined] + targets=self.run_state.targets, # type: ignore[attr-defined] ) def _show_prediction_figure( @@ -100,7 +99,7 @@ def _show_prediction_figure_agg( agg_pkg, title=( f"Aggregated Forecast: Weighted Combination of " - f"Targets {self.config.targets} ({task_name})" # type: ignore[attr-defined] + f"Targets {self.run_state.targets} ({task_name})" # type: ignore[attr-defined] ), save=False, ) @@ -161,16 +160,16 @@ def _aggregate_and_show( Returns: Aggregated prediction package dict. """ - if len(self.config.targets) == 1: - target = self.config.targets[0] + if len(self.run_state.targets) == 1: + target = self.run_state.targets[0] agg_pkg = results[target] self.agg_results[task_name] = agg_pkg return agg_pkg if self.config.agg_weights is not None: - active_weights = self.config.agg_weights[: len(self.config.targets)] + active_weights = self.config.agg_weights[: len(self.run_state.targets)] else: - n = len(self.config.targets) + n = len(self.run_state.targets) active_weights = [1.0 / n] * n self.logger.info( "No agg_weights configured — using equal weights (1/%d each).", n @@ -178,7 +177,7 @@ def _aggregate_and_show( agg_pkg = self.agg_predictor( results=results, - targets=self.config.targets, + targets=self.run_state.targets, weights=active_weights, ) self.agg_results[task_name] = agg_pkg diff --git a/src/spotforecast2/plots/plotter.py b/src/spotforecast2/plots/plotter.py index 8b386951..dfb2d4af 100644 --- a/src/spotforecast2/plots/plotter.py +++ b/src/spotforecast2/plots/plotter.py @@ -623,7 +623,10 @@ def plot_actual_vs_predicted( def plot_with_outliers( - df_pipeline: pd.DataFrame, df_pipeline_original: pd.DataFrame, config: Any + df_pipeline: pd.DataFrame, + df_pipeline_original: pd.DataFrame, + config: Any, + targets: Optional[list[str]] = None, ) -> None: """Interactive time series plot with outliers and optional bounds. @@ -637,15 +640,26 @@ def plot_with_outliers( The plot title includes the percentage of outliers detected for each target variable. + The resolved list of target column names must be passed explicitly via + *targets*. Callers inside the pipeline (e.g. ``PlottingMixin``) obtain + this list from ``task.run_state.targets`` after ``prepare_data`` has run. + A ``SimpleNamespace``/dict-like *config* that carries its own ``targets`` + attribute is still accepted for backwards-compatible standalone usage — + the explicit *targets* argument takes precedence when provided. + Args: df_pipeline (pd.DataFrame): The processed DataFrame from the pipeline, which may contain NaN values where outliers have been detected and removed. df_pipeline_original (pd.DataFrame): The original DataFrame before outlier removal. - config: Configuration object containing ``targets`` (list of column - names) and optionally ``bounds`` (list of ``(lower, upper)`` - tuples, one per target, in the same order as ``targets``). + config: Configuration object carrying ``bounds`` (optional list of + ``(lower, upper)`` tuples, one per target, in the same order as + *targets*). ``config.targets`` is used as a fallback when the + *targets* argument is ``None`` (legacy path). + targets: Resolved list of target column names. When ``None`` the + function falls back to ``config.targets`` (legacy callers that + pass a ``SimpleNamespace`` with ``targets`` set). Returns: None. Displays one interactive Plotly figure per target variable. @@ -667,17 +681,15 @@ def plot_with_outliers( data.loc[dates[20], "target2"] = 150 # Outlier in target2 df_pipeline = data.copy() df_pipeline.loc[[dates[10], dates[20]], ["target1", "target2"]] = np.nan - # Config with bounds - config = SimpleNamespace( - targets=["target1", "target2"], - bounds=[(-10, 200), (0, 100)], - ) - plot_with_outliers(df_pipeline, data, config) + # Config with bounds; targets passed explicitly + config = SimpleNamespace(bounds=[(-10, 200), (0, 100)]) + plot_with_outliers(df_pipeline, data, config, targets=["target1", "target2"]) ``` """ bounds = getattr(config, "bounds", None) + _targets = targets if targets is not None else config.targets - for i, target in enumerate(config.targets): + for i, target in enumerate(_targets): fig = go.Figure() # Plot Regular Data (lightgrey) diff --git a/tests/test_agg_predictor.py b/tests/test_agg_predictor.py index 8fa38085..5b429613 100644 --- a/tests/test_agg_predictor.py +++ b/tests/test_agg_predictor.py @@ -282,7 +282,7 @@ class TestAggregateAndShowAlwaysAggregates: def _make_task_with_targets(self, targets): task = MultiTask() - task.config.targets = targets + task.run_state.targets = targets return task def test_returns_dict_without_agg_weights(self): @@ -356,13 +356,13 @@ def _inject_pipeline(task, n_rows=300): df_test = df.iloc[-24:].copy().reset_index().rename(columns={"index": "DateTime"}) task.df_pipeline = df task.df_test = df_test - task.config.targets = ["A", "B"] + task.run_state.targets = ["A", "B"] task.config.agg_weights = [0.5, 0.5] task.data_with_exog = None task.exo_pred = None task.exog_feature_names = [] - task.config.end_train_ts = idx[-25] - task.config.start_train_ts = idx[0] + task.run_state.end_train_ts = idx[-25] + task.run_state.start_train_ts = idx[0] return task diff --git a/tests/test_consumer_contract_team4.py b/tests/test_consumer_contract_team4.py new file mode 100644 index 00000000..e111849c --- /dev/null +++ b/tests/test_consumer_contract_team4.py @@ -0,0 +1,219 @@ +# SPDX-FileCopyrightText: 2026 bartzbeielstein +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Consumer-contract test protecting ``bart26k-lecture/14_team_4_submission.qmd``. + +Decider requirement (ADR ``adr-multitask-configmulti-merge``, 2026-06-06): +every refactoring or code change must keep the lecture document valid and +tested — always against its *most recent* version. This test therefore parses +the qmd at test time (no frozen copy) and statically verifies its API surface +against the installed packages: + +1. every ``spotforecast2`` / ``spotforecast2_safe`` import in its python cells + resolves, +2. the ``ConfigEntsoe(...)`` constructor keywords bind against the current + signature, +3. every attribute the document reads or writes on its config object exists, +4. the ``MultiTask(...)`` call and every pipeline method invoked on the + resulting instance exist and accept the keywords used. + +The document is located via the ``TEAM4_QMD`` environment variable (falling +back to the canonical workspace path) and the whole module is skipped when it +is absent, so CI machines without the lecture repo stay green. The test never +*renders* the document — rendering trains models and needs ENTSO-E +credentials; a manual render remains the final pre-release check. +""" + +import ast +import inspect +import os +import re +from pathlib import Path + +import pytest +from spotforecast2_safe.configurator.config_entsoe import ConfigEntsoe + +from spotforecast2.multitask import MultiTask + +DEFAULT_QMD = Path.home() / "workspace" / "bart26k-lecture" / "14_team_4_submission.qmd" +QMD_PATH = Path(os.environ.get("TEAM4_QMD", DEFAULT_QMD)) + +pytestmark = pytest.mark.skipif( + not QMD_PATH.is_file(), + reason=f"protected consumer qmd not available: {QMD_PATH} (set TEAM4_QMD)", +) + +_CELL_RE = re.compile(r"^```\{python[^}]*\}\s*$(.*?)^```\s*$", re.MULTILINE | re.DOTALL) + + +def _python_cells(text: str) -> list[str]: + """Extract the source of all ``{python}`` cells from a qmd document.""" + return [m.group(1) for m in _CELL_RE.finditer(text)] + + +def _parsed_cells() -> list[ast.Module]: + """Parse all python cells; cells with qmd-only syntax are skipped.""" + text = QMD_PATH.read_text(encoding="utf-8") + cells = _python_cells(text) + assert cells, f"no python cells found in {QMD_PATH}" + trees = [] + for cell in cells: + try: + trees.append(ast.parse(cell)) + except SyntaxError: + # e.g. cells containing Quarto shortcodes; not part of the contract + continue + assert trees, "no python cell parsed — qmd structure changed?" + return trees + + +@pytest.fixture(scope="module") +def trees() -> list[ast.Module]: + return _parsed_cells() + + +def _walk(trees: list[ast.Module]): + for tree in trees: + yield from ast.walk(tree) + + +def _config_var_names(trees: list[ast.Module]) -> set[str]: + """Names of variables assigned from ``ConfigEntsoe(...)``.""" + names = set() + for node in _walk(trees): + if ( + isinstance(node, ast.Assign) + and isinstance(node.value, ast.Call) + and isinstance(node.value.func, ast.Name) + and node.value.func.id == "ConfigEntsoe" + ): + names.update(t.id for t in node.targets if isinstance(t, ast.Name)) + return names + + +def _multitask_var_names(trees: list[ast.Module]) -> set[str]: + """Names of variables assigned from ``MultiTask(...)``.""" + names = set() + for node in _walk(trees): + if ( + isinstance(node, ast.Assign) + and isinstance(node.value, ast.Call) + and isinstance(node.value.func, ast.Name) + and node.value.func.id == "MultiTask" + ): + names.update(t.id for t in node.targets if isinstance(t, ast.Name)) + return names + + +def _calls_of(trees: list[ast.Module], func_name: str): + for node in _walk(trees): + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == func_name + ): + yield node + + +def _bind(callable_obj, call: ast.Call) -> None: + """Bind a call's argument shape against a signature; fail on mismatch.""" + sig = inspect.signature(callable_obj) + args = [None] * len(call.args) + kwargs = {kw.arg: None for kw in call.keywords if kw.arg is not None} + sig.bind_partial(*args, **kwargs) # raises TypeError on renamed/removed params + + +def test_spotforecast_imports_resolve(trees): + """Every spotforecast2/spotforecast2_safe import in the qmd resolves.""" + import importlib + + checked = 0 + for node in _walk(trees): + if isinstance(node, ast.ImportFrom) and node.module: + if not node.module.startswith("spotforecast2"): + continue + mod = importlib.import_module(node.module) + for alias in node.names: + assert hasattr( + mod, alias.name + ), f"{node.module} no longer exports {alias.name!r}" + checked += 1 + elif isinstance(node, ast.Import): + for alias in node.names: + if alias.name.startswith("spotforecast2"): + importlib.import_module(alias.name) + checked += 1 + assert checked > 0, "qmd no longer imports from spotforecast2 packages?" + + +def test_config_entsoe_constructor_kwargs_bind(trees): + """The ConfigEntsoe(...) keywords used in the qmd bind to the signature.""" + calls = list(_calls_of(trees, "ConfigEntsoe")) + assert calls, "qmd no longer constructs ConfigEntsoe directly" + for call in calls: + _bind(ConfigEntsoe, call) + + +def test_config_attribute_surface(trees): + """Every attribute the qmd reads/writes on its config object exists. + + This is the core protection for the RunState migration: if the document + ever reads a field that a refactor removed from the config (e.g. one of + the derived window fields), this test fails before a release ships. + """ + config_vars = _config_var_names(trees) + assert config_vars, "qmd no longer assigns a ConfigEntsoe instance" + cfg = ConfigEntsoe() + missing = set() + for node in _walk(trees): + if ( + isinstance(node, ast.Attribute) + and isinstance(node.value, ast.Name) + and node.value.id in config_vars + ): + if not hasattr(cfg, node.attr): + missing.add(node.attr) + assert ( + not missing + ), f"qmd uses config attributes missing on ConfigEntsoe: {sorted(missing)}" + + +def test_multitask_call_and_pipeline_methods(trees): + """MultiTask(...) binds, and every method called on the instance exists.""" + mt_calls = list(_calls_of(trees, "MultiTask")) + assert mt_calls, "qmd no longer constructs MultiTask" + for call in mt_calls: + _bind(MultiTask, call) + # keywords that are not MultiTask params travel via **overrides into + # config.set_params(); they must be valid config fields. + mt_params = set(inspect.signature(MultiTask).parameters) + for kw in call.keywords: + if kw.arg is not None and kw.arg not in mt_params: + assert ( + kw.arg in ConfigEntsoe._PARAM_NAMES + ), f"MultiTask override {kw.arg!r} is not a ConfigEntsoe field" + + mt_vars = _multitask_var_names(trees) + assert mt_vars, "qmd no longer assigns a MultiTask instance" + invoked = [] + for node in _walk(trees): + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and isinstance(node.func.value, ast.Name) + and node.func.value.id in mt_vars + ): + invoked.append(node) + assert invoked, "qmd no longer calls any method on its MultiTask instance" + for call in invoked: + name = call.func.attr + method = getattr(MultiTask, name, None) + assert method is not None, f"MultiTask lost method {name!r} used by the qmd" + _bind( + method, + ast.Call( # account for the bound `self` slot + func=call.func, + args=[ast.Constant(value=None)] + list(call.args), + keywords=call.keywords, + ), + ) diff --git a/tests/test_cv_block_size.py b/tests/test_cv_block_size.py index 7c0c74dd..4019b249 100644 --- a/tests/test_cv_block_size.py +++ b/tests/test_cv_block_size.py @@ -19,7 +19,7 @@ be present on the config class — the override is exercised via the same ``getattr`` fallback that ``cv_ts`` uses, by assigning the attribute on the constructed config object (mirroring how the existing cv_ts tests set -``config.end_train_ts`` and ``config.train_size``). +``run_state.end_train_ts`` and ``config.train_size``). """ import math @@ -41,7 +41,7 @@ def _make_task(tmp_path: Path, *, val_days: int = 7, **kwargs) -> LazyTask: - """Return a LazyTask with ``end_train_ts`` pinned to ``_END_TRAIN``. + """Return a LazyTask with ``run_state.end_train_ts`` pinned to ``_END_TRAIN``. Mirrors the helper in ``test_cv_ts_sklearn.py``: ``delta_val`` is computed explicitly from ``val_days * number_folds`` rather than derived inside @@ -51,7 +51,7 @@ def _make_task(tmp_path: Path, *, val_days: int = 7, **kwargs) -> LazyTask: overrides = dict(kwargs, delta_val=pd.Timedelta(days=val_days * number_folds)) overrides.setdefault("data_frame_name", "test_data") t = LazyTask(cache_home=tmp_path, **overrides) - t.config.end_train_ts = _END_TRAIN + t.run_state.end_train_ts = _END_TRAIN return t @@ -65,7 +65,7 @@ def _skl_cv( task: LazyTask, y_train: pd.Series, test_size: int ) -> SklearnTimeSeriesSplit: """Build the sklearn TimeSeriesSplit equivalent for *task* with *test_size*.""" - end_cv = task.config.end_train_ts - task.config.delta_val + end_cv = task.run_state.end_train_ts - task.config.delta_val n_train_cv = len(y_train.loc[:end_cv]) max_train_size = n_train_cv if task.config.train_size is not None else None return SklearnTimeSeriesSplit( diff --git a/tests/test_cv_ts.py b/tests/test_cv_ts.py index 1ce6ecc9..692c4492 100644 --- a/tests/test_cv_ts.py +++ b/tests/test_cv_ts.py @@ -40,7 +40,7 @@ def _make_task(tmp_path: Path, *, val_days: int = 7, **kwargs) -> LazyTask: - """Return a LazyTask whose ``config.end_train_ts`` is set without loading data. + """Return a LazyTask whose ``run_state.end_train_ts`` is set without loading data. ``val_days`` is pinned to 7 so that ``delta_val = 7 * number_folds`` days, keeping the test series length requirements manageable. After the @@ -51,7 +51,7 @@ def _make_task(tmp_path: Path, *, val_days: int = 7, **kwargs) -> LazyTask: overrides = dict(kwargs, delta_val=pd.Timedelta(days=val_days * number_folds)) overrides.setdefault("data_frame_name", "test_data") t = LazyTask(cache_home=tmp_path, **overrides) - t.config.end_train_ts = _END_TRAIN + t.run_state.end_train_ts = _END_TRAIN return t @@ -110,7 +110,7 @@ def test_allow_incomplete_fold_is_true(self, task, y_train): class TestCvTsInitialTrainSize: def test_initial_train_size_matches_slice(self, task, y_train): - end_cv = task.config.end_train_ts - task.config.delta_val + end_cv = task.run_state.end_train_ts - task.config.delta_val expected = len(y_train.loc[:end_cv]) cv = task.cv_ts(y_train) assert cv.initial_train_size == expected diff --git a/tests/test_cv_ts_sklearn.py b/tests/test_cv_ts_sklearn.py index 73a46e9b..7b29757d 100644 --- a/tests/test_cv_ts_sklearn.py +++ b/tests/test_cv_ts_sklearn.py @@ -34,7 +34,7 @@ def _make_task(tmp_path, *, val_days: int = 7, **kwargs) -> LazyTask: - """Return a LazyTask with ``end_train_ts`` set to ``_END_TRAIN``. + """Return a LazyTask with ``run_state.end_train_ts`` set to ``_END_TRAIN``. After the config-object refactor ``delta_val`` is no longer derived from ``val_days * number_folds`` inside ``BaseTask``; the helper computes it @@ -44,7 +44,7 @@ def _make_task(tmp_path, *, val_days: int = 7, **kwargs) -> LazyTask: overrides = dict(kwargs, delta_val=pd.Timedelta(days=val_days * number_folds)) overrides.setdefault("data_frame_name", "test_data") t = LazyTask(cache_home=tmp_path, **overrides) - t.config.end_train_ts = _END_TRAIN + t.run_state.end_train_ts = _END_TRAIN return t @@ -56,7 +56,7 @@ def _make_y_train(end: pd.Timestamp = _END_TRAIN, n: int = _N) -> pd.Series: def _skl_cv(task: LazyTask, y_train: pd.Series) -> SklearnTimeSeriesSplit: """Build the equivalent sklearn TimeSeriesSplit for *task*.""" - end_cv = task.config.end_train_ts - task.config.delta_val + end_cv = task.run_state.end_train_ts - task.config.delta_val n_train_cv = len(y_train.loc[:end_cv]) max_train_size = n_train_cv if task.config.train_size is not None else None return SklearnTimeSeriesSplit( @@ -230,7 +230,7 @@ def test_fixed_train_size_flag_false_when_train_size_none(self, tmp_path, y_trai def test_max_train_size_equals_n_train_cv(self, task, y_train): """When ``train_size`` is set, ``max_train_size`` equals ``n_train_cv``.""" - end_cv = task.config.end_train_ts - task.config.delta_val + end_cv = task.run_state.end_train_ts - task.config.delta_val n_train_cv = len(y_train.loc[:end_cv]) # Each fold's training set should be at most n_train_cv observations. for train_idx, _ in _skl_cv(task, y_train).split(y_train): @@ -248,8 +248,8 @@ def test_more_folds_same_initial_train_size_fixed_window(self, tmp_path, y_train task_5 = _make_task(tmp_path, number_folds=5) task_10 = _make_task(tmp_path, number_folds=10) # Both use the same n_train_cv derived from end_cv - end_cv_5 = task_5.config.end_train_ts - task_5.config.delta_val - end_cv_10 = task_10.config.end_train_ts - task_10.config.delta_val + end_cv_5 = task_5.run_state.end_train_ts - task_5.config.delta_val + end_cv_10 = task_10.run_state.end_train_ts - task_10.config.delta_val n5 = len(y_train.loc[:end_cv_5]) n10 = len(y_train.loc[:end_cv_10]) # With fixed window: initial_train_size == max_train_size == n_train_cv @@ -260,8 +260,8 @@ def test_more_folds_consumes_more_validation_data(self, tmp_path, y_train): """Larger number_folds enlarges delta_val, shrinking n_train_cv.""" task_5 = _make_task(tmp_path, number_folds=5) task_15 = _make_task(tmp_path, number_folds=15) - end_cv_5 = task_5.config.end_train_ts - task_5.config.delta_val - end_cv_15 = task_15.config.end_train_ts - task_15.config.delta_val + end_cv_5 = task_5.run_state.end_train_ts - task_5.config.delta_val + end_cv_15 = task_15.run_state.end_train_ts - task_15.config.delta_val n5 = len(y_train.loc[:end_cv_5]) n15 = len(y_train.loc[:end_cv_15]) assert ( diff --git a/tests/test_docs_task_consistency.py b/tests/test_docs_task_consistency.py index a10584cd..17787934 100644 --- a/tests/test_docs_task_consistency.py +++ b/tests/test_docs_task_consistency.py @@ -352,7 +352,7 @@ def test_run_strategy_saves_models_when_auto_save_enabled(self): from spotforecast2.multitask.base import BaseTask task = BaseTask(predict_size=24, auto_save_models=True) - task.config.targets = ["t1"] + task.run_state.targets = ["t1"] strategy = MagicMock(name="strategy") strategy.prepare_forecaster.return_value = MagicMock(name="prepared") @@ -382,7 +382,7 @@ def test_run_strategy_skips_save_models_when_disabled(self): from spotforecast2.multitask.base import BaseTask task = BaseTask(predict_size=24, auto_save_models=False) - task.config.targets = ["t1"] + task.run_state.targets = ["t1"] strategy = MagicMock(name="strategy") strategy.prepare_forecaster.return_value = MagicMock(name="prepared") diff --git a/tests/test_exog_providers_pipeline.py b/tests/test_exog_providers_pipeline.py index c87c9ca5..f55521c5 100644 --- a/tests/test_exog_providers_pipeline.py +++ b/tests/test_exog_providers_pipeline.py @@ -39,10 +39,10 @@ def _make_task(**config_kwargs) -> MultiTask: mt = MultiTask(cfg) idx = pd.date_range("2024-01-01", periods=96, freq="h", tz="UTC") mt.df_pipeline = pd.DataFrame({"target_0": np.arange(96.0)}, index=idx) - mt.config.targets = ["target_0"] - mt.config.data_start = idx[0] - mt.config.data_end = idx[-1] - mt.config.cov_end = idx[-1] + mt.run_state.targets = ["target_0"] + mt.run_state.data_start = idx[0] + mt.run_state.data_end = idx[-1] + mt.run_state.cov_end = idx[-1] return mt diff --git a/tests/test_exogenous_failure_scenarios.py b/tests/test_exogenous_failure_scenarios.py index 8b3584c3..1a9545c1 100644 --- a/tests/test_exogenous_failure_scenarios.py +++ b/tests/test_exogenous_failure_scenarios.py @@ -63,9 +63,9 @@ def _make_task_ready( mt = MultiTask(cfg) idx = pd.date_range("2024-01-01", periods=48, freq="h", tz="UTC") mt.df_pipeline = pd.DataFrame({"target_0": range(48)}, index=idx) - mt.config.targets = ["target_0"] - mt.config.data_start = idx[0] - mt.config.cov_end = idx[-1] + mt.run_state.targets = ["target_0"] + mt.run_state.data_start = idx[0] + mt.run_state.cov_end = idx[-1] return mt diff --git a/tests/test_exogenous_schema_mismatch_integration.py b/tests/test_exogenous_schema_mismatch_integration.py index 4ecddced..70bbbec9 100644 --- a/tests/test_exogenous_schema_mismatch_integration.py +++ b/tests/test_exogenous_schema_mismatch_integration.py @@ -47,9 +47,9 @@ def _make_task(on_weather_failure: str = "raise") -> MultiTask: mt = MultiTask(cfg) idx = pd.date_range("2024-01-01", periods=48, freq="h", tz="UTC") mt.df_pipeline = pd.DataFrame({"target_0": range(48)}, index=idx) - mt.config.targets = ["target_0"] - mt.config.data_start = idx[0] - mt.config.cov_end = idx[-1] + mt.run_state.targets = ["target_0"] + mt.run_state.data_start = idx[0] + mt.run_state.cov_end = idx[-1] return mt diff --git a/tests/test_holiday_adjacency_features.py b/tests/test_holiday_adjacency_features.py index d4aece13..de223e4a 100644 --- a/tests/test_holiday_adjacency_features.py +++ b/tests/test_holiday_adjacency_features.py @@ -39,9 +39,9 @@ def _make_task(include_holiday_adjacency_features: bool = False) -> MultiTask: mt = MultiTask(cfg) idx = pd.date_range("2024-01-01", periods=72, freq="h", tz="UTC") mt.df_pipeline = pd.DataFrame({"target_0": np.arange(72.0)}, index=idx) - mt.config.targets = ["target_0"] - mt.config.data_start = idx[0] - mt.config.cov_end = idx[-1] + mt.run_state.targets = ["target_0"] + mt.run_state.data_start = idx[0] + mt.run_state.cov_end = idx[-1] return mt diff --git a/tests/test_multitask.py b/tests/test_multitask.py index 5c9ff73c..2e66d4fd 100644 --- a/tests/test_multitask.py +++ b/tests/test_multitask.py @@ -398,7 +398,12 @@ def test_plot_with_outliers_invoked_after_detect(self, demo_df): task.detect_outliers() with _patch("spotforecast2.multitask.base._plot_with_outliers") as mock_plt: task.plot_with_outliers() - mock_plt.assert_called_once() + mock_plt.assert_called_once_with( + df_pipeline=task.df_pipeline, + df_pipeline_original=task.df_pipeline_original, + config=task.config, + targets=task.run_state.targets, + ) def test_run_before_prepare_multitask(self): with pytest.raises(RuntimeError, match="Pipeline data not prepared"): @@ -518,7 +523,7 @@ class TestMultiTaskRunDispatcher: def test_invalid_task_raises(self): mt = MultiTask(task="invalid_task") mt.df_pipeline = pd.DataFrame() - mt.config.end_train_ts = pd.Timestamp("2024-01-01", tz="UTC") + mt.run_state.end_train_ts = pd.Timestamp("2024-01-01", tz="UTC") with pytest.raises(ValueError, match="Unknown task"): mt.run(show=False) @@ -608,16 +613,16 @@ def test_lazy_task_with_explicit_data(self, demo_df): def test_prepare_data_sets_targets(self, demo_df): task = LazyTask(data_frame_name="demo10") task.prepare_data(demo_data=demo_df) - assert task.config.targets is not None - assert len(task.config.targets) > 0 + assert task.run_state.targets is not None + assert len(task.run_state.targets) > 0 def test_prepare_data_sets_date_ranges(self, demo_df): task = LazyTask(data_frame_name="demo10") task.prepare_data(demo_data=demo_df) - assert task.config.data_start is not None - assert task.config.data_end is not None - assert task.config.cov_start is not None - assert task.config.cov_end is not None + assert task.run_state.data_start is not None + assert task.run_state.data_end is not None + assert task.run_state.cov_start is not None + assert task.run_state.cov_end is not None def test_prepare_data_returns_self(self, demo_df): task = LazyTask() @@ -811,9 +816,7 @@ def test_cache_home_none_resolves_to_default(self, tmp_path, monkeypatch): # _attach_file_handler resolves via get_cache_home(config.cache_home); # when cache_home=None the resolved path must equal the package default. file_handlers = [ - h - for h in mt.logger.handlers - if isinstance(h, logging.FileHandler) + h for h in mt.logger.handlers if isinstance(h, logging.FileHandler) ] # Loggers are singletons: earlier tests may have left handlers for # other cache locations — assert the default-resolved one exists. diff --git a/tests/test_multitask_dataframe.py b/tests/test_multitask_dataframe.py index 8e8d8125..3c81ece4 100644 --- a/tests/test_multitask_dataframe.py +++ b/tests/test_multitask_dataframe.py @@ -109,7 +109,7 @@ def test_pipeline_not_none_after_prepare(self, mt_with_df): def test_targets_populated_after_prepare(self, mt_with_df): mt_with_df.prepare_data() - assert len(mt_with_df.config.targets) > 0 + assert len(mt_with_df.run_state.targets) > 0 def test_pipeline_shape_is_2d(self, mt_with_df): mt_with_df.prepare_data() diff --git a/tests/test_multitask_defaults.py b/tests/test_multitask_defaults.py index e6e4bf3e..f011f047 100644 --- a/tests/test_multitask_defaults.py +++ b/tests/test_multitask_defaults.py @@ -60,7 +60,7 @@ def test_defaults_does_not_read_tuning_cache(): once per target without ever calling ``load_tuning_results`` (that path belongs to ``LazyStrategy`` with ``use_tuned_params=True``).""" task = DefaultsTask(predict_size=24, auto_save_models=False) - task.config.targets = ["t1"] + task.run_state.targets = ["t1"] strategy = DefaultsStrategy() with ( @@ -86,7 +86,7 @@ def test_defaults_does_not_write_tuning_results(): """End-to-end through ``_run_strategy``: no JSON tuning-result file is written for the defaults task (that path belongs to Optuna/SpotOptim).""" task = DefaultsTask(predict_size=24, auto_save_models=False) - task.config.targets = ["t1"] + task.run_state.targets = ["t1"] strategy = DefaultsStrategy() with ( @@ -112,7 +112,7 @@ def test_defaults_saves_models_under_defaults_key_when_auto_save(): """``auto_save_models=True`` causes ``_run_strategy`` to call ``save_models(task_name="defaults")`` so ``PredictTask`` can find them.""" task = DefaultsTask(predict_size=24, auto_save_models=True) - task.config.targets = ["t1"] + task.run_state.targets = ["t1"] strategy = DefaultsStrategy() with ( diff --git a/tests/test_multitask_single_target.py b/tests/test_multitask_single_target.py index e0eaf7d5..00367990 100644 --- a/tests/test_multitask_single_target.py +++ b/tests/test_multitask_single_target.py @@ -28,7 +28,7 @@ def single_target_lazy() -> LazyTask: not to integration-test the full pipeline. """ task = LazyTask(predict_size=24, auto_save_models=False) - task.config.targets = ["only_target"] + task.run_state.targets = ["only_target"] return task @@ -74,7 +74,7 @@ def test_multi_target_aggregation_still_runs(monkeypatch): """Sanity: multi-target configurations still invoke the aggregator (regression guard for Step 6's single-target short-circuit).""" task = LazyTask(predict_size=24, auto_save_models=False) - task.config.targets = ["a", "b"] + task.run_state.targets = ["a", "b"] task.config.agg_weights = [0.5, 0.5] seen = {} diff --git a/tests/test_multitask_weather_failure.py b/tests/test_multitask_weather_failure.py index e977f5cb..fd5c9727 100644 --- a/tests/test_multitask_weather_failure.py +++ b/tests/test_multitask_weather_failure.py @@ -44,9 +44,9 @@ def _make_task_ready(on_weather_failure: str = "raise") -> MultiTask: mt = MultiTask(cfg) idx = pd.date_range("2024-01-01", periods=48, freq="h", tz="UTC") mt.df_pipeline = pd.DataFrame({"target_0": range(48)}, index=idx) - mt.config.targets = ["target_0"] - mt.config.data_start = idx[0] - mt.config.cov_end = idx[-1] + mt.run_state.targets = ["target_0"] + mt.run_state.data_start = idx[0] + mt.run_state.cov_end = idx[-1] return mt @@ -60,13 +60,21 @@ def _mock_sibling_features(stack: ExitStack, index: pd.DatetimeIndex) -> None: """ empty = pd.DataFrame(index=index) stack.enter_context( - patch("spotforecast2_safe.multitask.base.get_calendar_features", return_value=empty) + patch( + "spotforecast2_safe.multitask.base.get_calendar_features", + return_value=empty, + ) ) stack.enter_context( - patch("spotforecast2_safe.multitask.base.get_day_night_features", return_value=empty) + patch( + "spotforecast2_safe.multitask.base.get_day_night_features", + return_value=empty, + ) ) stack.enter_context( - patch("spotforecast2_safe.multitask.base.get_holiday_features", return_value=empty) + patch( + "spotforecast2_safe.multitask.base.get_holiday_features", return_value=empty + ) ) # Downstream transforms — return shapes the caller assigns through. stack.enter_context( diff --git a/tests/test_plot_with_outliers_bounds.py b/tests/test_plot_with_outliers_bounds.py index 8d358dbf..d3dd6ca7 100644 --- a/tests/test_plot_with_outliers_bounds.py +++ b/tests/test_plot_with_outliers_bounds.py @@ -41,6 +41,7 @@ def _captured_figures( df_pipeline: pd.DataFrame, df_original: pd.DataFrame, config, + targets: list[str] | None = None, ) -> list[go.Figure]: """Run plot_with_outliers and collect figures without displaying them.""" figures = [] @@ -49,7 +50,7 @@ def fake_show(self, *args, **kwargs): figures.append(self) with patch.object(go.Figure, "show", fake_show): - plot_with_outliers(df_pipeline, df_original, config) + plot_with_outliers(df_pipeline, df_original, config, targets=targets) return figures @@ -239,3 +240,41 @@ def test_no_outliers_no_outlier_trace(self): figs = _captured_figures(data, data, config) names = [t.name for t in figs[0].data] assert "Outliers" not in names + + +# --------------------------------------------------------------------------- +# Tests: explicit targets argument (primary pipeline path) +# --------------------------------------------------------------------------- + + +class TestPlotWithOutliersExplicitTargets: + """The explicit ``targets=`` argument is the primary (run_state) path. + + ``PlottingMixin`` passes ``task.run_state.targets`` explicitly; the + ``config.targets`` fallback exists only for legacy standalone callers. + """ + + def test_explicit_targets_used_when_config_lacks_targets(self): + """A config without a targets attribute must work with explicit targets.""" + df_pipe, df_orig, _ = _make_data(targets=["load"]) + config = SimpleNamespace(bounds=None) # no targets attribute at all + figs = _captured_figures(df_pipe, df_orig, config, targets=["load"]) + assert len(figs) == 1 + assert "load" in figs[0].layout.title.text + + def test_explicit_targets_take_precedence_over_config(self): + """When both are present, the explicit argument wins.""" + df_pipe, df_orig, _ = _make_data(targets=["A", "B"]) + config = SimpleNamespace(targets=["A", "B"], bounds=None) + figs = _captured_figures(df_pipe, df_orig, config, targets=["A"]) + assert len(figs) == 1 # only the explicit list is plotted + assert "A" in figs[0].layout.title.text + + def test_explicit_targets_with_bounds(self): + """Bounds are applied positionally against the explicit target list.""" + df_pipe, df_orig, _ = _make_data(targets=["load"]) + config = SimpleNamespace(bounds=[(0.0, 100.0)]) + figs = _captured_figures(df_pipe, df_orig, config, targets=["load"]) + assert len(figs[0].layout.shapes) == 2 + y_vals = {s.y0 for s in figs[0].layout.shapes} + assert 0.0 in y_vals and 100.0 in y_vals diff --git a/tests/test_poly_features_degree.py b/tests/test_poly_features_degree.py index 28c599d9..7a1263fc 100644 --- a/tests/test_poly_features_degree.py +++ b/tests/test_poly_features_degree.py @@ -36,9 +36,9 @@ def _make_task(poly_features_degree: int, max_poly_features: int) -> MultiTask: mt = MultiTask(cfg) idx = pd.date_range("2024-01-01", periods=96, freq="h", tz="UTC") mt.df_pipeline = pd.DataFrame({"target_0": np.arange(96.0)}, index=idx) - mt.config.targets = ["target_0"] - mt.config.data_start = idx[0] - mt.config.cov_end = idx[-1] + mt.run_state.targets = ["target_0"] + mt.run_state.data_start = idx[0] + mt.run_state.cov_end = idx[-1] return mt diff --git a/tests/test_predict_task.py b/tests/test_predict_task.py index 1429bbb7..40f8c541 100644 --- a/tests/test_predict_task.py +++ b/tests/test_predict_task.py @@ -178,7 +178,7 @@ def test_run_raises_for_missing_target(self, tmp_path, single_forecaster, demo_d # Only save for target_0, but the demo10 dataset has multiple targets task.save_models(task_name="lazy", forecasters=single_forecaster) - targets = task.config.targets + targets = task.run_state.targets if len(targets) > 1: # At least one target should be missing with pytest.raises(RuntimeError, match="No saved model found for target"): @@ -224,7 +224,7 @@ def test_full_round_trip(self, tmp_path, demo_df): assert result is not None assert "predict" in pred.results - for target in pred.config.targets: + for target in pred.run_state.targets: pkg = pred.results["predict"][target] assert "future_pred" in pkg assert len(pkg["future_pred"]) == 24 @@ -258,7 +258,7 @@ def test_results_stored_under_predict_key(self, tmp_path, demo_df): assert "predict" in pred.results assert isinstance(pred.results["predict"], dict) - assert len(pred.results["predict"]) == len(pred.config.targets) + assert len(pred.results["predict"]) == len(pred.run_state.targets) def test_agg_results_stored(self, tmp_path, demo_df): """Verify aggregated results are stored.""" @@ -411,7 +411,7 @@ def test_expired_models_rejected(self, tmp_path, demo_df): pred.build_exogenous_features() # Save old-timestamped model files for all targets - for target in pred.config.targets: + for target in pred.run_state.targets: m = LinearRegression() m.fit(np.arange(10).reshape(-1, 1), np.arange(10)) old_ts = "20240101_000000" @@ -577,7 +577,7 @@ def test_package_keys(self, tmp_path, demo_df): pred.build_exogenous_features() pred.run(show=False) - for target in pred.config.targets: + for target in pred.run_state.targets: pkg = pred.results["predict"][target] assert "train_actual" in pkg assert "train_pred" in pkg @@ -611,7 +611,7 @@ def test_future_pred_length(self, tmp_path, demo_df): pred.build_exogenous_features() pred.run(show=False) - for target in pred.config.targets: + for target in pred.run_state.targets: assert len(pred.results["predict"][target]["future_pred"]) == 24 diff --git a/tests/test_spotoptim_parallel.py b/tests/test_spotoptim_parallel.py index 6142c2d4..21537069 100644 --- a/tests/test_spotoptim_parallel.py +++ b/tests/test_spotoptim_parallel.py @@ -23,9 +23,9 @@ import pandas as pd import pytest from sklearn.linear_model import Ridge - from spotforecast2_safe.forecaster.recursive import ForecasterRecursive from spotforecast2_safe.splitter import TimeSeriesFold + from spotforecast2.model_selection import spotoptim_search_forecaster from spotforecast2.multitask.strategies import SpotOptimStrategy diff --git a/tests/test_tasks_entsoe_predict.py b/tests/test_tasks_entsoe_predict.py index 22f3b42c..fed6268a 100644 --- a/tests/test_tasks_entsoe_predict.py +++ b/tests/test_tasks_entsoe_predict.py @@ -21,9 +21,7 @@ def _predict_call(model: str): Returns ``(positional_args, keyword_args)`` of the single ``_run_entsoe_pipeline`` call. """ - with patch( - "spotforecast2.tasks.task_entsoe._run_entsoe_pipeline" - ) as mock_pipeline: + with patch("spotforecast2.tasks.task_entsoe._run_entsoe_pipeline") as mock_pipeline: with patch("sys.argv", ["spotforecast2-entsoe", "predict", model]): main() assert mock_pipeline.call_count == 1 @@ -53,9 +51,7 @@ def test_predict_xgb_uses_xgb_project_and_factory(): def test_predict_default_model_is_lgbm(): - with patch( - "spotforecast2.tasks.task_entsoe._run_entsoe_pipeline" - ) as mock_pipeline: + with patch("spotforecast2.tasks.task_entsoe._run_entsoe_pipeline") as mock_pipeline: with patch("sys.argv", ["spotforecast2-entsoe", "predict"]): main() args = mock_pipeline.call_args.args diff --git a/tests/test_tasks_entsoe_train.py b/tests/test_tasks_entsoe_train.py index 8dd6a9ad..a5b1640b 100644 --- a/tests/test_tasks_entsoe_train.py +++ b/tests/test_tasks_entsoe_train.py @@ -29,9 +29,7 @@ def _train_call(model: str): ``_run_entsoe_pipeline`` call. ``--force`` bypasses the cadence gate so the dispatch test does not depend on the state of the user's model cache. """ - with patch( - "spotforecast2.tasks.task_entsoe._run_entsoe_pipeline" - ) as mock_pipeline: + with patch("spotforecast2.tasks.task_entsoe._run_entsoe_pipeline") as mock_pipeline: with patch("sys.argv", ["spotforecast2-entsoe", "train", model, "--force"]): main() assert mock_pipeline.call_count == 1 @@ -63,9 +61,7 @@ def test_train_xgb_dispatches_to_pipeline_with_xgb_factory(): def test_train_default_model_is_lgbm(): """Omitting the positional ``model`` arg falls back to LightGBM.""" - with patch( - "spotforecast2.tasks.task_entsoe._run_entsoe_pipeline" - ) as mock_pipeline: + with patch("spotforecast2.tasks.task_entsoe._run_entsoe_pipeline") as mock_pipeline: with patch("sys.argv", ["spotforecast2-entsoe", "train", "--force"]): main() args = mock_pipeline.call_args.args @@ -75,9 +71,7 @@ def test_train_default_model_is_lgbm(): def test_train_show_flag_forwards_to_pipeline(): - with patch( - "spotforecast2.tasks.task_entsoe._run_entsoe_pipeline" - ) as mock_pipeline: + with patch("spotforecast2.tasks.task_entsoe._run_entsoe_pipeline") as mock_pipeline: with patch( "sys.argv", ["spotforecast2-entsoe", "train", "lgbm", "--show", "--force"], diff --git a/tests/test_train_val_days.py b/tests/test_train_val_days.py index 25a313b3..d81acf94 100644 --- a/tests/test_train_val_days.py +++ b/tests/test_train_val_days.py @@ -102,9 +102,9 @@ def test_config_train_size_can_be_overridden_to_none(self): def test_cv_ts_fixed_train_size_true_when_train_size_set(self): t = LazyTask(ConfigMulti(train_size=pd.Timedelta(days=365))) - t.config.end_train_ts = pd.Timestamp("2025-01-01", tz="UTC") + t.run_state.end_train_ts = pd.Timestamp("2025-01-01", tz="UTC") n = 4000 - idx = pd.date_range(end=t.config.end_train_ts, periods=n, freq="h", tz="UTC") + idx = pd.date_range(end=t.run_state.end_train_ts, periods=n, freq="h", tz="UTC") y = pd.Series(range(n), index=idx, dtype=float) cv = t.cv_ts(y) assert cv.fixed_train_size is True diff --git a/uv.lock b/uv.lock index 3e82d2c6..24da88bd 100644 --- a/uv.lock +++ b/uv.lock @@ -3604,7 +3604,7 @@ wheels = [ [[package]] name = "spotforecast2" -version = "3.9.0" +version = "4.0.0" source = { editable = "." } dependencies = [ { name = "astral" }, @@ -3683,7 +3683,7 @@ requires-dist = [ { name = "safety", marker = "extra == 'dev'", specifier = ">=3.0.0" }, { name = "scikit-learn", specifier = ">=1.8.0" }, { name = "shap", specifier = ">=0.49.1" }, - { name = "spotforecast2-safe", specifier = ">=16.3.0,<17" }, + { name = "spotforecast2-safe", specifier = ">=18.0.0,<19" }, { name = "spotoptim", specifier = ">=0.12.3" }, { name = "tqdm", specifier = ">=4.67.2" }, ] @@ -3699,7 +3699,7 @@ dev = [ [[package]] name = "spotforecast2-safe" -version = "16.3.0" +version = "18.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "astral" }, @@ -3717,9 +3717,9 @@ dependencies = [ { name = "statsmodels" }, { name = "tqdm" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/28/ce/456d06fdd69ed28af43c892f976ddebe0a0a1638306d1b59e2e6c4b4df47/spotforecast2_safe-16.3.0.tar.gz", hash = "sha256:217f31ac5e97d6c5d4d34d805663f6920f6e261116a697c1da48bcbaa8eef213", size = 20594976, upload-time = "2026-06-05T00:44:56.487Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/59/200ebbb03ee5429f87c11cda3082a531f2abd786a94c594a24d9a9023def/spotforecast2_safe-18.0.0.tar.gz", hash = "sha256:23ac511e5b9f2313c8c680516bd688b3773e37427d5c90e19d90fd7b00def957", size = 20607276, upload-time = "2026-06-06T20:42:24.808Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/dc/beceeae4ca30a1ab7bd622852630d0b39ac23a5d4531955944964bfb1723/spotforecast2_safe-16.3.0-py3-none-any.whl", hash = "sha256:7ac352138d953e04af2f4c10eccd8848e6f26e1d5fb6f71c9490cb5b42b8567e", size = 20658346, upload-time = "2026-06-05T00:44:53.962Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c2/677dfbb970cb52524fd26b60b4ff9cd7e1cb0470c5a673feabc2f996a9dd/spotforecast2_safe-18.0.0-py3-none-any.whl", hash = "sha256:6b5f873f51d583f19b7ab914f8a7ed92a46d9f46e4f198e69679ec06f3d9bc2e", size = 20671800, upload-time = "2026-06-06T20:42:13.41Z" }, ] [[package]]