diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md index 77ed2fab8..fd2031aea 100644 --- a/.github/CODE_OF_CONDUCT.md +++ b/.github/CODE_OF_CONDUCT.md @@ -1,5 +1,5 @@ @@ -61,7 +61,7 @@ further defined and clarified by project maintainers. ## Conflict Resolution -Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at short.term.energy.forecasts@alliander.com. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at openstef@lfenergy.org. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project’s leadership. @@ -69,4 +69,4 @@ Project maintainers who do not follow or enforce the Code of Conduct in good fai This Code of Conduct is adapted from the Contributor Covenant, version 1.4, available at -https://www.contributor-covenant.org/version/1/4/code-of-conduct.html \ No newline at end of file +https://www.contributor-covenant.org/version/1/4/code-of-conduct.html diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index c2282aa47..8f9778107 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -1,5 +1,5 @@ @@ -31,7 +31,7 @@ For information about: - 💬 **Slack**: [LF Energy workspace](https://slack.lfenergy.org/) (#openstef channel) - 🐛 **Issues**: [GitHub Issues](https://github.com/OpenSTEF/openstef/issues) -- 📧 **Email**: short.term.energy.forecasts@alliander.com +- 📧 **Email**: openstef@lfenergy.org - 📖 **Support**: [Support page](https://openstef.github.io/openstef/v4/project/support.html) ## Good First Issues diff --git a/.github/pr-labeler.yml b/.github/pr-labeler.yml index b29c00f13..485395948 100644 --- a/.github/pr-labeler.yml +++ b/.github/pr-labeler.yml @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2017-2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2017-2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 # config of action in ./workflows/... diff --git a/.github/workflows/_job_quality_check.yaml b/.github/workflows/_job_quality_check.yaml index 388452dc2..f4507d23f 100644 --- a/.github/workflows/_job_quality_check.yaml +++ b/.github/workflows/_job_quality_check.yaml @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 @@ -62,4 +62,4 @@ jobs: - name: Stop if any quality step failed # All tests are run with always() not to stop on the first error. This step makes the workflow fail if any quality step failed. if: ${{ failure() }} - run: exit 1 \ No newline at end of file + run: exit 1 diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index b8b40503f..ba85e63c2 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # SPDX-License-Identifier: MPL-2.0 name: Quality Check @@ -18,4 +18,4 @@ permissions: jobs: quality: name: Quality Checks - uses: ./.github/workflows/_job_quality_check.yaml \ No newline at end of file + uses: ./.github/workflows/_job_quality_check.yaml diff --git a/.github/workflows/citations.yaml b/.github/workflows/citations.yaml index f8904588a..8809b15a1 100644 --- a/.github/workflows/citations.yaml +++ b/.github/workflows/citations.yaml @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2017-2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2017-2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 @@ -35,4 +35,4 @@ jobs: run: | sudo apt-get update && sudo apt-get install -y r-base - name: Validate CITATION.cff - uses: dieghernan/cff-validator@v4 \ No newline at end of file + uses: dieghernan/cff-validator@v4 diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index 0f5ad1fa4..29cd4570e 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/.github/workflows/pr-labeler.yaml b/.github/workflows/pr-labeler.yaml index 665a18fac..86df8af08 100644 --- a/.github/workflows/pr-labeler.yaml +++ b/.github/workflows/pr-labeler.yaml @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2017-2022 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2017-2022 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 # Automatically label PRs, config in ../pr-labler.yml @@ -18,4 +18,4 @@ jobs: with: configuration-path: .github/pr-labeler.yml # optional, .github/pr-labeler.yml is the default value env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release-dev.yaml b/.github/workflows/release-dev.yaml index 6cd590ed3..f007ca777 100644 --- a/.github/workflows/release-dev.yaml +++ b/.github/workflows/release-dev.yaml @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # SPDX-License-Identifier: MPL-2.0 name: Dev Release diff --git a/.github/workflows/release-v4.yaml b/.github/workflows/release-v4.yaml index 2f6d813c7..6479e85ff 100644 --- a/.github/workflows/release-v4.yaml +++ b/.github/workflows/release-v4.yaml @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # SPDX-License-Identifier: MPL-2.0 name: Release V4 @@ -80,4 +80,4 @@ jobs: - name: Publish packages run: uv publish --trusted-publishing always - name: Summary - run: echo "Published version ${{ steps.ver.outputs.version }}" \ No newline at end of file + run: echo "Published version ${{ steps.ver.outputs.version }}" diff --git a/.gitignore b/.gitignore index 05af14c40..f143d21c5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2017-2025 Contributors to the OpenSTEF project # noqa E501> +# SPDX-FileCopyrightText: 2017-2025 Contributors to the OpenSTEF project # noqa E501> # SPDX-License-Identifier: MPL-2.0 # Core @@ -124,4 +124,7 @@ certificates/ *.pkl # Benchmark outputs -benchmark_results*/ \ No newline at end of file +benchmark_results*/ +# Experiment outputs +optimization_results/ +dev_sandbox/ diff --git a/CITATION.cff b/CITATION.cff index 4c884a641..35c1d6432 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2017-2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2017-2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 @@ -60,4 +60,4 @@ authors: - name: "Contributors to the OpenSTEF project" contact: - name: "Team mailbox OpenSTEF" - email: "short.term.energy.forecasts@alliander.com" + email: "openstef@lfenergy.org" diff --git a/COMMITTERS.md b/COMMITTERS.md index 0088210d9..94a0581dd 100644 --- a/COMMITTERS.md +++ b/COMMITTERS.md @@ -1,5 +1,5 @@ diff --git a/README.md b/README.md index 55b5024a0..d075f24dc 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ @@ -142,4 +142,3 @@ This project includes third-party libraries licensed under their respective Open - **[GitHub Discussions](https://github.com/OpenSTEF/openstef/discussions)** - community Q&A and discussions - **[Issue Tracker](https://github.com/OpenSTEF/openstef/issues)** - bug reports and feature requests - **[LF Energy OpenSTEF](https://www.lfenergy.org/projects/openstef/)** - project homepage - diff --git a/REUSE.toml b/REUSE.toml index 061010cca..0d4f42da5 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -5,9 +5,10 @@ path = [ ".github/ISSUE_TEMPLATE/**", ".python-version", "uv.lock", + "examples/*/*.ipynb", ] precedence = "override" -SPDX-FileCopyrightText = "2025 Contributors to the OpenSTEF project " +SPDX-FileCopyrightText = "2025 Contributors to the OpenSTEF project " SPDX-License-Identifier = "MPL-2.0" [[annotations]] @@ -15,6 +16,5 @@ path = [ "**/__pycache__/**", ] precedence = "override" -SPDX-FileCopyrightText = "2025 Contributors to the OpenSTEF project " +SPDX-FileCopyrightText = "2025 Contributors to the OpenSTEF project " SPDX-License-Identifier = "MPL-2.0" - diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md index 2b7d377e4..400680a1b 100644 --- a/THIRD_PARTY_LICENSES.md +++ b/THIRD_PARTY_LICENSES.md @@ -1,5 +1,5 @@ @@ -1289,4 +1289,3 @@ Find a list of packages below - License: MIT - Compatible: True - Size: 22363 - diff --git a/data/liander2024-energy-forecasting-benchmark b/data/liander2024-energy-forecasting-benchmark new file mode 160000 index 000000000..ec28635e2 --- /dev/null +++ b/data/liander2024-energy-forecasting-benchmark @@ -0,0 +1 @@ +Subproject commit ec28635e20a12927f3323a2dc18a0719a361b56d diff --git a/docs/.gitkeep b/docs/.gitkeep index f44dad5bb..72baaab86 100644 --- a/docs/.gitkeep +++ b/docs/.gitkeep @@ -1,3 +1,3 @@ -# SPDX-FileCopyrightText: 2017-2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2017-2025 Contributors to the OpenSTEF project # -# SPDX-License-Identifier: MPL-2.0 \ No newline at end of file +# SPDX-License-Identifier: MPL-2.0 diff --git a/docs/pyproject.toml b/docs/pyproject.toml index e3ed9083f..f06651cfc 100644 --- a/docs/pyproject.toml +++ b/docs/pyproject.toml @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 @@ -10,7 +10,7 @@ readme = "README.md" keywords = [ "energy", "forecasting", "machinelearning" ] license = "MPL-2.0" authors = [ - { name = "Alliander N.V", email = "short.term.energy.forecasts@alliander.com" }, + { name = "Alliander N.V", email = "openstef@lfenergy.org" }, ] requires-python = ">=3.12,<4.0" dependencies = [ diff --git a/docs/source/_static/css/custom.css b/docs/source/_static/css/custom.css index 1e115752c..7a1227464 100644 --- a/docs/source/_static/css/custom.css +++ b/docs/source/_static/css/custom.css @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project + * SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project * * SPDX-License-Identifier: MPL-2.0 */ @@ -7,4 +7,4 @@ .navbar-brand.logo { padding-top: 0.75rem; padding-bottom: 0.75rem; -} \ No newline at end of file +} diff --git a/docs/source/_static/versions.json.license b/docs/source/_static/versions.json.license index 37e10dd31..7d320d6e2 100644 --- a/docs/source/_static/versions.json.license +++ b/docs/source/_static/versions.json.license @@ -1,3 +1,3 @@ -SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project SPDX-License-Identifier: MPL-2.0 diff --git a/docs/source/_templates/custom_class.rst b/docs/source/_templates/custom_class.rst index f9428029d..6887cc247 100644 --- a/docs/source/_templates/custom_class.rst +++ b/docs/source/_templates/custom_class.rst @@ -1,4 +1,4 @@ -.. SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +.. SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project .. .. SPDX-License-Identifier: MPL-2.0 diff --git a/docs/source/_templates/custom_function.rst b/docs/source/_templates/custom_function.rst index df6eb6284..b1c091ae0 100644 --- a/docs/source/_templates/custom_function.rst +++ b/docs/source/_templates/custom_function.rst @@ -1,4 +1,4 @@ -.. SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +.. SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project .. .. SPDX-License-Identifier: MPL-2.0 diff --git a/docs/source/_templates/module_overview.rst b/docs/source/_templates/module_overview.rst index da80ddc57..4978be78e 100644 --- a/docs/source/_templates/module_overview.rst +++ b/docs/source/_templates/module_overview.rst @@ -1,4 +1,4 @@ -.. SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +.. SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project .. .. SPDX-License-Identifier: MPL-2.0 diff --git a/docs/source/_templates/package_overview.rst b/docs/source/_templates/package_overview.rst index e67a2ad70..5cd4cd993 100644 --- a/docs/source/_templates/package_overview.rst +++ b/docs/source/_templates/package_overview.rst @@ -1,4 +1,4 @@ -.. SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +.. SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project .. .. SPDX-License-Identifier: MPL-2.0 diff --git a/docs/source/api/index.rst b/docs/source/api/index.rst index 67775642c..8e57fe44e 100644 --- a/docs/source/api/index.rst +++ b/docs/source/api/index.rst @@ -1,4 +1,4 @@ -.. SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +.. SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project .. .. SPDX-License-Identifier: MPL-2.0 @@ -85,5 +85,3 @@ BEAM Package (:mod:`openstef_beam`) analysis evaluation benchmarking - - diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 02517547a..42ffb2b68 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -1,5 +1,5 @@ .. comment: - SPDX-FileCopyrightText: 2017-2025 Contributors to the OpenSTEF project + SPDX-FileCopyrightText: 2017-2025 Contributors to the OpenSTEF project SPDX-License-Identifier: MPL-2.0 .. _changelog: diff --git a/docs/source/conf.py b/docs/source/conf.py index 5aa8a0d41..932a5d101 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/docs/source/contribute/_getting_help.rst b/docs/source/contribute/_getting_help.rst index 5bdbb53fa..033647d9f 100644 --- a/docs/source/contribute/_getting_help.rst +++ b/docs/source/contribute/_getting_help.rst @@ -1,4 +1,4 @@ -.. SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +.. SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project .. .. SPDX-License-Identifier: MPL-2.0 @@ -9,7 +9,7 @@ If you need assistance: * 💬 **Slack**: Join the `LF Energy Slack workspace `_ (#openstef channel) * 🐛 **Issues**: Check `GitHub Issues `_ or create a new one -* 📧 **Email**: Contact us at ``short.term.energy.forecasts@alliander.com`` +* 📧 **Email**: Contact us at ``openstef@lfenergy.org`` * 🤝 **Community meetings**: Join our four-weekly co-coding sessions For more information, see our :doc:`/project/support` page. diff --git a/docs/source/contribute/code_of_conduct.rst b/docs/source/contribute/code_of_conduct.rst index 1f2c9076f..afa7fe065 100644 --- a/docs/source/contribute/code_of_conduct.rst +++ b/docs/source/contribute/code_of_conduct.rst @@ -1,4 +1,4 @@ -.. SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +.. SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project .. .. SPDX-License-Identifier: MPL-2.0 diff --git a/docs/source/contribute/code_style_guide.rst b/docs/source/contribute/code_style_guide.rst index 62cf7d506..62855cf75 100644 --- a/docs/source/contribute/code_style_guide.rst +++ b/docs/source/contribute/code_style_guide.rst @@ -1,4 +1,4 @@ -.. SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +.. SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project .. .. SPDX-License-Identifier: MPL-2.0 diff --git a/docs/source/contribute/contributing_guide.rst b/docs/source/contribute/contributing_guide.rst index 06782b03b..abf42cee7 100644 --- a/docs/source/contribute/contributing_guide.rst +++ b/docs/source/contribute/contributing_guide.rst @@ -1,4 +1,4 @@ -.. SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +.. SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project .. .. SPDX-License-Identifier: MPL-2.0 diff --git a/docs/source/contribute/development_setup.rst b/docs/source/contribute/development_setup.rst index 7e67cf1f6..56e7b5c2c 100644 --- a/docs/source/contribute/development_setup.rst +++ b/docs/source/contribute/development_setup.rst @@ -1,4 +1,4 @@ -.. SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +.. SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project .. .. SPDX-License-Identifier: MPL-2.0 diff --git a/docs/source/contribute/development_workflow.rst b/docs/source/contribute/development_workflow.rst index 123dfdcc3..7ac92edda 100644 --- a/docs/source/contribute/development_workflow.rst +++ b/docs/source/contribute/development_workflow.rst @@ -1,4 +1,4 @@ -.. SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +.. SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project .. .. SPDX-License-Identifier: MPL-2.0 @@ -252,7 +252,7 @@ The ``reuse --fix`` command automatically adds the correct license header to new .. code-block:: python - # SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project + # SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/docs/source/contribute/document.rst b/docs/source/contribute/document.rst index 04962f275..06c42be5a 100644 --- a/docs/source/contribute/document.rst +++ b/docs/source/contribute/document.rst @@ -1,4 +1,4 @@ -.. SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +.. SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project .. .. SPDX-License-Identifier: MPL-2.0 @@ -397,4 +397,4 @@ If you need help with documentation specifically: * Check the `Sphinx documentation `_ * Look at existing documentation for examples -* Reference the `Diátaxis framework `_ for guidance on documentation types \ No newline at end of file +* Reference the `Diátaxis framework `_ for guidance on documentation types diff --git a/docs/source/contribute/index.rst b/docs/source/contribute/index.rst index 3f38db89d..9e86f5d60 100644 --- a/docs/source/contribute/index.rst +++ b/docs/source/contribute/index.rst @@ -1,4 +1,4 @@ -.. SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +.. SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project .. .. SPDX-License-Identifier: MPL-2.0 @@ -208,4 +208,4 @@ maintainable and user-friendly. :maxdepth: 1 :titlesonly: - code_of_conduct \ No newline at end of file + code_of_conduct diff --git a/docs/source/examples.rst b/docs/source/examples.rst index 4ce99919e..23023da77 100644 --- a/docs/source/examples.rst +++ b/docs/source/examples.rst @@ -1,5 +1,5 @@ .. comment: - SPDX-FileCopyrightText: 2017-2025 Contributors to the OpenSTEF project + SPDX-FileCopyrightText: 2017-2025 Contributors to the OpenSTEF project SPDX-License-Identifier: MPL-2.0 .. _examples: diff --git a/docs/source/images/methodology_train_predict.pptx.license b/docs/source/images/methodology_train_predict.pptx.license index 37e10dd31..7d320d6e2 100644 --- a/docs/source/images/methodology_train_predict.pptx.license +++ b/docs/source/images/methodology_train_predict.pptx.license @@ -1,3 +1,3 @@ -SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project SPDX-License-Identifier: MPL-2.0 diff --git a/docs/source/images/methodology_train_predict.svg.license b/docs/source/images/methodology_train_predict.svg.license index 37e10dd31..7d320d6e2 100644 --- a/docs/source/images/methodology_train_predict.svg.license +++ b/docs/source/images/methodology_train_predict.svg.license @@ -1,3 +1,3 @@ -SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project SPDX-License-Identifier: MPL-2.0 diff --git a/docs/source/images/uncertainty_estimation.svg.license b/docs/source/images/uncertainty_estimation.svg.license index 37e10dd31..7d320d6e2 100644 --- a/docs/source/images/uncertainty_estimation.svg.license +++ b/docs/source/images/uncertainty_estimation.svg.license @@ -1,3 +1,3 @@ -SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project SPDX-License-Identifier: MPL-2.0 diff --git a/docs/source/index.rst b/docs/source/index.rst index 1e0fe3720..06b65ce0a 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -1,4 +1,4 @@ -.. SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +.. SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project .. .. SPDX-License-Identifier: MPL-2.0 @@ -168,4 +168,4 @@ About OpenSTEF * :doc:`project/committee` * :doc:`project/maintainers` * :doc:`project/citing` - * :doc:`project/license` \ No newline at end of file + * :doc:`project/license` diff --git a/docs/source/logos/favicon.ico.license b/docs/source/logos/favicon.ico.license index 57abd4c9e..ce6567d86 100644 --- a/docs/source/logos/favicon.ico.license +++ b/docs/source/logos/favicon.ico.license @@ -1,3 +1,3 @@ -SPDX-FileCopyrightText: 2017-2023 Contributors to the OpenSTEF project +SPDX-FileCopyrightText: 2017-2023 Contributors to the OpenSTEF project SPDX-License-Identifier: MPL-2.0 diff --git a/docs/source/logos/logo_openstef_small.png.license b/docs/source/logos/logo_openstef_small.png.license index 854eb6f31..882929b50 100644 --- a/docs/source/logos/logo_openstef_small.png.license +++ b/docs/source/logos/logo_openstef_small.png.license @@ -1,3 +1,3 @@ -SPDX-FileCopyrightText: 2017-2025 Contributors to the OpenSTEF project +SPDX-FileCopyrightText: 2017-2025 Contributors to the OpenSTEF project SPDX-License-Identifier: MPL-2.0 diff --git a/docs/source/logos/openstef-horizontal-color.svg.license b/docs/source/logos/openstef-horizontal-color.svg.license index 37e10dd31..7d320d6e2 100644 --- a/docs/source/logos/openstef-horizontal-color.svg.license +++ b/docs/source/logos/openstef-horizontal-color.svg.license @@ -1,3 +1,3 @@ -SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project SPDX-License-Identifier: MPL-2.0 diff --git a/docs/source/logos/openstef-horizontal-white.svg.license b/docs/source/logos/openstef-horizontal-white.svg.license index 37e10dd31..7d320d6e2 100644 --- a/docs/source/logos/openstef-horizontal-white.svg.license +++ b/docs/source/logos/openstef-horizontal-white.svg.license @@ -1,3 +1,3 @@ -SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project SPDX-License-Identifier: MPL-2.0 diff --git a/docs/source/project/citing.rst b/docs/source/project/citing.rst index eb7e0a037..6eb509e25 100644 --- a/docs/source/project/citing.rst +++ b/docs/source/project/citing.rst @@ -1,4 +1,4 @@ -.. SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +.. SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project .. .. SPDX-License-Identifier: MPL-2.0 @@ -37,4 +37,4 @@ Citation File Format (CFF) .. container:: sphx-glr-download - :download:`Download CFF citation file: CITATION.cff <../../../CITATION.cff>` \ No newline at end of file + :download:`Download CFF citation file: CITATION.cff <../../../CITATION.cff>` diff --git a/docs/source/project/committee.rst b/docs/source/project/committee.rst index 7d01f3c74..2ddfe6ec2 100644 --- a/docs/source/project/committee.rst +++ b/docs/source/project/committee.rst @@ -1,4 +1,4 @@ -.. SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +.. SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project .. .. SPDX-License-Identifier: MPL-2.0 @@ -22,8 +22,8 @@ The TSC consists of the following members: 6. Maxime Fortin 7. Bart Pleiter -Any community member or Contributor can ask that something be reviewed by the TSC by contacting the TSC at ``short.term.energy.forecasts@alliander.com``. +Any community member or Contributor can ask that something be reviewed by the TSC by contacting the TSC at ``openstef@lfenergy.org``. More information, meeting notes and meeting links can be found in the `TSC documentation `_. -More information on project governance can be found in the `project governance documentation `_. \ No newline at end of file +More information on project governance can be found in the `project governance documentation `_. diff --git a/docs/source/project/index.rst b/docs/source/project/index.rst index d9fb7385a..61ef24f2e 100644 --- a/docs/source/project/index.rst +++ b/docs/source/project/index.rst @@ -1,5 +1,5 @@ .. comment: - SPDX-FileCopyrightText: 2017-2025 Contributors to the OpenSTEF project + SPDX-FileCopyrightText: 2017-2025 Contributors to the OpenSTEF project SPDX-License-Identifier: MPL-2.0 .. _community: @@ -34,4 +34,4 @@ If you found a bug or would like to request a feature, please `open an issue `_. -.. include:: support.rst \ No newline at end of file +.. include:: support.rst diff --git a/docs/source/project/license.rst b/docs/source/project/license.rst index 6cf4880cb..f2ca4f7c5 100644 --- a/docs/source/project/license.rst +++ b/docs/source/project/license.rst @@ -1,4 +1,4 @@ -.. SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +.. SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project .. .. SPDX-License-Identifier: MPL-2.0 diff --git a/docs/source/project/maintainers.rst b/docs/source/project/maintainers.rst index b541463c2..8e6e387aa 100644 --- a/docs/source/project/maintainers.rst +++ b/docs/source/project/maintainers.rst @@ -1,4 +1,4 @@ -.. SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +.. SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project .. .. SPDX-License-Identifier: MPL-2.0 @@ -24,4 +24,4 @@ The current maintainers of this project are: 6. Egor Dmitriev 7. Lars Schilders -Any community member or Contributor can ask a question or raise an issue to the maintainers by logging a GitHub issue. \ No newline at end of file +Any community member or Contributor can ask a question or raise an issue to the maintainers by logging a GitHub issue. diff --git a/docs/source/project/support.rst b/docs/source/project/support.rst index ce08beb0d..7727cbf55 100644 --- a/docs/source/project/support.rst +++ b/docs/source/project/support.rst @@ -1,4 +1,4 @@ -.. SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +.. SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project .. .. SPDX-License-Identifier: MPL-2.0 @@ -9,7 +9,7 @@ Support There are a few ways to connect with the OpenSTEF project: * Join the **#openstef channel**, which is part of the `LF Energy Slack workspace `_. - * Depending on your work e-mail address, you may need to be invited in order to join the Slack workspace. If this is the case, please e-mail ``short.term.energy.forecasts@alliander.com``. We are happy to invite you. + * Depending on your work e-mail address, you may need to be invited in order to join the Slack workspace. If this is the case, please e-mail ``openstef@lfenergy.org``. We are happy to invite you. * Send a **direct message** to one of the most recent `contributors `_. * Submit an **issue** at `GitHub `_. * Join the four-weekly **community meeting**. You can find information, including meeting invite links, on `our wiki page `_. @@ -27,4 +27,4 @@ This project manages bugs and enhancements using the `GitHub issue tracker +.. SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project .. .. SPDX-License-Identifier: MPL-2.0 @@ -10,4 +10,4 @@ External resources ====== Videos -====== \ No newline at end of file +====== diff --git a/docs/source/user_guide/index.rst b/docs/source/user_guide/index.rst index 7f119206d..af29e3944 100644 --- a/docs/source/user_guide/index.rst +++ b/docs/source/user_guide/index.rst @@ -1,4 +1,4 @@ -.. SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +.. SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project .. .. SPDX-License-Identifier: MPL-2.0 @@ -17,4 +17,3 @@ ========== User Guide ========== - diff --git a/docs/source/user_guide/installation.rst b/docs/source/user_guide/installation.rst index b289ade05..4184dec60 100644 --- a/docs/source/user_guide/installation.rst +++ b/docs/source/user_guide/installation.rst @@ -1,4 +1,4 @@ -.. SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +.. SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project .. .. SPDX-License-Identifier: MPL-2.0 @@ -314,7 +314,7 @@ If you encounter issues: 1. Check the `GitHub Issues `_ 2. Review the :doc:`../contribute/index` guide 3. Visit our :ref:`support` page for community resources -4. Contact us at short.term.energy.forecasts@alliander.com +4. Contact us at openstef@lfenergy.org Platform-Specific Notes ======================== @@ -396,4 +396,4 @@ OpenSTEF follows semantic versioning. To stay updated with the latest releases: # Upgrade to latest version pixi upgrade openstef -Subscribe to our `GitHub releases `_ for notifications about new versions and features. \ No newline at end of file +Subscribe to our `GitHub releases `_ for notifications about new versions and features. diff --git a/docs/source/user_guide/intro/index.rst b/docs/source/user_guide/intro/index.rst index 2610b31b7..986f8e6d8 100644 --- a/docs/source/user_guide/intro/index.rst +++ b/docs/source/user_guide/intro/index.rst @@ -1,4 +1,4 @@ -.. SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +.. SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project .. .. SPDX-License-Identifier: MPL-2.0 @@ -12,4 +12,4 @@ Intro to Energy Forecasting This page is inherited from OpenSTEF 3.0 and may still be revised. ... -.. include:: methodology_train_predict.rst \ No newline at end of file +.. include:: methodology_train_predict.rst diff --git a/docs/source/user_guide/intro/methodology_train_predict.rst b/docs/source/user_guide/intro/methodology_train_predict.rst index 8e3fb099a..3ab6c744f 100644 --- a/docs/source/user_guide/intro/methodology_train_predict.rst +++ b/docs/source/user_guide/intro/methodology_train_predict.rst @@ -1,4 +1,4 @@ -.. SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +.. SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project .. .. SPDX-License-Identifier: MPL-2.0 @@ -26,4 +26,4 @@ on how to the confidence estimations should be used. :alt: Uncertainty estimation -`Source file `__ \ No newline at end of file +`Source file `__ diff --git a/docs/source/user_guide/logging.rst b/docs/source/user_guide/logging.rst index 85dd04c3e..d6d590f0f 100644 --- a/docs/source/user_guide/logging.rst +++ b/docs/source/user_guide/logging.rst @@ -1,4 +1,4 @@ -.. SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +.. SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project .. .. SPDX-License-Identifier: MPL-2.0 diff --git a/docs/source/user_guide/quick_start.rst b/docs/source/user_guide/quick_start.rst index 3f0e15b77..a1dfacc2e 100644 --- a/docs/source/user_guide/quick_start.rst +++ b/docs/source/user_guide/quick_start.rst @@ -1,4 +1,4 @@ -.. SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +.. SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project .. .. SPDX-License-Identifier: MPL-2.0 @@ -10,4 +10,4 @@ Quick Start .. admonition:: This page is under construction. - Want to help? Check :ref:`Contributing ` for more information. \ No newline at end of file + Want to help? Check :ref:`Contributing ` for more information. diff --git a/docs/source/user_guide/tutorials.rst b/docs/source/user_guide/tutorials.rst index 7ca78f9a7..54c36ceff 100644 --- a/docs/source/user_guide/tutorials.rst +++ b/docs/source/user_guide/tutorials.rst @@ -1,5 +1,5 @@ .. comment: - SPDX-FileCopyrightText: 2017-2025 Contributors to the OpenSTEF project + SPDX-FileCopyrightText: 2017-2025 Contributors to the OpenSTEF project SPDX-License-Identifier: MPL-2.0 .. _tutorials: diff --git a/examples/.gitkeep b/examples/.gitkeep index f44dad5bb..72baaab86 100644 --- a/examples/.gitkeep +++ b/examples/.gitkeep @@ -1,3 +1,3 @@ -# SPDX-FileCopyrightText: 2017-2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2017-2025 Contributors to the OpenSTEF project # -# SPDX-License-Identifier: MPL-2.0 \ No newline at end of file +# SPDX-License-Identifier: MPL-2.0 diff --git a/examples/benchmarks/liander_2024_benchmark_xgboost_gblinear.py b/examples/benchmarks/liander_2024_benchmark_xgboost_gblinear.py index dc41b1744..00c07bdb7 100644 --- a/examples/benchmarks/liander_2024_benchmark_xgboost_gblinear.py +++ b/examples/benchmarks/liander_2024_benchmark_xgboost_gblinear.py @@ -6,7 +6,7 @@ The benchmark will evaluate XGBoost and GBLinear models on the dataset from HuggingFace. """ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 @@ -18,35 +18,29 @@ import logging import multiprocessing -from datetime import timedelta from pathlib import Path -from pydantic_extra_types.coordinate import Coordinate -from pydantic_extra_types.country import CountryAlpha2 - -from openstef_beam.backtesting.backtest_forecaster import BacktestForecasterConfig, OpenSTEF4BacktestForecaster -from openstef_beam.benchmarking.benchmark_pipeline import BenchmarkContext +from openstef_beam.benchmarking.baselines import ( + create_openstef4_preset_backtest_forecaster, +) from openstef_beam.benchmarking.benchmarks.liander2024 import Liander2024Category, create_liander2024_benchmark_runner from openstef_beam.benchmarking.callbacks.strict_execution_callback import StrictExecutionCallback -from openstef_beam.benchmarking.models.benchmark_target import BenchmarkTarget from openstef_beam.benchmarking.storage.local_storage import LocalBenchmarkStorage from openstef_core.types import LeadTime, Q from openstef_models.integrations.mlflow.mlflow_storage import MLFlowStorage from openstef_models.presets import ( ForecastingWorkflowConfig, - create_forecasting_workflow, ) -from openstef_models.presets.forecasting_workflow import LocationConfig -from openstef_models.workflows import CustomForecastingWorkflow logging.basicConfig(level=logging.INFO, format="[%(asctime)s][%(levelname)s] %(message)s") -OUTPUT_PATH = Path("./benchmark_results") +OUTPUT_PATH = Path("./benchmark_results_test_convenience") BENCHMARK_RESULTS_PATH_XGBOOST = OUTPUT_PATH / "XGBoost" BENCHMARK_RESULTS_PATH_GBLINEAR = OUTPUT_PATH / "GBLinear" N_PROCESSES = multiprocessing.cpu_count() # Amount of parallel processes to use for the benchmark + # Model configuration FORECAST_HORIZONS = [LeadTime.from_string("P3D")] # Forecast horizon(s) PREDICTION_QUANTILES = [ @@ -73,11 +67,12 @@ common_config = ForecastingWorkflowConfig( model_id="common_model_", + run_name=None, model="flatliner", horizons=FORECAST_HORIZONS, quantiles=PREDICTION_QUANTILES, - model_reuse_enable=False, - mlflow_storage=None, + model_reuse_enable=True, + mlflow_storage=storage, radiation_column="shortwave_radiation", rolling_aggregate_features=["mean", "median", "max", "min"], wind_speed_column="wind_speed_80m", @@ -91,72 +86,30 @@ gblinear_config = common_config.model_copy(update={"model": "gblinear"}) -# Create the backtest configuration -backtest_config = BacktestForecasterConfig( - requires_training=True, - predict_length=timedelta(days=7), - predict_min_length=timedelta(minutes=15), - predict_context_length=timedelta(days=14), # Context needed for lag features - predict_context_min_coverage=0.5, - training_context_length=timedelta(days=90), # Three months of training data - training_context_min_coverage=0.5, - predict_sample_interval=timedelta(minutes=15), -) - - -def _target_forecaster_factory( - context: BenchmarkContext, - target: BenchmarkTarget, -) -> OpenSTEF4BacktestForecaster: - # Factory function that creates a forecaster for a given target. - prefix = context.run_name - base_config = xgboost_config if context.run_name == "xgboost" else gblinear_config - - def _create_workflow() -> CustomForecastingWorkflow: - # Create a new workflow instance with fresh model. - return create_forecasting_workflow( - config=base_config.model_copy( - update={ - "model_id": f"{prefix}_{target.name}", - "location": LocationConfig( - name=target.name, - description=target.description, - coordinate=Coordinate( - latitude=target.latitude, - longitude=target.longitude, - ), - country_code=CountryAlpha2("NL"), - ), - } - ) - ) - - return OpenSTEF4BacktestForecaster( - config=backtest_config, - workflow_factory=_create_workflow, - debug=False, - cache_dir=OUTPUT_PATH / "cache" / f"{context.run_name}_{target.name}", - ) - - if __name__ == "__main__": # Run for XGBoost model create_liander2024_benchmark_runner( storage=LocalBenchmarkStorage(base_path=BENCHMARK_RESULTS_PATH_XGBOOST), callbacks=[StrictExecutionCallback()], ).run( - forecaster_factory=_target_forecaster_factory, + forecaster_factory=create_openstef4_preset_backtest_forecaster( + workflow_config=xgboost_config, + cache_dir=OUTPUT_PATH / "cache", + ), run_name="xgboost", n_processes=N_PROCESSES, filter_args=BENCHMARK_FILTER, ) - # Run for GBLinear model + # # Run for GBLinear model create_liander2024_benchmark_runner( storage=LocalBenchmarkStorage(base_path=BENCHMARK_RESULTS_PATH_GBLINEAR), callbacks=[StrictExecutionCallback()], ).run( - forecaster_factory=_target_forecaster_factory, + forecaster_factory=create_openstef4_preset_backtest_forecaster( + workflow_config=gblinear_config, + cache_dir=OUTPUT_PATH / "cache", + ), run_name="gblinear", n_processes=N_PROCESSES, filter_args=BENCHMARK_FILTER, diff --git a/examples/benchmarks/liander_2024_compare_results.py b/examples/benchmarks/liander_2024_compare_results.py index 3de16460c..dabbbec49 100644 --- a/examples/benchmarks/liander_2024_compare_results.py +++ b/examples/benchmarks/liander_2024_compare_results.py @@ -1,5 +1,5 @@ """Example for comparing benchmark results from different runs on the Liander 2024 dataset.""" -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/examples/benchmarks/liander_2024_ensemble.py b/examples/benchmarks/liander_2024_ensemble.py new file mode 100644 index 000000000..24b2ff887 --- /dev/null +++ b/examples/benchmarks/liander_2024_ensemble.py @@ -0,0 +1,135 @@ +"""Liander 2024 Benchmark Example. + +==================================== + +This example demonstrates how to set up and run the Liander 2024 STEF benchmark using OpenSTEF BEAM. +The benchmark will evaluate XGBoost and GBLinear models on the dataset from HuggingFace. +""" + +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# +# SPDX-License-Identifier: MPL-2.0 + +import os +import time + +os.environ["OMP_NUM_THREADS"] = "1" # Set OMP_NUM_THREADS to 1 to avoid issues with parallel execution and xgboost +os.environ["OPENBLAS_NUM_THREADS"] = "1" +os.environ["MKL_NUM_THREADS"] = "1" + +import logging +import multiprocessing +from datetime import timedelta +from pathlib import Path + +from openstef_beam.backtesting.backtest_forecaster import BacktestForecasterConfig +from openstef_beam.benchmarking.baselines import ( + create_openstef4_preset_backtest_forecaster, +) +from openstef_beam.benchmarking.benchmarks.liander2024 import Liander2024Category, create_liander2024_benchmark_runner +from openstef_beam.benchmarking.callbacks.strict_execution_callback import StrictExecutionCallback +from openstef_beam.benchmarking.storage.local_storage import LocalBenchmarkStorage +from openstef_core.types import LeadTime, Q +from openstef_meta.presets import ( + EnsembleWorkflowConfig, +) +from openstef_models.integrations.mlflow.mlflow_storage import MLFlowStorage + +logging.basicConfig(level=logging.INFO, format="[%(asctime)s][%(levelname)s] %(message)s") + +OUTPUT_PATH = Path("./benchmark_results") + +N_PROCESSES = 1 if True else multiprocessing.cpu_count() # Amount of parallel processes to use for the benchmark + +ensemble_type = "learned_weights" # "stacking", "learned_weights" or "rules" +base_models = [ + "lgbm", + "gblinear", +] # combination of "lgbm", "gblinear", "xgboost" and "lgbm_linear" +combiner_model = ( + "lgbm" # "lgbm", "xgboost", "rf" or "logistic" for learned weights combiner, gblinear for stacking combiner +) + +model = "Ensemble_contributions_" + "_".join(base_models) + "_" + ensemble_type + "_" + combiner_model + +# Model configuration +FORECAST_HORIZONS = [LeadTime.from_string("PT36H")] # Forecast horizon(s) +PREDICTION_QUANTILES = [ + Q(0.05), + Q(0.1), + Q(0.3), + Q(0.5), + Q(0.7), + Q(0.9), + Q(0.95), +] # Quantiles for probabilistic forecasts + +BENCHMARK_FILTER: list[Liander2024Category] | None = None + +USE_MLFLOW_STORAGE = False + +if USE_MLFLOW_STORAGE: + storage = MLFlowStorage( + tracking_uri=str(OUTPUT_PATH / "mlflow_artifacts"), + local_artifacts_path=OUTPUT_PATH / "mlflow_tracking_artifacts", + ) +else: + storage = None + +workflow_config = EnsembleWorkflowConfig( + model_id="common_model_", + ensemble_type=ensemble_type, + base_models=base_models, # type: ignore + combiner_model=combiner_model, + horizons=FORECAST_HORIZONS, + quantiles=PREDICTION_QUANTILES, + model_reuse_enable=False, + mlflow_storage=None, + radiation_column="shortwave_radiation", + rolling_aggregate_features=["mean", "median", "max", "min"], + wind_speed_column="wind_speed_80m", + pressure_column="surface_pressure", + temperature_column="temperature_2m", + relative_humidity_column="relative_humidity_2m", + energy_price_column="EPEX_NL", + forecast_combiner_sample_weight_exponent=0, + forecaster_sample_weight_exponent={ + "gblinear": 1, + "lgbm": 0, + "xgboost": 0, + "lgbm_linear": 0, + }, +) + + +# Create the backtest configuration +backtest_config = BacktestForecasterConfig( + requires_training=True, + predict_length=timedelta(days=7), + predict_min_length=timedelta(minutes=15), + predict_context_length=timedelta(days=14), # Context needed for lag features + predict_context_min_coverage=0.5, + training_context_length=timedelta(days=90), # Three months of training data + training_context_min_coverage=0.5, + predict_sample_interval=timedelta(minutes=15), +) + + +if __name__ == "__main__": + start_time = time.time() + create_liander2024_benchmark_runner( + storage=LocalBenchmarkStorage(base_path=OUTPUT_PATH / model), + data_dir=Path("data/liander2024-energy-forecasting-benchmark"), + callbacks=[StrictExecutionCallback()], + ).run( + forecaster_factory=create_openstef4_preset_backtest_forecaster( + workflow_config=workflow_config, + cache_dir=OUTPUT_PATH / "cache", + ), + run_name=model, + n_processes=N_PROCESSES, + filter_args=BENCHMARK_FILTER, + ) + + end_time = time.time() + print(f"Benchmark completed in {end_time - start_time:.2f} seconds.") diff --git a/examples/benchmarks/liander_2024_residual.py b/examples/benchmarks/liander_2024_residual.py new file mode 100644 index 000000000..a8a42b113 --- /dev/null +++ b/examples/benchmarks/liander_2024_residual.py @@ -0,0 +1,158 @@ +"""Liander 2024 Benchmark Example. + +==================================== + +This example demonstrates how to set up and run the Liander 2024 STEF benchmark using OpenSTEF BEAM. +The benchmark will evaluate XGBoost and GBLinear models on the dataset from HuggingFace. +""" + +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# +# SPDX-License-Identifier: MPL-2.0 + +import os +import time + +os.environ["OMP_NUM_THREADS"] = "1" # Set OMP_NUM_THREADS to 1 to avoid issues with parallel execution and xgboost +os.environ["OPENBLAS_NUM_THREADS"] = "1" +os.environ["MKL_NUM_THREADS"] = "1" + +import logging +import multiprocessing +from datetime import timedelta +from pathlib import Path + +from pydantic_extra_types.coordinate import Coordinate +from pydantic_extra_types.country import CountryAlpha2 + +from openstef_beam.backtesting.backtest_forecaster import BacktestForecasterConfig, OpenSTEF4BacktestForecaster +from openstef_beam.benchmarking.benchmark_pipeline import BenchmarkContext +from openstef_beam.benchmarking.benchmarks.liander2024 import Liander2024Category, create_liander2024_benchmark_runner +from openstef_beam.benchmarking.callbacks.strict_execution_callback import StrictExecutionCallback +from openstef_beam.benchmarking.models.benchmark_target import BenchmarkTarget +from openstef_beam.benchmarking.storage.local_storage import LocalBenchmarkStorage +from openstef_core.types import LeadTime, Q +from openstef_models.integrations.mlflow.mlflow_storage import MLFlowStorage +from openstef_models.presets import ( + ForecastingWorkflowConfig, + create_forecasting_workflow, +) +from openstef_models.presets.forecasting_workflow import LocationConfig +from openstef_models.workflows import CustomForecastingWorkflow + +logging.basicConfig(level=logging.INFO, format="[%(asctime)s][%(levelname)s] %(message)s") + +logger = logging.getLogger(__name__) + +OUTPUT_PATH = Path("./benchmark_results") + +N_PROCESSES = multiprocessing.cpu_count() # Amount of parallel processes to use for the benchmark + +model = "residual" # Can be "stacking", "learned_weights" or "residual" + +# Model configuration +FORECAST_HORIZONS = [LeadTime.from_string("PT36H")] # Forecast horizon(s) +PREDICTION_QUANTILES = [ + Q(0.05), + Q(0.1), + Q(0.3), + Q(0.5), + Q(0.7), + Q(0.9), + Q(0.95), +] # Quantiles for probabilistic forecasts + +BENCHMARK_FILTER: list[Liander2024Category] | None = None + +USE_MLFLOW_STORAGE = False + +if USE_MLFLOW_STORAGE: + storage = MLFlowStorage( + tracking_uri=str(OUTPUT_PATH / "mlflow_artifacts"), + local_artifacts_path=OUTPUT_PATH / "mlflow_tracking_artifacts", + ) +else: + storage = None + +common_config = ForecastingWorkflowConfig( + model_id="common_model_", + model=model, + horizons=FORECAST_HORIZONS, + quantiles=PREDICTION_QUANTILES, + model_reuse_enable=False, + mlflow_storage=None, + radiation_column="shortwave_radiation", + rolling_aggregate_features=["mean", "median", "max", "min"], + wind_speed_column="wind_speed_80m", + pressure_column="surface_pressure", + temperature_column="temperature_2m", + relative_humidity_column="relative_humidity_2m", + energy_price_column="EPEX_NL", +) + + +# Create the backtest configuration +backtest_config = BacktestForecasterConfig( + requires_training=True, + predict_length=timedelta(days=7), + predict_min_length=timedelta(minutes=15), + predict_context_length=timedelta(days=14), # Context needed for lag features + predict_context_min_coverage=0.5, + training_context_length=timedelta(days=90), # Three months of training data + training_context_min_coverage=0.5, + predict_sample_interval=timedelta(minutes=15), +) + + +def _target_forecaster_factory( + context: BenchmarkContext, + target: BenchmarkTarget, +) -> OpenSTEF4BacktestForecaster: + # Factory function that creates a forecaster for a given target. + prefix = context.run_name + base_config = common_config + + def _create_workflow() -> CustomForecastingWorkflow: + # Create a new workflow instance with fresh model. + return create_forecasting_workflow( + config=base_config.model_copy( + update={ + "model_id": f"{prefix}_{target.name}", + "location": LocationConfig( + name=target.name, + description=target.description, + coordinate=Coordinate( + latitude=target.latitude, + longitude=target.longitude, + ), + country_code=CountryAlpha2("NL"), + ), + } + ) + ) + + return OpenSTEF4BacktestForecaster( + config=backtest_config, + workflow_factory=_create_workflow, + debug=False, + cache_dir=OUTPUT_PATH / "cache" / f"{context.run_name}_{target.name}", + ) + + +if __name__ == "__main__": + start_time = time.time() + + create_liander2024_benchmark_runner( + storage=LocalBenchmarkStorage(base_path=OUTPUT_PATH / model), + data_dir=Path("../data/liander2024-energy-forecasting-benchmark"), # adjust path as needed + callbacks=[StrictExecutionCallback()], + ).run( + forecaster_factory=_target_forecaster_factory, + run_name=model, + n_processes=N_PROCESSES, + filter_args=BENCHMARK_FILTER, + ) + + end_time = time.time() + msg = f"Benchmark completed in {end_time - start_time:.2f} seconds." + logger.info(msg) diff --git a/examples/examples/.gitignore b/examples/examples/.gitignore index 1db058dc8..39116e399 100644 --- a/examples/examples/.gitignore +++ b/examples/examples/.gitignore @@ -1,5 +1,5 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 -mlflow_tracking* \ No newline at end of file +mlflow_tracking* diff --git a/examples/examples/configuring_model_pipeline_example.py b/examples/examples/configuring_model_pipeline_example.py index c315e5887..2b8250b1b 100644 --- a/examples/examples/configuring_model_pipeline_example.py +++ b/examples/examples/configuring_model_pipeline_example.py @@ -25,7 +25,7 @@ into a working forecasting system. """ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/examples/examples/forecasting_preset_example.py b/examples/examples/forecasting_preset_example.py index 47e1e42e4..480527252 100644 --- a/examples/examples/forecasting_preset_example.py +++ b/examples/examples/forecasting_preset_example.py @@ -25,7 +25,7 @@ into a working forecasting system. """ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/examples/model_contributions_plot.py b/examples/model_contributions_plot.py new file mode 100644 index 000000000..a0398cca1 --- /dev/null +++ b/examples/model_contributions_plot.py @@ -0,0 +1,438 @@ +# SPDX-FileCopyrightText: 2017-2025 Contributors to the OpenSTEF project +# SPDX-License-Identifier: MPL-2.0 +"""Model contribution visualization for ensemble forecasting. + +This module provides functionality to visualize and analyze model contributions +in ensemble forecasting, specifically comparing GBLinear and LGBM base models +across different quantiles. +""" + +from pathlib import Path + +import pandas as pd +import plotly.graph_objects as go +from plotly.subplots import make_subplots # type: ignore[attr-defined] +from tqdm import tqdm + +from openstef_beam.analysis.plots import ForecastTimeSeriesPlotter + +try: + from openstef_beam.analysis.plots import ForecastTimeSeriesPlotter +except ImportError: + ForecastTimeSeriesPlotter = None + + +def load_contribution_data(folder_path: Path, n_rows: int = 24) -> pd.DataFrame: + """Load and concatenate contribution data from parquet files. + + Args: + folder_path: Path to folder containing contribution parquet files. + n_rows: Number of rows to take from each file (default: 24 for 6 hours + at 15-minute intervals). + + Returns: + Concatenated DataFrame with contribution data. + + Raises: + ValueError: If no parquet files are found in the specified folder. + """ + parquet_files = sorted(folder_path.glob("contrib_*_predict.parquet")) + + if not parquet_files: + msg = f"No contribution files found in {folder_path}" + raise ValueError(msg) + + print(f"Found {len(parquet_files)} contribution files") + + df_list = [] + for file in tqdm(parquet_files, desc="Loading contribution data"): + df_temp = pd.read_parquet(file) # type: ignore[call-overload] + df_subset = df_temp.head(n_rows) + df_list.append(df_subset) # type: ignore[arg-type] + + df_combined = pd.concat(df_list, axis=0, ignore_index=False) # type: ignore[arg-type] + print(f"Combined dataframe shape: {df_combined.shape}") + if not df_combined.empty: + start_ts = df_combined.index.min() + end_ts = df_combined.index.max() + print(f"Dataframe start: {start_ts}, end: {end_ts}") + else: + print("Combined dataframe is empty; no timestamps available.") + + return df_combined + + +def _filter_by_date_range( + df: pd.DataFrame, + start_date: str | pd.Timestamp | None = None, + end_date: str | pd.Timestamp | None = None, +) -> pd.DataFrame: + """Filter dataframe by date range, handling timezone-aware indices. + + Args: + df: DataFrame with DatetimeIndex. + start_date: Start date for filtering. If None, no start filtering. + end_date: End date for filtering. If None, no end filtering. + + Returns: + Filtered DataFrame. + """ + df_filtered = df.copy() + + if start_date is not None: + start_dt = pd.to_datetime(start_date) + if df_filtered.index.tz is not None and start_dt.tz is None: # type: ignore[attr-defined] + start_dt = start_dt.tz_localize("UTC") + df_filtered = df_filtered[df_filtered.index >= start_dt] + + if end_date is not None: + end_dt = pd.to_datetime(end_date) + if df_filtered.index.tz is not None and end_dt.tz is None: # type: ignore[attr-defined] + end_dt = end_dt.tz_localize("UTC") + df_filtered = df_filtered[df_filtered.index <= end_dt] + + return df_filtered + + +def _create_model_traces(df: pd.DataFrame, quantiles: list[str], default_quantile: str = "P50") -> go.Figure: + """Create Plotly traces for each model and quantile. + + Args: + df: DataFrame with model contribution columns. + quantiles: List of quantile names (e.g., ['P05', 'P50']). + default_quantile: Quantile to show by default. + + Returns: + Plotly Figure with traces for all models and quantiles. + """ + fig = go.Figure() + + for quantile in quantiles: + gblinear_col = f"gblinear_quantile_{quantile}" + lgbm_col = f"lgbm_quantile_{quantile}" + + is_visible = quantile == default_quantile + + # Add GBLinear trace + fig.add_trace( # type: ignore[call-overload] + go.Scatter( + x=df.index, + y=df[gblinear_col], + name=f"GBLinear - {quantile}", + mode="lines", + line={"width": 2, "color": "#1f77b4"}, + opacity=0.85, + visible=is_visible, + ) + ) + + # Add LGBM trace + fig.add_trace( # type: ignore[call-overload] + go.Scatter( + x=df.index, + y=df[lgbm_col], + name=f"LGBM - {quantile}", + mode="lines", + line={"width": 2, "color": "#ff7f0e"}, + opacity=0.85, + visible=is_visible, + ) + ) + + return fig + + +def _create_quantile_buttons(quantiles: list[str]) -> list[dict]: # type: ignore[type-arg] + """Create button controls for quantile selection. + + Args: + quantiles: List of quantile names. + + Returns: + List of button configuration dictionaries. + """ + buttons = [] + for i, quantile in enumerate(quantiles): + # Two traces per quantile (GBLinear and LGBM) + visible = [False] * (len(quantiles) * 2) + visible[i * 2] = True # GBLinear + visible[i * 2 + 1] = True # LGBM + + buttons.append({ # type: ignore[arg-type] + "label": quantile, + "method": "update", + "args": [ + {"visible": visible}, + {"title.text": (f"Model Contributions - Quantile {quantile}")}, + ], + }) + + return buttons # type: ignore[return-value] + + +def plot_model_contributions( + df: pd.DataFrame, + start_date: str | pd.Timestamp | None = None, + end_date: str | pd.Timestamp | None = None, + quantiles: list[str] | None = None, +) -> go.Figure: + """Plot model contributions with interactive quantile selection. + + Creates an interactive Plotly visualization comparing GBLinear and LGBM + model contributions across different quantiles, with button controls to + switch between quantiles. + + Args: + df: DataFrame with model contribution columns. Expected columns: + 'gblinear_quantile_{Q}' and 'lgbm_quantile_{Q}' for each quantile. + start_date: Start date for filtering. If None, uses all data. + end_date: End date for filtering. If None, uses all data. + quantiles: List of quantile names (e.g., ['P05', 'P50']). If None, + defaults to ['P05', 'P10', 'P30', 'P50', 'P70', 'P90', 'P95']. + + Returns: + Plotly Figure object with interactive visualization. + + Example: + >>> df = load_contribution_data(Path("path/to/contributions")) + >>> fig = plot_model_contributions( + ... df, + ... start_date='2024-03-01', + ... end_date='2024-03-31', + ... quantiles=['P50', 'P90'] + ... ) + >>> fig.show() + """ + if quantiles is None: + quantiles = ["P05", "P10", "P30", "P50", "P70", "P90", "P95"] + + # Filter data by date range + df_plot = _filter_by_date_range(df, start_date, end_date) + + # Create traces for all models and quantiles + default_quantile = "P50" if "P50" in quantiles else quantiles[0] + fig = _create_model_traces(df_plot, quantiles, default_quantile) + + # Create quantile selection buttons + buttons = _create_quantile_buttons(quantiles) # type: ignore[assignment] + active_idx = quantiles.index(default_quantile) + + # Update layout with controls and styling + fig.update_layout( # type: ignore[call-overload] + legend={ + "orientation": "v", + "x": 1.02, + "xanchor": "left", + "y": 1.0, + "yanchor": "top", + }, + updatemenus=[ + { + "type": "buttons", + "buttons": buttons, + "direction": "down", + "showactive": True, + "active": active_idx, + "x": 1.02, + "xanchor": "left", + "y": 0.5, + "yanchor": "middle", + "pad": {"r": 10, "t": 10}, + "bgcolor": "lightgray", + "bordercolor": "gray", + "borderwidth": 1, + } + ], + title=f"Model Contributions - Quantile {quantiles[active_idx]}", + xaxis_title="Timestamp", + yaxis_title="Contribution", + hovermode="x unified", + height=600, + showlegend=True, + ) + + return fig + + +def plot_combined_visualization( + df: pd.DataFrame, + start_date: str | pd.Timestamp | None = None, + end_date: str | pd.Timestamp | None = None, + quantiles: list[str] | None = None, +) -> go.Figure: + """Plot model contributions and forecast time series in subplots. + + Creates a combined visualization with two subplots: + 1. Top: Model contributions comparing GBLinear and LGBM + 2. Bottom: Forecast time series using ForecastTimeSeriesPlotter showing + ensemble forecast, measurements, and uncertainty bands + + Args: + df: DataFrame with model contribution columns, ensemble forecasts, and load. + Expected columns: 'gblinear_quantile_{Q}', 'lgbm_quantile_{Q}', + 'quantile_{Q}', and 'load'. + start_date: Start date for filtering. If None, uses all data. + end_date: End date for filtering. If None, uses all data. + quantiles: List of quantile names (e.g., ['P05', 'P50']). If None, + defaults to ['P05', 'P10', 'P30', 'P50', 'P70', 'P90', 'P95']. + + Returns: + Plotly Figure object with combined subplots. + """ + if quantiles is None: + quantiles = ["P05", "P10", "P30", "P50", "P70", "P90", "P95"] + + # Filter data by date range + df_plot = _filter_by_date_range(df, start_date, end_date) + + # Create forecast plot using ForecastTimeSeriesPlotter + quantile_cols = [f"quantile_{q}" for q in quantiles] + + # Use ForecastTimeSeriesPlotter to create the forecast visualization + forecast_plotter = ForecastTimeSeriesPlotter() # type: ignore[misc] + forecast_plotter.add_measurements(df_plot["load"]) + forecast_plotter.add_model( + model_name="Ensemble", + forecast=df_plot["quantile_P50"], + quantiles=df_plot[quantile_cols].copy(), + ) + forecast_fig = forecast_plotter.plot(title="Ensemble Forecast vs Measurements") + + # Create subplot figure with 2 rows + fig = make_subplots( + rows=2, + cols=1, + row_heights=[0.5, 0.5], + subplot_titles=("Ensemble Forecast vs Measurements", "Model Contributions"), + vertical_spacing=0.12, + shared_xaxes=True, + ) + + # Add forecast traces to top subplot (row=1) + for trace in forecast_fig.data: # type: ignore[attr-defined] + fig.add_trace(trace, row=1, col=1) # type: ignore[arg-type] + + # Count the number of forecast traces for visibility calculation + num_forecast_traces = len(forecast_fig.data) # type: ignore[arg-type] + + # Add contribution traces to bottom subplot (row=2) + default_quantile = "P50" if "P50" in quantiles else quantiles[0] + + for quantile in quantiles: + gblinear_col = f"gblinear_quantile_{quantile}" + lgbm_col = f"lgbm_quantile_{quantile}" + + is_visible = quantile == default_quantile + + # Add GBLinear trace + fig.add_trace( # type: ignore[call-overload] + go.Scatter( + x=df_plot.index, + y=df_plot[gblinear_col], + name=f"GBLinear - {quantile}", + mode="lines", + line={"width": 2, "color": "#1f77b4"}, + opacity=0.85, + visible=is_visible, + legendgroup="contributions", + ), + row=2, + col=1, + ) + + # Add LGBM trace + fig.add_trace( # type: ignore[call-overload] + go.Scatter( + x=df_plot.index, + y=df_plot[lgbm_col], + name=f"LGBM - {quantile}", + mode="lines", + line={"width": 2, "color": "#ff7f0e"}, + opacity=0.85, + visible=is_visible, + legendgroup="contributions", + ), + row=2, + col=1, + ) + + # Create quantile selection buttons (only affects contribution subplot) + buttons = [] + for i, quantile in enumerate(quantiles): + # Calculate visibility for all traces + # First set for forecast traces (always visible) + visible = [True] * num_forecast_traces + + # Add visibility for contribution traces (2 traces per quantile) + for j, _ in enumerate(quantiles): + visible.extend([j == i, j == i]) # GBLinear and LGBM visibility + + buttons.append({ # type: ignore[arg-type] + "label": quantile, + "method": "update", + "args": [ + {"visible": visible}, + {"title.text": (f"Model Contributions & Forecast - Quantile {quantile}")}, + ], + }) + + active_idx = quantiles.index(default_quantile) + + # Update layout + fig.update_layout( # type: ignore[call-overload] + title=f"Model Contributions & Forecast - Quantile {quantiles[active_idx]}", + height=1000, + hovermode="x unified", + showlegend=True, + updatemenus=[ + { + "type": "buttons", + "buttons": buttons, + "direction": "down", + "showactive": True, + "active": active_idx, + "x": 1.02, + "xanchor": "left", + "y": 0.5, + "yanchor": "middle", + "pad": {"r": 10, "t": 10}, + "bgcolor": "lightgray", + "bordercolor": "gray", + "borderwidth": 1, + } + ], + ) + + # Update axes labels + fig.update_xaxes(title_text="Timestamp", row=2, col=1) # type: ignore[call-overload] + fig.update_yaxes(title_text="Load (MW)", row=1, col=1) # type: ignore[call-overload] + fig.update_yaxes(title_text="Contribution", row=2, col=1) # type: ignore[call-overload] + + return fig + + +def main() -> None: + """Main function to demonstrate contribution visualization.""" + # Load contribution data + # Get the project root (two levels up from this file) + project_root = Path(__file__).parent.parent + folder_path = ( + project_root + / "benchmark_results" + / "cache" + / "Ensemble_contributions_lgbm_gblinear_learned_weights_lgbm_OS Apeldoorn" + ) + + df_combined = load_contribution_data(folder_path, n_rows=24) + + # Create combined visualization with contributions and forecast subplots + combined_fig = plot_combined_visualization(df_combined) + combined_output = project_root / "benchmark_results" / "model_contributions_combined_plot.html" + combined_fig.write_html(combined_output) # type: ignore[call-overload] + print(f"Combined plot saved to: {combined_output}") + + combined_fig.show() # type: ignore[call-overload] + + +if __name__ == "__main__": + main() diff --git a/examples/pyproject.toml b/examples/pyproject.toml new file mode 100644 index 000000000..a347a8b7d --- /dev/null +++ b/examples/pyproject.toml @@ -0,0 +1,28 @@ +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# +# SPDX-License-Identifier: MPL-2.0 + +[project] +name = "openstef-examples" +version = "0.0.0" +description = "Examples and tutorials for OpenSTEF" +readme = "README.md" +requires-python = ">=3.12,<4.0" +dependencies = [ + "openstef", + "openstef-beam", + "openstef-core", + "openstef-models", +] + +optional-dependencies.tutorials = [ + "huggingface-hub>=1.2.2", + "jupyter>=1.1.1", + "kaleido" +] + +[tool.uv.sources] +openstef = { workspace = true } +openstef-beam = { workspace = true } +openstef-core = { workspace = true } +openstef-models = { workspace = true } diff --git a/examples/tutorials/.gitignore b/examples/tutorials/.gitignore new file mode 100644 index 000000000..1e97dea7c --- /dev/null +++ b/examples/tutorials/.gitignore @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# +# SPDX-License-Identifier: MPL-2.0 + +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv + +liander_dataset/ \ No newline at end of file diff --git a/examples/tutorials/backtesting_openstef_with_beam.ipynb b/examples/tutorials/backtesting_openstef_with_beam.ipynb new file mode 100644 index 000000000..9ae4d54fc --- /dev/null +++ b/examples/tutorials/backtesting_openstef_with_beam.ipynb @@ -0,0 +1,460 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "caf13084", + "metadata": {}, + "source": [ + "# 📊 Backtesting OpenSTEF Models with OpenSTEF-BEAM\n", + "\n", + "This tutorial demonstrates how to use **OpenSTEF-BEAM** (Backtesting, Evaluation, Analysis, Metrics) to systematically evaluate forecasting models. You'll learn how to:\n", + "\n", + "1. **Configure benchmark experiments** with multiple model types\n", + "2. **Run parallel backtests** across dozens of energy assets\n", + "3. **Compare model performance** with standardized metrics\n", + "4. **Generate analysis reports** with interactive visualizations\n", + "\n", + "> **BEAM** provides a rigorous framework for model evaluation, ensuring fair comparisons and reproducible results." + ] + }, + { + "cell_type": "markdown", + "id": "329ce2a3", + "metadata": {}, + "source": [ + "## 🔧 Environment Setup\n", + "\n", + "First, we configure thread settings to prevent conflicts with XGBoost's internal parallelization when running multiple processes." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "24d53eb6", + "metadata": {}, + "outputs": [], + "source": [ + "# --- Thread Configuration ---\n", + "# Prevent thread contention when running parallel backtests with XGBoost\n", + "import os\n", + "os.environ[\"OMP_NUM_THREADS\"] = \"1\"\n", + "os.environ[\"OPENBLAS_NUM_THREADS\"] = \"1\"\n", + "os.environ[\"MKL_NUM_THREADS\"] = \"1\"\n", + "\n", + "# --- Standard Imports ---\n", + "import logging\n", + "import multiprocessing\n", + "from pathlib import Path\n", + "\n", + "logging.basicConfig(level=logging.INFO, format=\"[%(asctime)s][%(levelname)s] %(message)s\")" + ] + }, + { + "cell_type": "markdown", + "id": "0a2d9aed", + "metadata": {}, + "source": [ + "## ⚙️ Benchmark Configuration\n", + "\n", + "Configure the benchmark parameters:\n", + "- **Output paths** — where to store results for each model\n", + "- **Forecast horizons** — how far ahead to predict (using ISO 8601 duration format)\n", + "- **Quantiles** — prediction intervals for probabilistic evaluation" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "99c03b80", + "metadata": {}, + "outputs": [], + "source": [ + "# Import types for configuration\n", + "from openstef_core.types import LeadTime, Q # LeadTime: forecast horizon, Q: quantile\n", + "from openstef_beam.benchmarking.benchmarks.liander2024 import Liander2024Category\n", + "\n", + "# --- Output Paths ---\n", + "OUTPUT_PATH = Path(\"./benchmark_results\")\n", + "BENCHMARK_RESULTS_PATH_XGBOOST = OUTPUT_PATH / \"XGBoost\"\n", + "BENCHMARK_RESULTS_PATH_GBLINEAR = OUTPUT_PATH / \"GBLinear\"\n", + "\n", + "# --- Parallelization ---\n", + "N_PROCESSES = multiprocessing.cpu_count() # Use all available CPU cores\n", + "print(f\"🖥️ Running with {N_PROCESSES} parallel processes\")\n", + "\n", + "# --- Forecast Configuration ---\n", + "FORECAST_HORIZONS = [LeadTime.from_string(\"P3D\")] # 3-day ahead forecast (ISO 8601: P3D)\n", + "\n", + "# Quantiles for probabilistic forecasting (7 quantiles covering 5th to 95th percentile)\n", + "PREDICTION_QUANTILES = [\n", + " Q(0.05), Q(0.1), Q(0.3), # Lower quantiles\n", + " Q(0.5), # Median\n", + " Q(0.7), Q(0.9), Q(0.95), # Upper quantiles\n", + "]\n", + "\n", + "# --- Benchmark Filter (optional) ---\n", + "# Set to None to run all categories, or specify categories like:\n", + "# BENCHMARK_FILTER = [Liander2024Category.TRANSFORMER, Liander2024Category.MV_FEEDER]\n", + "BENCHMARK_FILTER: list[Liander2024Category] | None = None" + ] + }, + { + "cell_type": "markdown", + "id": "a3618966", + "metadata": {}, + "source": [ + "## 🛠️ Model Configuration\n", + "\n", + "We define a **common configuration** that both models share, then create model-specific variants. This ensures fair comparison by keeping all settings identical except the model type.\n", + "\n", + "### Available Models:\n", + "- **XGBoost** — Gradient boosting trees (handles complex nonlinear patterns)\n", + "- **GBLinear** — Gradient boosted linear model (better extrapolation, faster)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3a39b756", + "metadata": {}, + "outputs": [], + "source": [ + "# Import workflow configuration\n", + "from openstef_models.presets import ForecastingWorkflowConfig\n", + "\n", + "# Common configuration shared by all models\n", + "# This ensures fair comparison by keeping all settings identical\n", + "common_config = ForecastingWorkflowConfig(\n", + " model_id=\"benchmark_model_\",\n", + " run_name=None,\n", + " model=\"flatliner\", # Placeholder - will be overwritten per model\n", + " \n", + " # Forecast settings\n", + " horizons=FORECAST_HORIZONS,\n", + " quantiles=PREDICTION_QUANTILES,\n", + " \n", + " # Model reuse: reuse trained model for same target (speeds up backtesting)\n", + " model_reuse_enable=True,\n", + " mlflow_storage=None, # Disable MLflow for this demo\n", + " \n", + " # Weather feature column mappings (match dataset column names)\n", + " radiation_column=\"shortwave_radiation\",\n", + " wind_speed_column=\"wind_speed_80m\", # 80m wind speed for better wind park predictions\n", + " pressure_column=\"surface_pressure\",\n", + " temperature_column=\"temperature_2m\",\n", + " relative_humidity_column=\"relative_humidity_2m\",\n", + " \n", + " # Additional features\n", + " energy_price_column=\"EPEX_NL\", # Day-ahead electricity price\n", + " rolling_aggregate_features=[\"mean\", \"median\", \"max\", \"min\"], # Rolling window stats\n", + " \n", + " # Logging\n", + " verbosity=0, # Quiet mode for batch processing\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ed202922", + "metadata": {}, + "outputs": [], + "source": [ + "# Create model-specific configurations by copying common config and updating model type\n", + "xgboost_config = common_config.model_copy(update={\"model\": \"xgboost\"})\n", + "gblinear_config = common_config.model_copy(update={\"model\": \"gblinear\"})\n", + "\n", + "print(\"✅ Model configurations created:\")\n", + "print(f\" - XGBoost: {xgboost_config.model}\")\n", + "print(f\" - GBLinear: {gblinear_config.model}\")" + ] + }, + { + "cell_type": "markdown", + "id": "4425a740", + "metadata": {}, + "source": [ + "## 💾 Storage Configuration\n", + "\n", + "**LocalBenchmarkStorage** manages the file structure for benchmark results:\n", + "```\n", + "benchmark_results/\n", + "├── XGBoost/\n", + "│ ├── backtest/ # Raw predictions\n", + "│ ├── evaluation/ # Metrics per target\n", + "│ └── analysis/ # Visualizations (HTML)\n", + "└── GBLinear/\n", + " └── ...\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c2e44656", + "metadata": {}, + "outputs": [], + "source": [ + "# Initialize storage backends for each model\n", + "from openstef_beam.benchmarking.storage.local_storage import LocalBenchmarkStorage\n", + "\n", + "storage_xgboost = LocalBenchmarkStorage(base_path=BENCHMARK_RESULTS_PATH_XGBOOST)\n", + "storage_gblinear = LocalBenchmarkStorage(base_path=BENCHMARK_RESULTS_PATH_GBLINEAR)\n", + "\n", + "print(f\"📁 XGBoost results: {BENCHMARK_RESULTS_PATH_XGBOOST}\")\n", + "print(f\"📁 GBLinear results: {BENCHMARK_RESULTS_PATH_GBLINEAR}\")" + ] + }, + { + "cell_type": "markdown", + "id": "41e6b2e3", + "metadata": {}, + "source": [ + "## 🚀 Run Backtests\n", + "\n", + "Now we run the **Liander 2024 Benchmark** — a comprehensive evaluation suite that:\n", + "1. Downloads the benchmark dataset from HuggingFace Hub (if needed)\n", + "2. Runs backtests across 5 asset categories (transformers, feeders, solar/wind parks)\n", + "3. Computes metrics and generates analysis visualizations\n", + "\n", + "⚠️ **Note**: This may take several minutes depending on your hardware." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d6aae871", + "metadata": {}, + "outputs": [], + "source": [ + "# Import benchmark components\n", + "from openstef_beam.benchmarking.benchmarks.liander2024 import create_liander2024_benchmark_runner\n", + "from openstef_beam.benchmarking.callbacks.strict_execution_callback import StrictExecutionCallback\n", + "from openstef_beam.benchmarking.baselines import create_openstef4_preset_backtest_forecaster\n", + "\n", + "# --- Run XGBoost Benchmark ---\n", + "print(\"🌲 Running XGBoost benchmark...\")\n", + "create_liander2024_benchmark_runner(\n", + " storage=storage_xgboost,\n", + " callbacks=[StrictExecutionCallback()], # Fail fast on errors\n", + ").run(\n", + " forecaster_factory=create_openstef4_preset_backtest_forecaster(\n", + " workflow_config=xgboost_config,\n", + " ),\n", + " run_name=\"xgboost\",\n", + " n_processes=N_PROCESSES,\n", + " filter_args=BENCHMARK_FILTER,\n", + ")\n", + "print(\"✅ XGBoost benchmark complete!\")\n", + "\n", + "# --- Run GBLinear Benchmark ---\n", + "print(\"\\n📈 Running GBLinear benchmark...\")\n", + "create_liander2024_benchmark_runner(\n", + " storage=storage_gblinear,\n", + " callbacks=[StrictExecutionCallback()],\n", + ").run(\n", + " forecaster_factory=create_openstef4_preset_backtest_forecaster(\n", + " workflow_config=gblinear_config,\n", + " ),\n", + " run_name=\"gblinear\",\n", + " n_processes=N_PROCESSES,\n", + " filter_args=BENCHMARK_FILTER,\n", + ")\n", + "print(\"✅ GBLinear benchmark complete!\")" + ] + }, + { + "cell_type": "markdown", + "id": "d1690a07", + "metadata": {}, + "source": [ + "## 📊 Compare Model Performance\n", + "\n", + "The **BenchmarkComparisonPipeline** generates side-by-side analysis of multiple models:\n", + "- Global metrics across all targets\n", + "- Per-category breakdowns (transformers, feeders, etc.)\n", + "- Time-windowed performance analysis" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0a6bdfcf", + "metadata": {}, + "outputs": [], + "source": [ + "# Run model comparison analysis\n", + "from openstef_beam.benchmarking import BenchmarkComparisonPipeline\n", + "from openstef_beam.benchmarking.benchmarks.liander2024 import LIANDER2024_ANALYSIS_CONFIG\n", + "\n", + "# Create comparison pipeline\n", + "target_provider = create_liander2024_benchmark_runner(\n", + " storage=LocalBenchmarkStorage(base_path=OUTPUT_PATH),\n", + ").target_provider\n", + "\n", + "comparison_pipeline = BenchmarkComparisonPipeline(\n", + " analysis_config=LIANDER2024_ANALYSIS_CONFIG,\n", + " storage=LocalBenchmarkStorage(base_path=OUTPUT_PATH),\n", + " target_provider=target_provider,\n", + ")\n", + "\n", + "# Generate comparison reports\n", + "print(\"📊 Generating comparison analysis...\")\n", + "comparison_pipeline.run(run_data={\n", + " \"xgboost\": storage_xgboost,\n", + " \"gblinear\": storage_gblinear,\n", + "})\n", + "print(\"✅ Comparison analysis complete!\")" + ] + }, + { + "cell_type": "markdown", + "id": "c22c61f4", + "metadata": {}, + "source": [ + "## 📈 View Analysis Results\n", + "\n", + "The benchmark generates interactive HTML visualizations. Let's open the most important ones:\n", + "\n", + "### Key Metrics:\n", + "- **rCRPS** (relative Continuous Ranked Probability Score) — measures probabilistic forecast accuracy\n", + "- **rMAE** (relative Mean Absolute Error) — measures point forecast accuracy\n", + "- Lower values = better performance" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "af09be7e", + "metadata": {}, + "outputs": [], + "source": [ + "# Open key analysis plots in browser\n", + "# HTML visualizations are interactive and best viewed in a browser\n", + "import webbrowser\n", + "import os\n", + "\n", + "# Base path for analysis results\n", + "analysis_base = os.path.abspath('./benchmark_results/analysis/D-1T06:00')\n", + "\n", + "# Define key visualizations to open\n", + "visualizations = [\n", + " (\"rCRPS Grouped by Category\", \"rCRPS_grouped.html\"),\n", + " (\"rCRPS Time-Windowed (7 days)\", \"rCRPS_windowed_7D.html\"),\n", + "]\n", + "\n", + "print(\"🌐 Opening analysis visualizations in browser...\\n\")\n", + "for name, filename in visualizations:\n", + " filepath = os.path.join(analysis_base, filename)\n", + " if os.path.exists(filepath):\n", + " print(f\" 📊 {name}\")\n", + " webbrowser.open(f'file://{filepath}')\n", + " else:\n", + " print(f\" ⚠️ {name} not found at {filepath}\")" + ] + }, + { + "cell_type": "markdown", + "id": "59e8d779", + "metadata": {}, + "source": [ + "### 🔍 Explore Individual Target Results\n", + "\n", + "You can also view time series plots for individual targets. Let's look at a transformer forecast:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ea2fd469", + "metadata": {}, + "outputs": [], + "source": [ + "# List available target-specific visualizations\n", + "import glob\n", + "\n", + "# Find all time series plots for individual targets\n", + "target_plots = glob.glob('./benchmark_results/XGBoost/analysis/*/*/time_series_plot*.html')\n", + "\n", + "if target_plots:\n", + " print(\"📊 Available target-specific time series plots:\\n\")\n", + " for i, plot in enumerate(sorted(target_plots)[:5]): # Show first 5\n", + " parts = plot.split('/')\n", + " category = parts[-3] # e.g., \"transformer\"\n", + " target = parts[-2] # e.g., \"OS Apeldoorn\"\n", + " print(f\" {i+1}. {category}/{target}\")\n", + " \n", + " # Open the first transformer plot as an example\n", + " transformer_plots = [p for p in target_plots if 'transformer' in p]\n", + " if transformer_plots:\n", + " example_plot = os.path.abspath(transformer_plots[0])\n", + " print(f\"\\n🌐 Opening example: {transformer_plots[0]}\")\n", + " webbrowser.open(f'file://{example_plot}')\n", + "else:\n", + " print(\"⚠️ No target-specific plots found. Run the benchmark first.\")" + ] + }, + { + "cell_type": "markdown", + "id": "e41df479", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 Summary\n", + "\n", + "In this tutorial, you learned how to:\n", + "\n", + "1. ✅ **Configure benchmark experiments** with `ForecastingWorkflowConfig`\n", + "2. ✅ **Run parallel backtests** using the Liander 2024 benchmark\n", + "3. ✅ **Compare models** (XGBoost vs GBLinear) with `BenchmarkComparisonPipeline`\n", + "4. ✅ **Analyze results** with interactive HTML visualizations\n", + "\n", + "### 📁 Output Structure\n", + "\n", + "```\n", + "benchmark_results/\n", + "├── XGBoost/\n", + "│ ├── backtest/ # Raw predictions (parquet)\n", + "│ ├── evaluation/ # Metrics per target\n", + "│ └── analysis/ # HTML visualizations\n", + "├── GBLinear/\n", + "│ └── ...\n", + "└── analysis/ # Comparison analysis (both models)\n", + " └── D-1T06:00/\n", + " ├── rCRPS_grouped.html # Probabilistic accuracy by category\n", + " ├── rMAE_grouped.html # Point forecast accuracy\n", + " └── summary.html # Overall summary\n", + "```\n", + "\n", + "### 🚀 Next Steps\n", + "\n", + "- Experiment with different `FORECAST_HORIZONS` (e.g., `\"PT6H\"`, `\"P7D\"`)\n", + "- Add more quantiles for higher resolution prediction intervals\n", + "- Filter specific categories with `BENCHMARK_FILTER`\n", + "- Integrate MLflow for experiment tracking" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.5" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/tutorials/forecasting_with_workflow_presets.ipynb b/examples/tutorials/forecasting_with_workflow_presets.ipynb new file mode 100644 index 000000000..1a7c298a4 --- /dev/null +++ b/examples/tutorials/forecasting_with_workflow_presets.ipynb @@ -0,0 +1,972 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "65fbd6d9", + "metadata": {}, + "source": [ + "# 🔮 Forecasting with OpenSTEF 4.0 Workflow Presets\n", + "\n", + "This tutorial demonstrates how to use **OpenSTEF 4.0** to create energy load forecasts using the **Workflow Presets** pattern. You'll learn how to:\n", + "\n", + "1. **Load real-world energy data** from the Liander 2024 benchmark dataset\n", + "2. **Configure a forecasting workflow** with weather features and prediction quantiles\n", + "3. **Train a model** and inspect its performance\n", + "4. **Generate probabilistic forecasts** with confidence intervals\n", + "5. **Visualize results** and explain feature importance\n", + "\n", + "> **OpenSTEF** (Short-Term Energy Forecasting) is a modular library for creating accurate energy forecasts in the power grid domain." + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "c8a83428", + "metadata": {}, + "outputs": [], + "source": [ + "# --- Setup: Logging and Display Configuration ---\n", + "# Configure logging to see training progress and plotly to render as PNG for VS Code compatibility\n", + "import logging\n", + "import pandas as pd\n", + "import plotly.io as pio\n", + "\n", + "pd.options.plotting.backend = \"plotly\"\n", + "pio.renderers.default = \"png\" # Use PNG for VS Code notebook compatibility\n", + "\n", + "logging.basicConfig(level=logging.INFO, format=\"[%(asctime)s][%(levelname)s] %(message)s\")\n", + "logger = logging.getLogger(__name__)\n", + "logging.getLogger(\"choreographer\").setLevel(logging.ERROR)\n", + "logging.getLogger(\"kaleido\").setLevel(logging.ERROR)\n", + "logging.getLogger(\"choreographer\").disabled = True\n", + "logging.getLogger(\"kaleido\").disabled = True" + ] + }, + { + "cell_type": "markdown", + "id": "f2afee2d", + "metadata": {}, + "source": [ + "## 📦 Step 1: Download the Dataset\n", + "\n", + "We'll use the **Liander 2024 Energy Forecasting Benchmark** dataset from HuggingFace Hub. This dataset contains:\n", + "- **Load measurements** — historical energy consumption from various installations (mv feeders, transformers, etc.)\n", + "- **Weather forecasts** — versioned weather predictions (temperature, radiation, wind, etc.)\n", + "- **EPEX prices** — day-ahead electricity market prices\n", + "- **Profiles** — typical daily/weekly load patterns" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ead642f4", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/Users/egor.dmitriev/projects/openstef/openstef4/.venv/lib/python3.13/site-packages/huggingface_hub/utils/_validators.py:202: UserWarning:\n", + "\n", + "The `local_dir_use_symlinks` argument is deprecated and ignored in `hf_hub_download`. Downloading to a local directory does not use symlinks anymore.\n", + "\n", + "[2025-12-12 14:12:32,556][INFO] HTTP Request: HEAD https://huggingface.co/datasets/OpenSTEF/liander2024-energy-forecasting-benchmark/resolve/main/load_measurements/mv_feeder/OS%20Gorredijk.parquet \"HTTP/1.1 302 Found\"\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Downloading load_measurements/mv_feeder/OS Gorredijk.parquet...\n", + "✓ load_measurements/mv_feeder/OS Gorredijk.parquet downloaded\n", + "Downloading weather_forecasts_versioned/mv_feeder/OS Gorredijk.parquet...\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "[2025-12-12 14:12:32,672][INFO] HTTP Request: HEAD https://huggingface.co/datasets/OpenSTEF/liander2024-energy-forecasting-benchmark/resolve/main/weather_forecasts_versioned/mv_feeder/OS%20Gorredijk.parquet \"HTTP/1.1 302 Found\"\n", + "[2025-12-12 14:12:32,814][INFO] HTTP Request: HEAD https://huggingface.co/datasets/OpenSTEF/liander2024-energy-forecasting-benchmark/resolve/main/EPEX.parquet \"HTTP/1.1 302 Found\"\n", + "Warning: You are sending unauthenticated requests to the HF Hub. Please set a HF_TOKEN to enable higher rate limits and faster downloads.\n", + "[2025-12-12 14:12:32,815][WARNING] Warning: You are sending unauthenticated requests to the HF Hub. Please set a HF_TOKEN to enable higher rate limits and faster downloads.\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "✓ weather_forecasts_versioned/mv_feeder/OS Gorredijk.parquet downloaded\n", + "Downloading EPEX.parquet...\n", + "✓ EPEX.parquet downloaded\n", + "Downloading profiles.parquet...\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "[2025-12-12 14:12:32,928][INFO] HTTP Request: HEAD https://huggingface.co/datasets/OpenSTEF/liander2024-energy-forecasting-benchmark/resolve/main/profiles.parquet \"HTTP/1.1 302 Found\"\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "✓ profiles.parquet downloaded\n", + "\n", + "✅ All files downloaded successfully!\n" + ] + } + ], + "source": [ + "# Download dataset from HuggingFace Hub\n", + "# The dataset is stored as parquet files for efficient loading\n", + "from huggingface_hub import hf_hub_download\n", + "from openstef_core.base_model import Path\n", + "\n", + "repo_id = \"OpenSTEF/liander2024-energy-forecasting-benchmark\" # Public benchmark dataset\n", + "local_dir = Path(\"./liander_dataset\")\n", + "target = \"mv_feeder/OS Gorredijk\" # Specific installation to focus on\n", + "\n", + "# Download required files: load measurements, weather, prices, and profiles\n", + "files_to_download = [\n", + " \"load_measurements/mv_feeder/OS Gorredijk.parquet\", # Energy consumption data\n", + " \"weather_forecasts_versioned/mv_feeder/OS Gorredijk.parquet\", # Weather features\n", + " \"EPEX.parquet\", # Electricity prices (optional feature)\n", + " \"profiles.parquet\" # Standard load profiles (optional feature)\n", + "]\n", + "\n", + "for filename in files_to_download:\n", + " print(f\"Downloading {filename}...\")\n", + " hf_hub_download(repo_id=repo_id, filename=filename, repo_type=\"dataset\",\n", + " local_dir=local_dir, local_dir_use_symlinks=False)\n", + " print(f\"✓ {filename} downloaded\")\n", + "\n", + "print(\"\\n✅ All files downloaded successfully!\")" + ] + }, + { + "cell_type": "markdown", + "id": "81d12312", + "metadata": {}, + "source": [ + "## 📊 Step 2: Load and Prepare the Data\n", + "\n", + "OpenSTEF uses **VersionedTimeSeriesDataset** — a specialized data structure that handles:\n", + "- **Time versioning** — tracks when data became available (crucial for realistic backtesting)\n", + "- **Lazy composition** — efficiently combines datasets without O(n²) memory overhead\n", + "- **Temporal alignment** — ensures all features are properly aligned by timestamp" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a5522df3", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "[2025-12-12 14:12:32,950][WARNING] Parquet file does not contain 'sample_interval' attribute. Using default value of 15 minutes.\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "[2025-12-12 14:12:32,987][WARNING] Parquet file does not contain 'sample_interval' attribute. Using default value of 15 minutes.\n", + "[2025-12-12 14:12:33,038][WARNING] Parquet file does not contain 'sample_interval' attribute. Using default value of 15 minutes.\n", + "[2025-12-12 14:12:33,047][WARNING] Parquet file does not contain 'sample_interval' attribute. Using default value of 15 minutes.\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Dataset shape: (35136, 28)\n", + "Date range: 2024-01-01 00:00:00+00:00 to 2024-12-31 23:45:00+00:00\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
loadtemperature_2mrelative_humidity_2msurface_pressurecloud_coverwind_speed_10mwind_speed_80mwind_direction_10mshortwave_radiationdirect_radiation...E1C_AMI_AE2A_AZI_AE2A_AMI_AE2B_AZI_AE2B_AMI_AE3A_AE3B_AE3C_AE3D_AE4A_A
timestamp
2024-01-01 00:00:00+00:00423333.3333337.24350085.025322994.236450100.028.18595343.832863204.9284520.00.0...0.0000640.0000240.0000340.0000530.0000640.0000580.0000580.0000580.0000580.000079
2024-01-01 00:15:00+00:00436666.6666677.28100084.808533994.186523100.028.75338044.976219206.9310150.00.0...0.0000610.0000240.0000340.0000520.0000630.0000580.0000580.0000580.0000580.000079
2024-01-01 00:30:00+00:00410000.0000007.31850184.591743994.136597100.029.32080746.119572208.9335630.00.0...0.0000600.0000230.0000330.0000510.0000630.0000580.0000580.0000580.0000580.000079
2024-01-01 00:45:00+00:00403333.3333337.35600084.374954994.086731100.029.88823347.262924210.9361270.00.0...0.0000570.0000230.0000320.0000510.0000610.0000590.0000590.0000590.0000590.000079
2024-01-01 01:00:00+00:00420000.0000007.39350084.158165994.036804100.030.45566048.406281212.9386900.00.0...0.0000550.0000240.0000320.0000520.0000600.0000570.0000570.0000570.0000570.000079
\n", + "

5 rows × 28 columns

\n", + "
" + ], + "text/plain": [ + " load temperature_2m \\\n", + "timestamp \n", + "2024-01-01 00:00:00+00:00 423333.333333 7.243500 \n", + "2024-01-01 00:15:00+00:00 436666.666667 7.281000 \n", + "2024-01-01 00:30:00+00:00 410000.000000 7.318501 \n", + "2024-01-01 00:45:00+00:00 403333.333333 7.356000 \n", + "2024-01-01 01:00:00+00:00 420000.000000 7.393500 \n", + "\n", + " relative_humidity_2m surface_pressure \\\n", + "timestamp \n", + "2024-01-01 00:00:00+00:00 85.025322 994.236450 \n", + "2024-01-01 00:15:00+00:00 84.808533 994.186523 \n", + "2024-01-01 00:30:00+00:00 84.591743 994.136597 \n", + "2024-01-01 00:45:00+00:00 84.374954 994.086731 \n", + "2024-01-01 01:00:00+00:00 84.158165 994.036804 \n", + "\n", + " cloud_cover wind_speed_10m wind_speed_80m \\\n", + "timestamp \n", + "2024-01-01 00:00:00+00:00 100.0 28.185953 43.832863 \n", + "2024-01-01 00:15:00+00:00 100.0 28.753380 44.976219 \n", + "2024-01-01 00:30:00+00:00 100.0 29.320807 46.119572 \n", + "2024-01-01 00:45:00+00:00 100.0 29.888233 47.262924 \n", + "2024-01-01 01:00:00+00:00 100.0 30.455660 48.406281 \n", + "\n", + " wind_direction_10m shortwave_radiation \\\n", + "timestamp \n", + "2024-01-01 00:00:00+00:00 204.928452 0.0 \n", + "2024-01-01 00:15:00+00:00 206.931015 0.0 \n", + "2024-01-01 00:30:00+00:00 208.933563 0.0 \n", + "2024-01-01 00:45:00+00:00 210.936127 0.0 \n", + "2024-01-01 01:00:00+00:00 212.938690 0.0 \n", + "\n", + " direct_radiation ... E1C_AMI_A E2A_AZI_A \\\n", + "timestamp ... \n", + "2024-01-01 00:00:00+00:00 0.0 ... 0.000064 0.000024 \n", + "2024-01-01 00:15:00+00:00 0.0 ... 0.000061 0.000024 \n", + "2024-01-01 00:30:00+00:00 0.0 ... 0.000060 0.000023 \n", + "2024-01-01 00:45:00+00:00 0.0 ... 0.000057 0.000023 \n", + "2024-01-01 01:00:00+00:00 0.0 ... 0.000055 0.000024 \n", + "\n", + " E2A_AMI_A E2B_AZI_A E2B_AMI_A E3A_A \\\n", + "timestamp \n", + "2024-01-01 00:00:00+00:00 0.000034 0.000053 0.000064 0.000058 \n", + "2024-01-01 00:15:00+00:00 0.000034 0.000052 0.000063 0.000058 \n", + "2024-01-01 00:30:00+00:00 0.000033 0.000051 0.000063 0.000058 \n", + "2024-01-01 00:45:00+00:00 0.000032 0.000051 0.000061 0.000059 \n", + "2024-01-01 01:00:00+00:00 0.000032 0.000052 0.000060 0.000057 \n", + "\n", + " E3B_A E3C_A E3D_A E4A_A \n", + "timestamp \n", + "2024-01-01 00:00:00+00:00 0.000058 0.000058 0.000058 0.000079 \n", + "2024-01-01 00:15:00+00:00 0.000058 0.000058 0.000058 0.000079 \n", + "2024-01-01 00:30:00+00:00 0.000058 0.000058 0.000058 0.000079 \n", + "2024-01-01 00:45:00+00:00 0.000059 0.000059 0.000059 0.000079 \n", + "2024-01-01 01:00:00+00:00 0.000057 0.000057 0.000057 0.000079 \n", + "\n", + "[5 rows x 28 columns]" + ] + }, + "execution_count": 13, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Load datasets using OpenSTEF's VersionedTimeSeriesDataset\n", + "# This class handles versioned data where each value has an \"available_at\" timestamp\n", + "from openstef_core.datasets import VersionedTimeSeriesDataset\n", + "\n", + "# Load each data source from parquet files\n", + "load_dataset = VersionedTimeSeriesDataset.read_parquet(\n", + " local_dir / \"load_measurements/mv_feeder/OS Gorredijk.parquet\"\n", + ")\n", + "weather_dataset = VersionedTimeSeriesDataset.read_parquet(\n", + " local_dir / \"weather_forecasts_versioned/mv_feeder/OS Gorredijk.parquet\"\n", + ")\n", + "epex_dataset = VersionedTimeSeriesDataset.read_parquet(local_dir / \"EPEX.parquet\")\n", + "profiles_dataset = VersionedTimeSeriesDataset.read_parquet(local_dir / \"profiles.parquet\")\n", + "\n", + "# Combine all datasets using left join (keep all load timestamps, match features where available)\n", + "# select_version() materializes the lazy dataset into a concrete TimeSeriesDataset\n", + "dataset = VersionedTimeSeriesDataset.concat(\n", + " [load_dataset, weather_dataset, epex_dataset, profiles_dataset], \n", + " mode=\"left\" # Left join keeps all timestamps from the first dataset\n", + ").select_version()\n", + "\n", + "# Preview the combined dataset\n", + "print(f\"Dataset shape: {dataset.data.shape}\")\n", + "print(f\"Date range: {dataset.data.index.min()} to {dataset.data.index.max()}\")\n", + "dataset.data.head()" + ] + }, + { + "cell_type": "markdown", + "id": "675e594e", + "metadata": {}, + "source": [ + "## ✂️ Step 3: Split Data into Training and Forecast Periods\n", + "\n", + "We'll use:\n", + "- **90 days** of historical data for training\n", + "- **14 days** as the forecast period (where we'll generate predictions)" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "844ac4a2", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "📈 Training period: 2024-03-01 to 2024-05-30 (8640 samples)\n", + "🔮 Forecast period: 2024-05-30 to 2024-06-13 (1344 samples)\n" + ] + } + ], + "source": [ + "# Define training and forecast time periods\n", + "from datetime import datetime, timedelta\n", + "\n", + "# Training period: 90 days of historical data\n", + "train_start = datetime.fromisoformat(\"2024-03-01T00:00:00Z\")\n", + "train_end = train_start + timedelta(days=90)\n", + "\n", + "# Forecast period: 14 days after training (this is where we'll predict)\n", + "forecast_start = train_end\n", + "forecast_end = forecast_start + timedelta(days=14)\n", + "\n", + "# Split the dataset using time-based filtering\n", + "train_dataset = dataset.filter_by_range(start=train_start, end=train_end)\n", + "forecast_dataset = dataset.filter_by_range(start=forecast_start, end=forecast_end)\n", + "\n", + "print(f\"📈 Training period: {train_start.date()} to {train_end.date()} ({len(train_dataset.data)} samples)\")\n", + "print(f\"🔮 Forecast period: {forecast_start.date()} to {forecast_end.date()} ({len(forecast_dataset.data)} samples)\")" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "07c5b563", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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" + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# Visualize the training data\n", + "# The plot shows the 'load' column (energy consumption in MW) over time\n", + "fig = train_dataset.data[[\"load\"]].plot(title=\"Training Data: Energy Load over Time\")\n", + "fig.update_layout(yaxis_title=\"Load (MW)\", xaxis_title=\"Time\")\n", + "fig.show()" + ] + }, + { + "cell_type": "markdown", + "id": "6c13fe2b", + "metadata": {}, + "source": [ + "## ⚙️ Step 4: Configure the Forecasting Workflow\n", + "\n", + "OpenSTEF uses a **ForecastingWorkflowConfig** to define all aspects of the forecasting pipeline:\n", + "- **Model type** — `gblinear` (gradient boosted linear model) or `xgboost`\n", + "- **Forecast horizons** — how far ahead to predict (e.g., 36 hours)\n", + "- **Quantiles** — prediction intervals for probabilistic forecasts\n", + "- **Feature columns** — which weather variables to use\n", + "\n", + "The **GBLinear** model is particularly good for energy forecasting because:\n", + "1. It can extrapolate beyond training data (important for rare events)\n", + "2. It provides interpretable feature importance\n", + "3. It's fast to train and predict" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "1b5c88dc", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "✅ Workflow configured successfully!\n" + ] + } + ], + "source": [ + "# Import workflow components\n", + "from openstef_core.types import LeadTime, Q # LeadTime: forecast horizon, Q: quantile\n", + "from openstef_models.presets import ForecastingWorkflowConfig, create_forecasting_workflow\n", + "from openstef_models.presets.forecasting_workflow import GBLinearForecaster\n", + "\n", + "# Configure the forecasting workflow\n", + "workflow = create_forecasting_workflow(\n", + " config=ForecastingWorkflowConfig(\n", + " # Model identification\n", + " model_id=\"gblinear_demo_v1\",\n", + " model=\"gblinear\", # Use gradient boosted linear model\n", + " \n", + " # Forecast settings\n", + " horizons=[LeadTime.from_string(\"PT36H\")], # Predict up to 36 hours ahead\n", + " quantiles=[Q(0.5), Q(0.1), Q(0.9)], # Median + 80% prediction interval\n", + " \n", + " # Target column (what we're predicting)\n", + " target_column=\"load\",\n", + " \n", + " # Weather feature columns (from the dataset)\n", + " temperature_column=\"temperature_2m\",\n", + " relative_humidity_column=\"relative_humidity_2m\",\n", + " wind_speed_column=\"wind_speed_10m\",\n", + " radiation_column=\"shortwave_radiation\", # Solar radiation\n", + " pressure_column=\"surface_pressure\",\n", + " \n", + " # Training settings\n", + " verbosity=1, # Show progress during training\n", + " mlflow_storage=None, # Disable MLflow tracking for this demo\n", + " \n", + " # Model-specific hyperparameters\n", + " gblinear_hyperparams=GBLinearForecaster.HyperParams(\n", + " n_steps=50 # Number of boosting iterations\n", + " )\n", + " )\n", + ")\n", + "\n", + "print(\"✅ Workflow configured successfully!\")" + ] + }, + { + "cell_type": "markdown", + "id": "8915b7ec", + "metadata": {}, + "source": [ + "## 🏋️ Step 5: Train the Model\n", + "\n", + "The workflow's `fit()` method handles the entire training pipeline:\n", + "1. **Preprocessing** — feature engineering, data validation, scaling\n", + "2. **Training** — fit the model on historical data\n", + "3. **Evaluation** — compute metrics on training data" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "0b941aec", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "[2025-12-12 14:12:35,855][INFO] 🏋️ Starting model training...\n", + "[2025-12-12 14:12:35,992][WARNING] No aggregation functions specified for RollingAggregatesAdder. Returning original data.\n", + "[2025-12-12 14:12:36,109][WARNING] No aggregation functions specified for RollingAggregatesAdder. Returning original data.\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[0]\tvalidation_0-mean_pinball_loss:0.34619\tvalidation_1-mean_pinball_loss:0.36512\n", + "[1]\tvalidation_0-mean_pinball_loss:0.28327\tvalidation_1-mean_pinball_loss:0.28960\n", + "[2]\tvalidation_0-mean_pinball_loss:0.23968\tvalidation_1-mean_pinball_loss:0.24857\n", + "[3]\tvalidation_0-mean_pinball_loss:0.20084\tvalidation_1-mean_pinball_loss:0.21070\n", + "[4]\tvalidation_0-mean_pinball_loss:0.16668\tvalidation_1-mean_pinball_loss:0.17744\n", + "[5]\tvalidation_0-mean_pinball_loss:0.14015\tvalidation_1-mean_pinball_loss:0.15102\n", + "[6]\tvalidation_0-mean_pinball_loss:0.12257\tvalidation_1-mean_pinball_loss:0.13536\n", + "[7]\tvalidation_0-mean_pinball_loss:0.11089\tvalidation_1-mean_pinball_loss:0.12362\n", + "[8]\tvalidation_0-mean_pinball_loss:0.10177\tvalidation_1-mean_pinball_loss:0.11396\n", + "[9]\tvalidation_0-mean_pinball_loss:0.09542\tvalidation_1-mean_pinball_loss:0.10819\n", + "[10]\tvalidation_0-mean_pinball_loss:0.09122\tvalidation_1-mean_pinball_loss:0.10361\n", + "[11]\tvalidation_0-mean_pinball_loss:0.08817\tvalidation_1-mean_pinball_loss:0.10134\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/Users/egor.dmitriev/projects/openstef/openstef4/.venv/lib/python3.13/site-packages/sklearn/utils/validation.py:2749: UserWarning:\n", + "\n", + "X does not have valid feature names, but StandardScaler was fitted with feature names\n", + "\n", + "/Users/egor.dmitriev/projects/openstef/openstef4/.venv/lib/python3.13/site-packages/sklearn/utils/validation.py:2749: UserWarning:\n", + "\n", + "X does not have valid feature names, but StandardScaler was fitted with feature names\n", + "\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[12]\tvalidation_0-mean_pinball_loss:0.08606\tvalidation_1-mean_pinball_loss:0.09880\n", + "[13]\tvalidation_0-mean_pinball_loss:0.08434\tvalidation_1-mean_pinball_loss:0.09764\n", + "[14]\tvalidation_0-mean_pinball_loss:0.08324\tvalidation_1-mean_pinball_loss:0.09593\n", + "[15]\tvalidation_0-mean_pinball_loss:0.08256\tvalidation_1-mean_pinball_loss:0.09539\n", + "[16]\tvalidation_0-mean_pinball_loss:0.08257\tvalidation_1-mean_pinball_loss:0.09481\n", + "[17]\tvalidation_0-mean_pinball_loss:0.08251\tvalidation_1-mean_pinball_loss:0.09515\n", + "[18]\tvalidation_0-mean_pinball_loss:0.08331\tvalidation_1-mean_pinball_loss:0.09463\n", + "[19]\tvalidation_0-mean_pinball_loss:0.08284\tvalidation_1-mean_pinball_loss:0.09461\n", + "[20]\tvalidation_0-mean_pinball_loss:0.08272\tvalidation_1-mean_pinball_loss:0.09353\n", + "[21]\tvalidation_0-mean_pinball_loss:0.08310\tvalidation_1-mean_pinball_loss:0.09463\n", + "[22]\tvalidation_0-mean_pinball_loss:0.08209\tvalidation_1-mean_pinball_loss:0.09301\n", + "[23]\tvalidation_0-mean_pinball_loss:0.08167\tvalidation_1-mean_pinball_loss:0.09290\n", + "[24]\tvalidation_0-mean_pinball_loss:0.08122\tvalidation_1-mean_pinball_loss:0.09183\n", + "[25]\tvalidation_0-mean_pinball_loss:0.08246\tvalidation_1-mean_pinball_loss:0.09343\n", + "[26]\tvalidation_0-mean_pinball_loss:0.07978\tvalidation_1-mean_pinball_loss:0.09027\n", + "[27]\tvalidation_0-mean_pinball_loss:0.08002\tvalidation_1-mean_pinball_loss:0.09094\n", + "[28]\tvalidation_0-mean_pinball_loss:0.08027\tvalidation_1-mean_pinball_loss:0.09084\n", + "[29]\tvalidation_0-mean_pinball_loss:0.08078\tvalidation_1-mean_pinball_loss:0.09126\n", + "[30]\tvalidation_0-mean_pinball_loss:0.07913\tvalidation_1-mean_pinball_loss:0.08980\n", + "[31]\tvalidation_0-mean_pinball_loss:0.07941\tvalidation_1-mean_pinball_loss:0.08994\n", + "[32]\tvalidation_0-mean_pinball_loss:0.07874\tvalidation_1-mean_pinball_loss:0.08926\n", + "[33]\tvalidation_0-mean_pinball_loss:0.07926\tvalidation_1-mean_pinball_loss:0.09018\n", + "[34]\tvalidation_0-mean_pinball_loss:0.08022\tvalidation_1-mean_pinball_loss:0.09024\n", + "[35]\tvalidation_0-mean_pinball_loss:0.08110\tvalidation_1-mean_pinball_loss:0.09072\n", + "[36]\tvalidation_0-mean_pinball_loss:0.07766\tvalidation_1-mean_pinball_loss:0.08812\n", + "[37]\tvalidation_0-mean_pinball_loss:0.07749\tvalidation_1-mean_pinball_loss:0.08784\n", + "[38]\tvalidation_0-mean_pinball_loss:0.07753\tvalidation_1-mean_pinball_loss:0.08805\n", + "[39]\tvalidation_0-mean_pinball_loss:0.07742\tvalidation_1-mean_pinball_loss:0.08782\n", + "[40]\tvalidation_0-mean_pinball_loss:0.07893\tvalidation_1-mean_pinball_loss:0.08909\n", + "[41]\tvalidation_0-mean_pinball_loss:0.08088\tvalidation_1-mean_pinball_loss:0.09088\n", + "[42]\tvalidation_0-mean_pinball_loss:0.07880\tvalidation_1-mean_pinball_loss:0.08845\n", + "[43]\tvalidation_0-mean_pinball_loss:0.07898\tvalidation_1-mean_pinball_loss:0.08939\n", + "[44]\tvalidation_0-mean_pinball_loss:0.07875\tvalidation_1-mean_pinball_loss:0.08851\n", + "[45]\tvalidation_0-mean_pinball_loss:0.07938\tvalidation_1-mean_pinball_loss:0.08977\n", + "[46]\tvalidation_0-mean_pinball_loss:0.07810\tvalidation_1-mean_pinball_loss:0.08837\n", + "[47]\tvalidation_0-mean_pinball_loss:0.07834\tvalidation_1-mean_pinball_loss:0.08867\n", + "[48]\tvalidation_0-mean_pinball_loss:0.07771\tvalidation_1-mean_pinball_loss:0.08764\n", + "[49]\tvalidation_0-mean_pinball_loss:0.07863\tvalidation_1-mean_pinball_loss:0.08907\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "[2025-12-12 14:12:36,827][WARNING] No aggregation functions specified for RollingAggregatesAdder. Returning original data.\n", + "[2025-12-12 14:12:37,064][INFO] ✅ Training complete!\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "📊 Training Evaluation Metrics:\n", + " quantile R2 observed_probability\n", + "0 0.5 0.835640 0.588889\n", + "1 0.1 0.546782 0.092940\n", + "2 0.9 0.673923 0.894097\n" + ] + } + ], + "source": [ + "# Train the model on historical data\n", + "logger.info(\"🏋️ Starting model training...\")\n", + "\n", + "result = workflow.fit(train_dataset)\n", + "\n", + "# Display training metrics\n", + "if result is not None:\n", + " logger.info(\"✅ Training complete!\")\n", + " print(\"\\n📊 Training Evaluation Metrics:\")\n", + " print(result.metrics_full.to_dataframe())\n", + " \n", + " if result.metrics_test is not None:\n", + " print(\"\\n📊 Test Set Metrics (held-out validation):\")\n", + " print(result.metrics_test.to_dataframe())" + ] + }, + { + "cell_type": "markdown", + "id": "2f89ec59", + "metadata": {}, + "source": [ + "## 🔮 Step 6: Generate Forecasts\n", + "\n", + "Now we use the trained model to predict energy load for the next 14 days. The output is a **ForecastDataset** containing:\n", + "- **Median prediction** (`quantile_P50`)\n", + "- **Lower bound** (`quantile_P10`) — 10th percentile\n", + "- **Upper bound** (`quantile_P90`) — 90th percentile" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "5e18b079", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "[2025-12-12 14:12:37,071][INFO] 🔮 Generating forecasts...\n", + "[2025-12-12 14:12:37,120][WARNING] No aggregation functions specified for RollingAggregatesAdder. Returning original data.\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "📈 Forecast generated for 1344 timestamps\n", + "📊 Quantiles: [0.5, 0.1, 0.9]\n", + "\n", + "🔍 Last 5 forecast values:\n", + " quantile_P50 quantile_P10 quantile_P90 \\\n", + "timestamp \n", + "2024-06-12 22:45:00+00:00 438055.28125 346233.06250 460536.84375 \n", + "2024-06-12 23:00:00+00:00 417909.34375 335798.56250 438184.90625 \n", + "2024-06-12 23:15:00+00:00 410978.18750 330271.25000 429153.15625 \n", + "2024-06-12 23:30:00+00:00 398514.87500 318843.21875 413887.43750 \n", + "2024-06-12 23:45:00+00:00 381927.81250 306417.43750 397558.40625 \n", + "\n", + " load stdev \n", + "timestamp \n", + "2024-06-12 22:45:00+00:00 326666.666667 36988.759513 \n", + "2024-06-12 23:00:00+00:00 326666.666667 35235.209770 \n", + "2024-06-12 23:15:00+00:00 320000.000000 35235.209770 \n", + "2024-06-12 23:30:00+00:00 310000.000000 35235.209770 \n", + "2024-06-12 23:45:00+00:00 296666.666667 35235.209770 \n" + ] + } + ], + "source": [ + "# Generate probabilistic forecasts for the forecast period\n", + "from openstef_core.datasets import ForecastDataset\n", + "\n", + "logger.info(\"🔮 Generating forecasts...\")\n", + "forecast: ForecastDataset = workflow.predict(forecast_dataset)\n", + "\n", + "# Display forecast summary\n", + "print(f\"\\n📈 Forecast generated for {len(forecast.data)} timestamps\")\n", + "print(f\"📊 Quantiles: {forecast.quantiles}\")\n", + "print(\"\\n🔍 Last 5 forecast values:\")\n", + "print(forecast.data.tail())" + ] + }, + { + "cell_type": "markdown", + "id": "67585d92", + "metadata": {}, + "source": [ + "## 📈 Step 7: Visualize Forecast Results\n", + "\n", + "OpenSTEF-BEAM provides **ForecastTimeSeriesPlotter** for beautiful interactive visualizations:\n", + "- Actual measurements shown as a line\n", + "- Forecast median shown as another line\n", + "- Prediction intervals shown as shaded areas" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "id": "dbdead5d", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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" + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# Create an interactive forecast visualization\n", + "from openstef_beam.analysis.plots import ForecastTimeSeriesPlotter\n", + "\n", + "fig = (\n", + " ForecastTimeSeriesPlotter()\n", + " # Add actual measurements (ground truth)\n", + " .add_measurements(measurements=forecast_dataset.data[\"load\"])\n", + " # Add model predictions with confidence bands\n", + " .add_model(\n", + " model_name=\"GBLinear\",\n", + " forecast=forecast.median_series, # P50 prediction\n", + " quantiles=forecast.quantiles_data # P10-P90 confidence band\n", + " )\n", + " .plot()\n", + ")\n", + "\n", + "# Update layout for better presentation\n", + "fig.update_layout(\n", + " title=\"🔮 Energy Load Forecast vs Actual\",\n", + " yaxis_title=\"Load (MW)\",\n", + " xaxis_title=\"Time\",\n", + " height=500\n", + ")\n", + "fig.show()" + ] + }, + { + "cell_type": "markdown", + "id": "f1647ef2", + "metadata": {}, + "source": [ + "## 🔍 Step 8: Explain Feature Importance\n", + "\n", + "Understanding **why** the model makes certain predictions is crucial for trust and debugging. GBLinear models provide clear feature importance rankings." + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "id": "d5c6859c", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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" + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# Visualize feature importance using the ExplainableForecaster interface\n", + "from typing import cast\n", + "from openstef_models.explainability import ExplainableForecaster\n", + "\n", + "# The GBLinear model implements ExplainableForecaster, providing feature importance\n", + "explainable_model = cast(ExplainableForecaster, workflow.model.forecaster)\n", + "\n", + "# Create an interactive treemap of feature importances\n", + "# Larger boxes = more important features\n", + "fig = explainable_model.plot_feature_importances()\n", + "fig.update_layout(title=\"🔍 Feature Importance Treemap\")\n", + "fig.show()" + ] + }, + { + "cell_type": "markdown", + "id": "01c28d0d", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 Summary\n", + "\n", + "In this tutorial, you learned how to:\n", + "\n", + "1. ✅ **Load energy data** from the Liander 2024 benchmark dataset\n", + "2. ✅ **Configure a workflow** with `ForecastingWorkflowConfig`\n", + "3. ✅ **Train a GBLinear model** for probabilistic forecasting\n", + "4. ✅ **Generate forecasts** with confidence intervals\n", + "5. ✅ **Visualize results** and feature importance\n", + "\n", + "### 🚀 Next Steps\n", + "\n", + "- Try different models: `\"xgboost\"` for more complex patterns\n", + "- Experiment with more quantiles for narrower prediction intervals\n", + "- Use the **backtesting notebook** to evaluate model performance systematically\n", + "- Explore MLflow integration for experiment tracking" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.11" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/packages/openstef-beam/README.md b/packages/openstef-beam/README.md index aac731009..526cccec5 100644 --- a/packages/openstef-beam/README.md +++ b/packages/openstef-beam/README.md @@ -1,7 +1,7 @@ -# openstef-beam \ No newline at end of file +# openstef-beam diff --git a/packages/openstef-beam/pyproject.toml b/packages/openstef-beam/pyproject.toml index ae12687ec..5c33fb373 100644 --- a/packages/openstef-beam/pyproject.toml +++ b/packages/openstef-beam/pyproject.toml @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 @@ -15,7 +15,7 @@ readme = "README.md" keywords = [ "energy", "forecasting", "machinelearning" ] license = "MPL-2.0" authors = [ - { name = "Alliander N.V", email = "short.term.energy.forecasts@alliander.com" }, + { name = "Alliander N.V", email = "openstef@lfenergy.org" }, ] requires-python = ">=3.12,<4.0" classifiers = [ diff --git a/packages/openstef-beam/src/openstef_beam/__init__.py b/packages/openstef-beam/src/openstef_beam/__init__.py index 600748532..9507dc9f1 100644 --- a/packages/openstef-beam/src/openstef_beam/__init__.py +++ b/packages/openstef-beam/src/openstef_beam/__init__.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2017-2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2017-2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/src/openstef_beam/analysis/__init__.py b/packages/openstef-beam/src/openstef_beam/analysis/__init__.py index d7dcbfcdc..6f3cca7c6 100644 --- a/packages/openstef-beam/src/openstef_beam/analysis/__init__.py +++ b/packages/openstef-beam/src/openstef_beam/analysis/__init__.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/src/openstef_beam/analysis/analysis_pipeline.py b/packages/openstef-beam/src/openstef_beam/analysis/analysis_pipeline.py index 51bfdcdfe..78b6954c3 100644 --- a/packages/openstef-beam/src/openstef_beam/analysis/analysis_pipeline.py +++ b/packages/openstef-beam/src/openstef_beam/analysis/analysis_pipeline.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/src/openstef_beam/analysis/models/__init__.py b/packages/openstef-beam/src/openstef_beam/analysis/models/__init__.py index 0f762926c..fc43186fe 100644 --- a/packages/openstef-beam/src/openstef_beam/analysis/models/__init__.py +++ b/packages/openstef-beam/src/openstef_beam/analysis/models/__init__.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/src/openstef_beam/analysis/models/target_metadata.py b/packages/openstef-beam/src/openstef_beam/analysis/models/target_metadata.py index 6fd670638..d545fc09e 100644 --- a/packages/openstef-beam/src/openstef_beam/analysis/models/target_metadata.py +++ b/packages/openstef-beam/src/openstef_beam/analysis/models/target_metadata.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/src/openstef_beam/analysis/models/visualization_aggregation.py b/packages/openstef-beam/src/openstef_beam/analysis/models/visualization_aggregation.py index dee42a6a7..665600696 100644 --- a/packages/openstef-beam/src/openstef_beam/analysis/models/visualization_aggregation.py +++ b/packages/openstef-beam/src/openstef_beam/analysis/models/visualization_aggregation.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/src/openstef_beam/analysis/models/visualization_output.py b/packages/openstef-beam/src/openstef_beam/analysis/models/visualization_output.py index 3c5106b22..00a7d1238 100644 --- a/packages/openstef-beam/src/openstef_beam/analysis/models/visualization_output.py +++ b/packages/openstef-beam/src/openstef_beam/analysis/models/visualization_output.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/src/openstef_beam/analysis/plots/__init__.py b/packages/openstef-beam/src/openstef_beam/analysis/plots/__init__.py index 424c903e9..92892f7d9 100644 --- a/packages/openstef-beam/src/openstef_beam/analysis/plots/__init__.py +++ b/packages/openstef-beam/src/openstef_beam/analysis/plots/__init__.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/src/openstef_beam/analysis/plots/forecast_time_series_plotter.py b/packages/openstef-beam/src/openstef_beam/analysis/plots/forecast_time_series_plotter.py index 3e37cc324..28ee40367 100644 --- a/packages/openstef-beam/src/openstef_beam/analysis/plots/forecast_time_series_plotter.py +++ b/packages/openstef-beam/src/openstef_beam/analysis/plots/forecast_time_series_plotter.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 @@ -8,12 +8,16 @@ forecasts, measurements, and uncertainty quantiles across multiple models. """ +from datetime import timedelta from typing import Any, ClassVar, Self, TypedDict, cast import numpy as np import pandas as pd import plotly.express as px import plotly.graph_objects as go +from pydantic import Field, PrivateAttr + +from openstef_core.base_model import BaseConfig class ModelData(TypedDict): @@ -53,7 +57,7 @@ class QuantilePolygonStyle(TypedDict): legendgroup: str -class ForecastTimeSeriesPlotter: +class ForecastTimeSeriesPlotter(BaseConfig): """Creates interactive time series charts comparing forecasts, measurements, and uncertainty bands. This plotter visualizes forecast performance over time by overlaying multiple models' @@ -111,17 +115,21 @@ class ForecastTimeSeriesPlotter: stroke_opacity: float = 0.8 stroke_width: float = 1.5 - def __init__(self, *, connect_gaps: bool = True): - """Initialize the ForecastTimeSeriesPlotter. - - Args: - connect_gaps: If True, connects data points across missing timestamps with lines. - If False, leaves gaps where data is missing (no interpolation). - """ - self.measurements: pd.Series | None = None - self.models_data: list[ModelData] = [] - self.limits: list[dict[str, Any]] = [] - self.connect_gaps = connect_gaps + sample_interval: timedelta = Field( + default=timedelta(minutes=15), + description="Expected interval between consecutive samples in the time series data.", + ) + connect_gaps: bool = Field( + default=True, + description=( + "If True, connects data points across missing timestamps with lines. " + "If False, leaves gaps where data is missing (no interpolation)." + ), + ) + + _measurements: pd.Series | None = PrivateAttr(default=None) + _models_data: list[ModelData] = PrivateAttr(default_factory=list[ModelData]) + _limits: list[dict[str, Any]] = PrivateAttr(default_factory=list[dict[str, Any]]) def _insert_gaps_for_missing_timestamps(self, series: pd.Series, sample_interval: pd.Timedelta) -> pd.Series: """Insert NaN values where there are temporal gaps larger than the expected sample interval. @@ -158,7 +166,7 @@ def add_measurements(self, measurements: pd.Series) -> Self: Returns: ForecastTimeSeriesPlotter: The current instance for method chaining. """ - self.measurements = measurements + self._measurements = measurements return self def add_model( @@ -200,7 +208,7 @@ def add_model( "quantiles": quantiles, } - self.models_data.append(model_data) + self._models_data.append(model_data) return self def add_limit( @@ -219,9 +227,9 @@ def add_limit( ForecastTimeSeriesPlotter: The current instance for method chaining. """ if name is None: - name = f"Limit {len(self.limits) + 1}" + name = f"Limit {len(self._limits) + 1}" - self.limits.append({ + self._limits.append({ "value": value, "name": name, }) @@ -373,7 +381,7 @@ def _prepare_quantile_bands(self) -> list[BandData]: List of BandData dictionaries with quantile band information. """ bands: list[BandData] = [] - for model_index, model_data in enumerate(self.models_data): + for model_index, model_data in enumerate(self._models_data): if model_data["quantiles"] is None: continue @@ -408,7 +416,7 @@ def _prepare_forecast_lines(self) -> list[LineData]: List of LineData dictionaries with forecast line information. """ lines: list[LineData] = [] - for model_index, model_data in enumerate(self.models_data): + for model_index, model_data in enumerate(self._models_data): model_name = model_data["model_name"] forecast = model_data["forecast"] @@ -430,7 +438,7 @@ def _prepare_quantile_50th_lines(self) -> list[LineData]: List of LineData dictionaries with 50th quantile line information. """ lines: list[LineData] = [] - for model_index, model_data in enumerate(self.models_data): + for model_index, model_data in enumerate(self._models_data): model_name = model_data["model_name"] quantiles = model_data["quantiles"] forecast = model_data["forecast"] @@ -499,17 +507,17 @@ def _add_lines_to_figure(self, figure: go.Figure, lines: list[LineData]) -> None def _add_measurements_to_figure(self, figure: go.Figure) -> None: """Add measurements to the figure.""" - if self.measurements is not None: + if self._measurements is not None: if self.connect_gaps: # Original behavior - use data as-is - measurements_data = self.measurements + measurements_data = self._measurements x_data = measurements_data.index y_data = measurements_data else: # Process data to insert gaps for missing timestamps - measurements_data = self.measurements + measurements_data = self._measurements processed_data = self._insert_gaps_for_missing_timestamps( - measurements_data, pd.Timedelta(self.measurements.sample_interval) + measurements_data, pd.Timedelta(self.sample_interval) ) x_data = processed_data.index y_data = processed_data @@ -529,7 +537,7 @@ def _add_measurements_to_figure(self, figure: go.Figure) -> None: def _add_limits_to_figure(self, figure: go.Figure) -> None: """Add horizontal limit lines to the figure.""" - for limit in self.limits: + for limit in self._limits: figure.add_hline( # type: ignore[reportUnknownMemberType] y=limit["value"], line_dash="dot", @@ -560,7 +568,7 @@ def plot(self, title: str = "Time Series Plots") -> go.Figure: Raises: ValueError: If no data has been added to the plotter. """ - if not self.models_data and self.measurements is None: + if not self._models_data and self._measurements is None: msg = "No data has been added. Use add_measurements or add_model first." raise ValueError(msg) diff --git a/packages/openstef-beam/src/openstef_beam/analysis/plots/grouped_target_metric_plotter.py b/packages/openstef-beam/src/openstef_beam/analysis/plots/grouped_target_metric_plotter.py index a1ba48a6f..81fd989e5 100644 --- a/packages/openstef-beam/src/openstef_beam/analysis/plots/grouped_target_metric_plotter.py +++ b/packages/openstef-beam/src/openstef_beam/analysis/plots/grouped_target_metric_plotter.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/src/openstef_beam/analysis/plots/precision_recall_curve_plotter.py b/packages/openstef-beam/src/openstef_beam/analysis/plots/precision_recall_curve_plotter.py index 82bcc2b96..c4cc00453 100644 --- a/packages/openstef-beam/src/openstef_beam/analysis/plots/precision_recall_curve_plotter.py +++ b/packages/openstef-beam/src/openstef_beam/analysis/plots/precision_recall_curve_plotter.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/src/openstef_beam/analysis/plots/quantile_calibration_box_plotter.py b/packages/openstef-beam/src/openstef_beam/analysis/plots/quantile_calibration_box_plotter.py index d293d0f59..785734cb6 100644 --- a/packages/openstef-beam/src/openstef_beam/analysis/plots/quantile_calibration_box_plotter.py +++ b/packages/openstef-beam/src/openstef_beam/analysis/plots/quantile_calibration_box_plotter.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/src/openstef_beam/analysis/plots/quantile_probability_plotter.py b/packages/openstef-beam/src/openstef_beam/analysis/plots/quantile_probability_plotter.py index b167239c3..8f3f2ea4d 100644 --- a/packages/openstef-beam/src/openstef_beam/analysis/plots/quantile_probability_plotter.py +++ b/packages/openstef-beam/src/openstef_beam/analysis/plots/quantile_probability_plotter.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/src/openstef_beam/analysis/plots/summary_table_plotter.py b/packages/openstef-beam/src/openstef_beam/analysis/plots/summary_table_plotter.py index 736ec1484..3c573cca6 100644 --- a/packages/openstef-beam/src/openstef_beam/analysis/plots/summary_table_plotter.py +++ b/packages/openstef-beam/src/openstef_beam/analysis/plots/summary_table_plotter.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/src/openstef_beam/analysis/plots/windowed_metric_plotter.py b/packages/openstef-beam/src/openstef_beam/analysis/plots/windowed_metric_plotter.py index a3192fe0d..33c75a183 100644 --- a/packages/openstef-beam/src/openstef_beam/analysis/plots/windowed_metric_plotter.py +++ b/packages/openstef-beam/src/openstef_beam/analysis/plots/windowed_metric_plotter.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/src/openstef_beam/analysis/visualizations/__init__.py b/packages/openstef-beam/src/openstef_beam/analysis/visualizations/__init__.py index 8a5f82722..bb1b95397 100644 --- a/packages/openstef-beam/src/openstef_beam/analysis/visualizations/__init__.py +++ b/packages/openstef-beam/src/openstef_beam/analysis/visualizations/__init__.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/src/openstef_beam/analysis/visualizations/base.py b/packages/openstef-beam/src/openstef_beam/analysis/visualizations/base.py index 5294db8a4..1fb1105f0 100644 --- a/packages/openstef-beam/src/openstef_beam/analysis/visualizations/base.py +++ b/packages/openstef-beam/src/openstef_beam/analysis/visualizations/base.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/src/openstef_beam/analysis/visualizations/grouped_target_metric_visualization.py b/packages/openstef-beam/src/openstef_beam/analysis/visualizations/grouped_target_metric_visualization.py index c0717a180..42af4584d 100644 --- a/packages/openstef-beam/src/openstef_beam/analysis/visualizations/grouped_target_metric_visualization.py +++ b/packages/openstef-beam/src/openstef_beam/analysis/visualizations/grouped_target_metric_visualization.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/src/openstef_beam/analysis/visualizations/precision_recall_curve_visualization.py b/packages/openstef-beam/src/openstef_beam/analysis/visualizations/precision_recall_curve_visualization.py index 9fc9389ad..f1d16156e 100644 --- a/packages/openstef-beam/src/openstef_beam/analysis/visualizations/precision_recall_curve_visualization.py +++ b/packages/openstef-beam/src/openstef_beam/analysis/visualizations/precision_recall_curve_visualization.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/src/openstef_beam/analysis/visualizations/quantile_calibration_box_visualization.py b/packages/openstef-beam/src/openstef_beam/analysis/visualizations/quantile_calibration_box_visualization.py index 7b316a877..8fe8f07a0 100644 --- a/packages/openstef-beam/src/openstef_beam/analysis/visualizations/quantile_calibration_box_visualization.py +++ b/packages/openstef-beam/src/openstef_beam/analysis/visualizations/quantile_calibration_box_visualization.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/src/openstef_beam/analysis/visualizations/quantile_probability_visualization.py b/packages/openstef-beam/src/openstef_beam/analysis/visualizations/quantile_probability_visualization.py index eb0fedaa1..04cbb7b3f 100644 --- a/packages/openstef-beam/src/openstef_beam/analysis/visualizations/quantile_probability_visualization.py +++ b/packages/openstef-beam/src/openstef_beam/analysis/visualizations/quantile_probability_visualization.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/src/openstef_beam/analysis/visualizations/summary_table_visualization.py b/packages/openstef-beam/src/openstef_beam/analysis/visualizations/summary_table_visualization.py index e952e06ab..280c9ca7c 100644 --- a/packages/openstef-beam/src/openstef_beam/analysis/visualizations/summary_table_visualization.py +++ b/packages/openstef-beam/src/openstef_beam/analysis/visualizations/summary_table_visualization.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/src/openstef_beam/analysis/visualizations/timeseries_visualization.py b/packages/openstef-beam/src/openstef_beam/analysis/visualizations/timeseries_visualization.py index 3a444f3a2..ab80ec6f5 100644 --- a/packages/openstef-beam/src/openstef_beam/analysis/visualizations/timeseries_visualization.py +++ b/packages/openstef-beam/src/openstef_beam/analysis/visualizations/timeseries_visualization.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 @@ -102,7 +102,10 @@ def create_by_none( report: EvaluationSubsetReport, metadata: TargetMetadata, ) -> VisualizationOutput: - plotter = ForecastTimeSeriesPlotter(connect_gaps=self.connect_gaps) + plotter = ForecastTimeSeriesPlotter( + connect_gaps=self.connect_gaps, + sample_interval=report.subset.sample_interval, + ) # Add measurements as the baseline plotter.add_measurements(report.get_measurements()) diff --git a/packages/openstef-beam/src/openstef_beam/analysis/visualizations/windowed_metric_visualization.py b/packages/openstef-beam/src/openstef_beam/analysis/visualizations/windowed_metric_visualization.py index a0f294dad..249b8d5da 100644 --- a/packages/openstef-beam/src/openstef_beam/analysis/visualizations/windowed_metric_visualization.py +++ b/packages/openstef-beam/src/openstef_beam/analysis/visualizations/windowed_metric_visualization.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/src/openstef_beam/backtesting/__init__.py b/packages/openstef-beam/src/openstef_beam/backtesting/__init__.py index 61d352e03..600bdf236 100644 --- a/packages/openstef-beam/src/openstef_beam/backtesting/__init__.py +++ b/packages/openstef-beam/src/openstef_beam/backtesting/__init__.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/src/openstef_beam/backtesting/backtest_callback.py b/packages/openstef-beam/src/openstef_beam/backtesting/backtest_callback.py index 51da8b7c1..723a940ac 100644 --- a/packages/openstef-beam/src/openstef_beam/backtesting/backtest_callback.py +++ b/packages/openstef-beam/src/openstef_beam/backtesting/backtest_callback.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/src/openstef_beam/backtesting/backtest_event.py b/packages/openstef-beam/src/openstef_beam/backtesting/backtest_event.py index 2917520af..9e1700e10 100644 --- a/packages/openstef-beam/src/openstef_beam/backtesting/backtest_event.py +++ b/packages/openstef-beam/src/openstef_beam/backtesting/backtest_event.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/src/openstef_beam/backtesting/backtest_event_generator.py b/packages/openstef-beam/src/openstef_beam/backtesting/backtest_event_generator.py index 32638dc30..cf028e495 100644 --- a/packages/openstef-beam/src/openstef_beam/backtesting/backtest_event_generator.py +++ b/packages/openstef-beam/src/openstef_beam/backtesting/backtest_event_generator.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/src/openstef_beam/backtesting/backtest_forecaster/__init__.py b/packages/openstef-beam/src/openstef_beam/backtesting/backtest_forecaster/__init__.py index 1f6689bbf..95bf12ced 100644 --- a/packages/openstef-beam/src/openstef_beam/backtesting/backtest_forecaster/__init__.py +++ b/packages/openstef-beam/src/openstef_beam/backtesting/backtest_forecaster/__init__.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 @@ -16,14 +16,10 @@ BacktestForecasterConfig, BacktestForecasterMixin, ) -from openstef_beam.backtesting.backtest_forecaster.openstef4_backtest_forecaster import ( - OpenSTEF4BacktestForecaster, -) __all__ = [ "BacktestBatchForecasterMixin", "BacktestForecasterConfig", "BacktestForecasterMixin", "DummyForecaster", - "OpenSTEF4BacktestForecaster", ] diff --git a/packages/openstef-beam/src/openstef_beam/backtesting/backtest_forecaster/dummy_forecaster.py b/packages/openstef-beam/src/openstef_beam/backtesting/backtest_forecaster/dummy_forecaster.py index 9b931ad6d..85314e638 100644 --- a/packages/openstef-beam/src/openstef_beam/backtesting/backtest_forecaster/dummy_forecaster.py +++ b/packages/openstef-beam/src/openstef_beam/backtesting/backtest_forecaster/dummy_forecaster.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/src/openstef_beam/backtesting/backtest_forecaster/mixins.py b/packages/openstef-beam/src/openstef_beam/backtesting/backtest_forecaster/mixins.py index 5c02a6fa5..709242440 100644 --- a/packages/openstef-beam/src/openstef_beam/backtesting/backtest_forecaster/mixins.py +++ b/packages/openstef-beam/src/openstef_beam/backtesting/backtest_forecaster/mixins.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/src/openstef_beam/backtesting/backtest_forecaster/openstef4_backtest_forecaster.py b/packages/openstef-beam/src/openstef_beam/backtesting/backtest_forecaster/openstef4_backtest_forecaster.py deleted file mode 100644 index 56dad935f..000000000 --- a/packages/openstef-beam/src/openstef_beam/backtesting/backtest_forecaster/openstef4_backtest_forecaster.py +++ /dev/null @@ -1,127 +0,0 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project -# -# SPDX-License-Identifier: MPL-2.0 - -"""OpenSTEF 4.0 forecaster for backtesting pipelines.""" - -import logging -from collections.abc import Callable -from pathlib import Path -from typing import Any, override - -from pydantic import Field, PrivateAttr - -from openstef_beam.backtesting.backtest_forecaster.mixins import BacktestForecasterConfig, BacktestForecasterMixin -from openstef_beam.backtesting.restricted_horizon_timeseries import RestrictedHorizonVersionedTimeSeries -from openstef_core.base_model import BaseModel -from openstef_core.datasets import TimeSeriesDataset -from openstef_core.exceptions import FlatlinerDetectedError, NotFittedError -from openstef_core.types import Q -from openstef_models.workflows.custom_forecasting_workflow import CustomForecastingWorkflow - - -class OpenSTEF4BacktestForecaster(BaseModel, BacktestForecasterMixin): - """Forecaster that allows using a ForecastingWorkflow to be used in backtesting, specifically for OpenSTEF4 models. - - A new workflow is created each time fit() is called using the provided workflow_factory, - ensuring fresh model instances for each training cycle during benchmarking. - """ - - config: BacktestForecasterConfig = Field( - description="Configuration for the backtest forecaster interface", - ) - workflow_factory: Callable[[], CustomForecastingWorkflow] = Field( - description="Factory function that creates a new CustomForecastingWorkflow instance", - ) - cache_dir: Path = Field( - description="Directory to use for caching model artifacts during backtesting", - ) - debug: bool = Field( - default=False, - description="When True, saves intermediate input data for debugging", - ) - - _workflow: CustomForecastingWorkflow | None = PrivateAttr(default=None) - _is_flatliner_detected: bool = PrivateAttr(default=False) - - _logger: logging.Logger = PrivateAttr(default=logging.getLogger(__name__)) - - @override - def model_post_init(self, context: Any) -> None: - if self.debug: - self.cache_dir.mkdir(parents=True, exist_ok=True) - - @property - @override - def quantiles(self) -> list[Q]: - # Create a workflow instance if needed to get quantiles - if self._workflow is None: - self._workflow = self.workflow_factory() - # Extract quantiles from the workflow's model - return self._workflow.model.forecaster.config.quantiles - - @override - def fit(self, data: RestrictedHorizonVersionedTimeSeries) -> None: - # Create a new workflow for this training cycle - workflow = self.workflow_factory() - - # Extract the dataset for training - training_data = data.get_window( - start=data.horizon - self.config.training_context_length, end=data.horizon, available_before=data.horizon - ) - - if self.debug: - id_str = data.horizon.strftime("%Y%m%d%H%M%S") - training_data.to_parquet(path=self.cache_dir / f"debug_{id_str}_training.parquet") - - try: - # Use the workflow's fit method - workflow.fit(data=training_data) - self._is_flatliner_detected = False - except FlatlinerDetectedError: - self._logger.warning("Flatliner detected during training") - self._is_flatliner_detected = True - return # Skip setting the workflow on flatliner detection - - self._workflow = workflow - - if self.debug: - id_str = data.horizon.strftime("%Y%m%d%H%M%S") - self._workflow.model.prepare_input(training_data).to_parquet( # pyright: ignore[reportPrivateUsage] - path=self.cache_dir / f"debug_{id_str}_prepared_training.parquet" - ) - - @override - def predict(self, data: RestrictedHorizonVersionedTimeSeries) -> TimeSeriesDataset | None: - if self._is_flatliner_detected: - self._logger.info("Skipping prediction due to prior flatliner detection") - return None - - if self._workflow is None: - raise NotFittedError("Must call fit() before predict()") - - # Extract the dataset including both historical context and forecast period - predict_data = data.get_window( - start=data.horizon - self.config.predict_context_length, - end=data.horizon + self.config.predict_length, # Include the forecast period - available_before=data.horizon, # Only use data available at prediction time (prevents lookahead bias) - ) - - try: - forecast = self._workflow.predict( - data=predict_data, - forecast_start=data.horizon, # Where historical data ends and forecasting begins - ) - except FlatlinerDetectedError: - self._logger.info("Flatliner detected during prediction") - return None - - if self.debug: - id_str = data.horizon.strftime("%Y%m%d%H%M%S") - predict_data.to_parquet(path=self.cache_dir / f"debug_{id_str}_predict.parquet") - forecast.to_parquet(path=self.cache_dir / f"debug_{id_str}_forecast.parquet") - - return forecast - - -__all__ = ["OpenSTEF4BacktestForecaster"] diff --git a/packages/openstef-beam/src/openstef_beam/backtesting/backtest_pipeline.py b/packages/openstef-beam/src/openstef_beam/backtesting/backtest_pipeline.py index 00a89a63a..65a8393af 100644 --- a/packages/openstef-beam/src/openstef_beam/backtesting/backtest_pipeline.py +++ b/packages/openstef-beam/src/openstef_beam/backtesting/backtest_pipeline.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/src/openstef_beam/backtesting/restricted_horizon_timeseries.py b/packages/openstef-beam/src/openstef_beam/backtesting/restricted_horizon_timeseries.py index 5040a038b..1950b28f4 100644 --- a/packages/openstef-beam/src/openstef_beam/backtesting/restricted_horizon_timeseries.py +++ b/packages/openstef-beam/src/openstef_beam/backtesting/restricted_horizon_timeseries.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/src/openstef_beam/benchmarking/__init__.py b/packages/openstef-beam/src/openstef_beam/benchmarking/__init__.py index 6015b6941..312814fe1 100644 --- a/packages/openstef-beam/src/openstef_beam/benchmarking/__init__.py +++ b/packages/openstef-beam/src/openstef_beam/benchmarking/__init__.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/src/openstef_beam/benchmarking/baselines/__init__.py b/packages/openstef-beam/src/openstef_beam/benchmarking/baselines/__init__.py new file mode 100644 index 000000000..19124fc22 --- /dev/null +++ b/packages/openstef-beam/src/openstef_beam/benchmarking/baselines/__init__.py @@ -0,0 +1,20 @@ +"""Benchmarks baselines used by the OpenSTEF Beam benchmarking utilities. + +This package exposes baseline forecasters for use in backtesting. +""" + +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# +# SPDX-License-Identifier: MPL-2.0 + +from openstef_beam.benchmarking.baselines.openstef4 import ( + OpenSTEF4BacktestForecaster, + WorkflowCreationContext, + create_openstef4_preset_backtest_forecaster, +) + +__all__ = [ + "OpenSTEF4BacktestForecaster", + "WorkflowCreationContext", + "create_openstef4_preset_backtest_forecaster", +] diff --git a/packages/openstef-beam/src/openstef_beam/benchmarking/baselines/openstef4.py b/packages/openstef-beam/src/openstef_beam/benchmarking/baselines/openstef4.py new file mode 100644 index 000000000..9019a2156 --- /dev/null +++ b/packages/openstef-beam/src/openstef_beam/benchmarking/baselines/openstef4.py @@ -0,0 +1,279 @@ +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# +# SPDX-License-Identifier: MPL-2.0 + +"""OpenSTEF 4.0 forecaster for backtesting pipelines.""" + +import logging +from collections.abc import Callable +from datetime import timedelta +from functools import partial +from pathlib import Path +from typing import Any, cast, override + +import pandas as pd +from pydantic import Field, PrivateAttr +from pydantic_extra_types.coordinate import Coordinate + +from openstef_beam.backtesting.backtest_forecaster.mixins import ( + BacktestForecasterConfig, + BacktestForecasterMixin, +) +from openstef_beam.backtesting.restricted_horizon_timeseries import ( + RestrictedHorizonVersionedTimeSeries, +) +from openstef_beam.benchmarking.benchmark_pipeline import ( + BenchmarkContext, + BenchmarkTarget, + ForecasterFactory, +) +from openstef_core.base_model import BaseConfig, BaseModel +from openstef_core.datasets import TimeSeriesDataset +from openstef_core.exceptions import FlatlinerDetectedError, NotFittedError +from openstef_core.types import Q +from openstef_meta.models.ensemble_forecasting_model import EnsembleForecastingModel +from openstef_meta.presets import EnsembleWorkflowConfig, create_ensemble_workflow +from openstef_models.presets import ForecastingWorkflowConfig +from openstef_models.workflows.custom_forecasting_workflow import ( + CustomForecastingWorkflow, +) + + +class WorkflowCreationContext(BaseConfig): + """Context information for workflow execution within backtesting.""" + + step_name: str | None = Field( + default=None, + description="Name of the current backtesting step.", + ) + + +class OpenSTEF4BacktestForecaster(BaseModel, BacktestForecasterMixin): + """Forecaster that allows using a ForecastingWorkflow to be used in backtesting, specifically for OpenSTEF4 models. + + A new workflow is created each time fit() is called using the provided workflow_factory, + ensuring fresh model instances for each training cycle during benchmarking. + """ + + config: BacktestForecasterConfig = Field( + description="Configuration for the backtest forecaster interface", + ) + workflow_factory: Callable[[WorkflowCreationContext], CustomForecastingWorkflow] = Field( + description="Factory function that creates a new CustomForecastingWorkflow instance", + ) + cache_dir: Path = Field( + description="Directory to use for caching model artifacts during backtesting", + ) + debug: bool = Field( + default=False, + description="When True, saves intermediate input data for debugging", + ) + contributions: bool = Field( + default=False, + description="When True, saves base Forecaster prediction contributions for ensemble models in cache_dir", + ) + + _workflow: CustomForecastingWorkflow | None = PrivateAttr(default=None) + _is_flatliner_detected: bool = PrivateAttr(default=False) + + _logger: logging.Logger = PrivateAttr(default=logging.getLogger(__name__)) + + @override + def model_post_init(self, context: Any) -> None: + if self.debug or self.contributions: + self.cache_dir.mkdir(parents=True, exist_ok=True) + + @property + @override + def quantiles(self) -> list[Q]: + # Create a workflow instance if needed to get quantiles + if self._workflow is None: + self._workflow = self.workflow_factory(WorkflowCreationContext()) + # Extract quantiles from the workflow's model + + if isinstance(self._workflow.model, EnsembleForecastingModel): + name = self._workflow.model.forecaster_names[0] + return self._workflow.model.forecasters[name].config.quantiles + return self._workflow.model.forecaster.config.quantiles + + @override + def fit(self, data: RestrictedHorizonVersionedTimeSeries) -> None: + # Create a new workflow for this training cycle + context = WorkflowCreationContext(step_name=data.horizon.isoformat()) + workflow = self.workflow_factory(context) + + # Extract the dataset for training + training_data = data.get_window( + start=data.horizon - self.config.training_context_length, + end=data.horizon, + available_before=data.horizon, + ) + + if self.debug: + id_str = data.horizon.strftime("%Y%m%d%H%M%S") + training_data.to_parquet(path=self.cache_dir / f"debug_{id_str}_training.parquet") + + try: + # Use the workflow's fit method + workflow.fit(data=training_data) + self._is_flatliner_detected = False + except FlatlinerDetectedError: + self._logger.warning("Flatliner detected during training") + self._is_flatliner_detected = True + return # Skip setting the workflow on flatliner detection + + self._workflow = workflow + + if self.debug: + id_str = data.horizon.strftime("%Y%m%d%H%M%S") + self._workflow.model.prepare_input(training_data).to_parquet( # pyright: ignore[reportPrivateUsage] + path=self.cache_dir / f"debug_{id_str}_prepared_training.parquet" + ) + + @override + def predict(self, data: RestrictedHorizonVersionedTimeSeries) -> TimeSeriesDataset | None: + if self._is_flatliner_detected: + self._logger.info("Skipping prediction due to prior flatliner detection") + return None + + if self._workflow is None: + raise NotFittedError("Must call fit() before predict()") + + # Extract the dataset including both historical context and forecast period + predict_data = data.get_window( + start=data.horizon - self.config.predict_context_length, + end=data.horizon + self.config.predict_length, # Include the forecast period + available_before=data.horizon, # Only use data available at prediction time (prevents lookahead bias) + ) + + try: + forecast = self._workflow.predict( + data=predict_data, + forecast_start=data.horizon, # Where historical data ends and forecasting begins + ) + except FlatlinerDetectedError: + self._logger.info("Flatliner detected during prediction") + return None + + if self.debug: + id_str = data.horizon.strftime("%Y%m%d%H%M%S") + predict_data.to_parquet(path=self.cache_dir / f"debug_{id_str}_predict.parquet") + forecast.to_parquet(path=self.cache_dir / f"debug_{id_str}_forecast.parquet") + + if self.contributions and isinstance(self._workflow.model, EnsembleForecastingModel): + contr_str = data.horizon.strftime("%Y%m%d%H%M%S") + contributions = self._workflow.model.predict_contributions(predict_data, forecast_start=data.horizon) + df = pd.concat([contributions, forecast.data.drop(columns=["load"])], axis=1) + + df.to_parquet(path=self.cache_dir / f"contrib_{contr_str}_predict.parquet") + return forecast + + +class OpenSTEF4PresetBacktestForecaster(OpenSTEF4BacktestForecaster): + pass + + +def _preset_target_forecaster_factory( + base_config: ForecastingWorkflowConfig | EnsembleWorkflowConfig, + backtest_config: BacktestForecasterConfig, + cache_dir: Path, + context: BenchmarkContext, + target: BenchmarkTarget, +) -> OpenSTEF4BacktestForecaster: + from openstef_models.presets import create_forecasting_workflow # noqa: PLC0415 + from openstef_models.presets.forecasting_workflow import LocationConfig # noqa: PLC0415 + + # Factory function that creates a forecaster for a given target. + prefix = context.run_name + + def _create_workflow(context: WorkflowCreationContext) -> CustomForecastingWorkflow: + # Create a new workflow instance with fresh model. + if isinstance(base_config, EnsembleWorkflowConfig): + return create_ensemble_workflow( + config=base_config.model_copy( + update={ + "model_id": f"{prefix}_{target.name}", + "location": LocationConfig( + name=target.name, + description=target.description, + coordinate=Coordinate( + latitude=target.latitude, + longitude=target.longitude, + ), + ), + } + ) + ) + + return create_forecasting_workflow( + config=base_config.model_copy( + update={ + "model_id": f"{prefix}_{target.name}", + "run_name": context.step_name, + "location": LocationConfig( + name=target.name, + description=target.description, + coordinate=Coordinate( + latitude=target.latitude, + longitude=target.longitude, + ), + ), + } + ) + ) + + return OpenSTEF4BacktestForecaster( + config=backtest_config, + workflow_factory=_create_workflow, + debug=False, + cache_dir=cache_dir / f"{context.run_name}_{target.name}", + ) + + +def create_openstef4_preset_backtest_forecaster( + workflow_config: ForecastingWorkflowConfig | EnsembleWorkflowConfig, + backtest_config: BacktestForecasterConfig | None = None, + cache_dir: Path = Path("cache"), +) -> ForecasterFactory[BenchmarkTarget]: + """Create a factory that returns an OpenSTEF4BacktestForecaster for a benchmark target. + + Args: + workflow_config: The configured `ForecastingWorkflowConfig` that will be cloned and + assigned to a target-specific workflow instance. + backtest_config: Optional `BacktestForecasterConfig` to control training/prediction windows. + If None, a sensible default is created. + cache_dir: Directory to store cached artifacts for created forecasters. A subdirectory will be + created per benchmark run and target. + + Returns: + A `ForecasterFactory[BenchmarkTarget]` partial which accepts a `BenchmarkContext` and a + `BenchmarkTarget` and returns a configured `OpenSTEF4BacktestForecaster`. + """ + if backtest_config is None: + backtest_config = BacktestForecasterConfig( + requires_training=True, + predict_length=timedelta(days=7), + predict_min_length=timedelta(minutes=15), + predict_context_length=timedelta(days=14), # Context needed for lag features + predict_context_min_coverage=0.5, + training_context_length=timedelta(days=90), # Three months of training data + training_context_min_coverage=0.5, + predict_sample_interval=timedelta(minutes=15), + ) + + return cast( + ForecasterFactory[BenchmarkTarget], + partial( + _preset_target_forecaster_factory, + workflow_config, + backtest_config, + cache_dir, + ), + ) + + +__all__ = [ + "OpenSTEF4BacktestForecaster", + "WorkflowCreationContext", + "create_openstef4_preset_backtest_forecaster", +] diff --git a/packages/openstef-beam/src/openstef_beam/benchmarking/benchmark_comparison_pipeline.py b/packages/openstef-beam/src/openstef_beam/benchmarking/benchmark_comparison_pipeline.py index 406f9d162..2aaac8f5c 100644 --- a/packages/openstef-beam/src/openstef_beam/benchmarking/benchmark_comparison_pipeline.py +++ b/packages/openstef-beam/src/openstef_beam/benchmarking/benchmark_comparison_pipeline.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/src/openstef_beam/benchmarking/benchmark_pipeline.py b/packages/openstef-beam/src/openstef_beam/benchmarking/benchmark_pipeline.py index 7d4649def..f4220e0fa 100644 --- a/packages/openstef-beam/src/openstef_beam/benchmarking/benchmark_pipeline.py +++ b/packages/openstef-beam/src/openstef_beam/benchmarking/benchmark_pipeline.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/src/openstef_beam/benchmarking/benchmarks/__init__.py b/packages/openstef-beam/src/openstef_beam/benchmarking/benchmarks/__init__.py index c28bb079f..be557d247 100644 --- a/packages/openstef-beam/src/openstef_beam/benchmarking/benchmarks/__init__.py +++ b/packages/openstef-beam/src/openstef_beam/benchmarking/benchmarks/__init__.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/src/openstef_beam/benchmarking/benchmarks/liander2024.py b/packages/openstef-beam/src/openstef_beam/benchmarking/benchmarks/liander2024.py index 4018016a9..3315f3401 100644 --- a/packages/openstef-beam/src/openstef_beam/benchmarking/benchmarks/liander2024.py +++ b/packages/openstef-beam/src/openstef_beam/benchmarking/benchmarks/liander2024.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/src/openstef_beam/benchmarking/callbacks/__init__.py b/packages/openstef-beam/src/openstef_beam/benchmarking/callbacks/__init__.py index 6cea43259..eeda01bff 100644 --- a/packages/openstef-beam/src/openstef_beam/benchmarking/callbacks/__init__.py +++ b/packages/openstef-beam/src/openstef_beam/benchmarking/callbacks/__init__.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/src/openstef_beam/benchmarking/callbacks/base.py b/packages/openstef-beam/src/openstef_beam/benchmarking/callbacks/base.py index 4b9eef430..56d4b6f4e 100644 --- a/packages/openstef-beam/src/openstef_beam/benchmarking/callbacks/base.py +++ b/packages/openstef-beam/src/openstef_beam/benchmarking/callbacks/base.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/src/openstef_beam/benchmarking/callbacks/strict_execution_callback.py b/packages/openstef-beam/src/openstef_beam/benchmarking/callbacks/strict_execution_callback.py index d8c3efe9b..834fb682a 100644 --- a/packages/openstef-beam/src/openstef_beam/benchmarking/callbacks/strict_execution_callback.py +++ b/packages/openstef-beam/src/openstef_beam/benchmarking/callbacks/strict_execution_callback.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/src/openstef_beam/benchmarking/models/__init__.py b/packages/openstef-beam/src/openstef_beam/benchmarking/models/__init__.py index 438ad0acd..1480ae8ce 100644 --- a/packages/openstef-beam/src/openstef_beam/benchmarking/models/__init__.py +++ b/packages/openstef-beam/src/openstef_beam/benchmarking/models/__init__.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/src/openstef_beam/benchmarking/models/benchmark_target.py b/packages/openstef-beam/src/openstef_beam/benchmarking/models/benchmark_target.py index d164d5682..10417dc27 100644 --- a/packages/openstef-beam/src/openstef_beam/benchmarking/models/benchmark_target.py +++ b/packages/openstef-beam/src/openstef_beam/benchmarking/models/benchmark_target.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/src/openstef_beam/benchmarking/storage/__init__.py b/packages/openstef-beam/src/openstef_beam/benchmarking/storage/__init__.py index 7ff55f500..c783d91f8 100644 --- a/packages/openstef-beam/src/openstef_beam/benchmarking/storage/__init__.py +++ b/packages/openstef-beam/src/openstef_beam/benchmarking/storage/__init__.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/src/openstef_beam/benchmarking/storage/base.py b/packages/openstef-beam/src/openstef_beam/benchmarking/storage/base.py index 0b64e06c5..5b42dd2ed 100644 --- a/packages/openstef-beam/src/openstef_beam/benchmarking/storage/base.py +++ b/packages/openstef-beam/src/openstef_beam/benchmarking/storage/base.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/src/openstef_beam/benchmarking/storage/local_storage.py b/packages/openstef-beam/src/openstef_beam/benchmarking/storage/local_storage.py index 1f0c26639..892cd09ec 100644 --- a/packages/openstef-beam/src/openstef_beam/benchmarking/storage/local_storage.py +++ b/packages/openstef-beam/src/openstef_beam/benchmarking/storage/local_storage.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/src/openstef_beam/benchmarking/storage/s3_storage.py b/packages/openstef-beam/src/openstef_beam/benchmarking/storage/s3_storage.py index 477391903..e51232bfb 100644 --- a/packages/openstef-beam/src/openstef_beam/benchmarking/storage/s3_storage.py +++ b/packages/openstef-beam/src/openstef_beam/benchmarking/storage/s3_storage.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/src/openstef_beam/benchmarking/target_provider.py b/packages/openstef-beam/src/openstef_beam/benchmarking/target_provider.py index bbd2c7bb2..c9f084f0e 100644 --- a/packages/openstef-beam/src/openstef_beam/benchmarking/target_provider.py +++ b/packages/openstef-beam/src/openstef_beam/benchmarking/target_provider.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/src/openstef_beam/evaluation/__init__.py b/packages/openstef-beam/src/openstef_beam/evaluation/__init__.py index 1a378f004..d2474a9cf 100644 --- a/packages/openstef-beam/src/openstef_beam/evaluation/__init__.py +++ b/packages/openstef-beam/src/openstef_beam/evaluation/__init__.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/src/openstef_beam/evaluation/evaluation_pipeline.py b/packages/openstef-beam/src/openstef_beam/evaluation/evaluation_pipeline.py index 6355735d5..d7bbe7963 100644 --- a/packages/openstef-beam/src/openstef_beam/evaluation/evaluation_pipeline.py +++ b/packages/openstef-beam/src/openstef_beam/evaluation/evaluation_pipeline.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 @@ -237,6 +237,10 @@ def _iterate_subsets( if evaluation_mask is not None: predictions_filtered = predictions_filtered.filter_index(evaluation_mask) + # Remove target column from predictions to avoid duplication + if target_column in predictions_filtered.data.columns: + predictions_filtered = predictions_filtered.pipe_pandas(lambda df: df.drop(columns=[target_column])) + yield ( lead_time, ForecastDataset( diff --git a/packages/openstef-beam/src/openstef_beam/evaluation/metric_providers.py b/packages/openstef-beam/src/openstef_beam/evaluation/metric_providers.py index d1b7abc6f..95d79e00f 100644 --- a/packages/openstef-beam/src/openstef_beam/evaluation/metric_providers.py +++ b/packages/openstef-beam/src/openstef_beam/evaluation/metric_providers.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/src/openstef_beam/evaluation/models/__init__.py b/packages/openstef-beam/src/openstef_beam/evaluation/models/__init__.py index d93e0acd8..198bbc210 100644 --- a/packages/openstef-beam/src/openstef_beam/evaluation/models/__init__.py +++ b/packages/openstef-beam/src/openstef_beam/evaluation/models/__init__.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/src/openstef_beam/evaluation/models/report.py b/packages/openstef-beam/src/openstef_beam/evaluation/models/report.py index 7766d5947..f466dc713 100644 --- a/packages/openstef-beam/src/openstef_beam/evaluation/models/report.py +++ b/packages/openstef-beam/src/openstef_beam/evaluation/models/report.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 @@ -64,13 +64,11 @@ def read_parquet(cls, path: Path) -> Self: metrics = TypeAdapter[list[SubsetMetric]](list[SubsetMetric]).validate_json( (path / "metrics.json").read_bytes() ) - filtering = TypeAdapter[Filtering](Filtering).validate_python(path.name) + # Reverse sanitization: convert underscores back to colons for parsing + filtering_str = path.name.replace("_", ":") + filtering = TypeAdapter[Filtering](Filtering).validate_python(filtering_str) subset = ForecastDataset.read_parquet(path / "subset.parquet") - return cls( - filtering=filtering, - subset=subset, - metrics=metrics, - ) + return cls(filtering=filtering, subset=subset, metrics=metrics) def get_global_metric(self) -> SubsetMetric | None: """Returns the SubsetMetric with window='global', or None if not found.""" @@ -129,7 +127,9 @@ def to_parquet(self, path: Path): """ path.mkdir(parents=True, exist_ok=True) for subset_report in self.subset_reports: - subset_report.to_parquet(path / str(subset_report.filtering)) + # Sanitize filtering name for Windows compatibility (replace colons) + filtering_name = str(subset_report.filtering).replace(":", "_") + subset_report.to_parquet(path / filtering_name) @classmethod def read_parquet(cls, path: Path) -> Self: diff --git a/packages/openstef-beam/src/openstef_beam/evaluation/models/subset.py b/packages/openstef-beam/src/openstef_beam/evaluation/models/subset.py index 0a1414899..16a5e7f63 100644 --- a/packages/openstef-beam/src/openstef_beam/evaluation/models/subset.py +++ b/packages/openstef-beam/src/openstef_beam/evaluation/models/subset.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/src/openstef_beam/evaluation/models/window.py b/packages/openstef-beam/src/openstef_beam/evaluation/models/window.py index 318802d0e..2212af3d0 100644 --- a/packages/openstef-beam/src/openstef_beam/evaluation/models/window.py +++ b/packages/openstef-beam/src/openstef_beam/evaluation/models/window.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/src/openstef_beam/evaluation/window_iterators.py b/packages/openstef-beam/src/openstef_beam/evaluation/window_iterators.py index 1e5f2045c..7188bf3f0 100644 --- a/packages/openstef-beam/src/openstef_beam/evaluation/window_iterators.py +++ b/packages/openstef-beam/src/openstef_beam/evaluation/window_iterators.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/src/openstef_beam/metrics/__init__.py b/packages/openstef-beam/src/openstef_beam/metrics/__init__.py index 96758954d..ea4ccf7ce 100644 --- a/packages/openstef-beam/src/openstef_beam/metrics/__init__.py +++ b/packages/openstef-beam/src/openstef_beam/metrics/__init__.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/src/openstef_beam/metrics/metrics_deterministic.py b/packages/openstef-beam/src/openstef_beam/metrics/metrics_deterministic.py index 2de97042d..f77f55579 100644 --- a/packages/openstef-beam/src/openstef_beam/metrics/metrics_deterministic.py +++ b/packages/openstef-beam/src/openstef_beam/metrics/metrics_deterministic.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/src/openstef_beam/metrics/metrics_probabilistic.py b/packages/openstef-beam/src/openstef_beam/metrics/metrics_probabilistic.py index 79bfa85f7..a49f2386c 100644 --- a/packages/openstef-beam/src/openstef_beam/metrics/metrics_probabilistic.py +++ b/packages/openstef-beam/src/openstef_beam/metrics/metrics_probabilistic.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/tests/__init__.py b/packages/openstef-beam/tests/__init__.py index 81747127d..72baaab86 100644 --- a/packages/openstef-beam/tests/__init__.py +++ b/packages/openstef-beam/tests/__init__.py @@ -1,3 +1,3 @@ -# SPDX-FileCopyrightText: 2017-2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2017-2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/tests/unit/__init__.py b/packages/openstef-beam/tests/unit/__init__.py index 81747127d..72baaab86 100644 --- a/packages/openstef-beam/tests/unit/__init__.py +++ b/packages/openstef-beam/tests/unit/__init__.py @@ -1,3 +1,3 @@ -# SPDX-FileCopyrightText: 2017-2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2017-2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/tests/unit/analysis/plots/test_forecast_time_series_plotter.py b/packages/openstef-beam/tests/unit/analysis/plots/test_forecast_time_series_plotter.py index b860adeb2..98acb3111 100644 --- a/packages/openstef-beam/tests/unit/analysis/plots/test_forecast_time_series_plotter.py +++ b/packages/openstef-beam/tests/unit/analysis/plots/test_forecast_time_series_plotter.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 # pyright: basic, reportAttributeAccessIssue=false @@ -23,11 +23,11 @@ def test_add_model_with_forecast_only(): plotter.add_model(model_name="Model A", forecast=forecast) # Assert - assert len(plotter.models_data) == 1 - assert plotter.models_data[0]["model_name"] == "Model A" - assert plotter.models_data[0]["forecast"] is not None - pd.testing.assert_series_equal(plotter.models_data[0]["forecast"], forecast) - assert plotter.models_data[0]["quantiles"] is None + assert len(plotter._models_data) == 1 + assert plotter._models_data[0]["model_name"] == "Model A" + assert plotter._models_data[0]["forecast"] is not None + pd.testing.assert_series_equal(plotter._models_data[0]["forecast"], forecast) + assert plotter._models_data[0]["quantiles"] is None def test_add_model_with_quantiles_only(): @@ -42,11 +42,11 @@ def test_add_model_with_quantiles_only(): plotter.add_model(model_name="Model B", quantiles=quantiles) # Assert - assert len(plotter.models_data) == 1 - assert plotter.models_data[0]["model_name"] == "Model B" - assert plotter.models_data[0]["forecast"] is None - assert plotter.models_data[0]["quantiles"] is not None - pd.testing.assert_frame_equal(plotter.models_data[0]["quantiles"], quantiles) + assert len(plotter._models_data) == 1 + assert plotter._models_data[0]["model_name"] == "Model B" + assert plotter._models_data[0]["forecast"] is None + assert plotter._models_data[0]["quantiles"] is not None + pd.testing.assert_frame_equal(plotter._models_data[0]["quantiles"], quantiles) def test_add_model_with_forecast_and_quantiles(): @@ -64,12 +64,12 @@ def test_add_model_with_forecast_and_quantiles(): plotter.add_model(model_name="Model C", forecast=forecast, quantiles=quantiles) # Assert - assert len(plotter.models_data) == 1 - assert plotter.models_data[0]["model_name"] == "Model C" - assert plotter.models_data[0]["forecast"] is not None - pd.testing.assert_series_equal(plotter.models_data[0]["forecast"], forecast) - assert plotter.models_data[0]["quantiles"] is not None - pd.testing.assert_frame_equal(plotter.models_data[0]["quantiles"], quantiles) + assert len(plotter._models_data) == 1 + assert plotter._models_data[0]["model_name"] == "Model C" + assert plotter._models_data[0]["forecast"] is not None + pd.testing.assert_series_equal(plotter._models_data[0]["forecast"], forecast) + assert plotter._models_data[0]["quantiles"] is not None + pd.testing.assert_frame_equal(plotter._models_data[0]["quantiles"], quantiles) def test_method_chaining(): @@ -88,11 +88,11 @@ def test_method_chaining(): ) # Assert - assert plotter.measurements is not None - pd.testing.assert_series_equal(plotter.measurements, measurements) - assert len(plotter.models_data) == 2 - assert plotter.models_data[0]["model_name"] == "Model 1" - assert plotter.models_data[1]["model_name"] == "Model 2" + assert plotter._measurements is not None + pd.testing.assert_series_equal(plotter._measurements, measurements) + assert len(plotter._models_data) == 2 + assert plotter._models_data[0]["model_name"] == "Model 1" + assert plotter._models_data[1]["model_name"] == "Model 2" def test_add_model_raises_if_no_data(): @@ -126,8 +126,8 @@ def test_add_measurements(): plotter.add_measurements(measurements) # Assert - assert plotter.measurements is not None - pd.testing.assert_series_equal(plotter.measurements, measurements) + assert plotter._measurements is not None + pd.testing.assert_series_equal(plotter._measurements, measurements) def test_plot_with_no_data_raises(): diff --git a/packages/openstef-beam/tests/unit/analysis/plots/test_grouped_target_metric_plotter.py b/packages/openstef-beam/tests/unit/analysis/plots/test_grouped_target_metric_plotter.py index be9aa516b..227d54a83 100644 --- a/packages/openstef-beam/tests/unit/analysis/plots/test_grouped_target_metric_plotter.py +++ b/packages/openstef-beam/tests/unit/analysis/plots/test_grouped_target_metric_plotter.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/tests/unit/analysis/plots/test_precision_recall_curve_plotter.py b/packages/openstef-beam/tests/unit/analysis/plots/test_precision_recall_curve_plotter.py index 79382fe1c..79853e716 100644 --- a/packages/openstef-beam/tests/unit/analysis/plots/test_precision_recall_curve_plotter.py +++ b/packages/openstef-beam/tests/unit/analysis/plots/test_precision_recall_curve_plotter.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/tests/unit/analysis/plots/test_quantile_calibration_box_plotter.py b/packages/openstef-beam/tests/unit/analysis/plots/test_quantile_calibration_box_plotter.py index dd0b753a8..61782eff6 100644 --- a/packages/openstef-beam/tests/unit/analysis/plots/test_quantile_calibration_box_plotter.py +++ b/packages/openstef-beam/tests/unit/analysis/plots/test_quantile_calibration_box_plotter.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 # pyright: basic diff --git a/packages/openstef-beam/tests/unit/analysis/plots/test_quantile_probability_plotter.py b/packages/openstef-beam/tests/unit/analysis/plots/test_quantile_probability_plotter.py index 521093428..84bf048d8 100644 --- a/packages/openstef-beam/tests/unit/analysis/plots/test_quantile_probability_plotter.py +++ b/packages/openstef-beam/tests/unit/analysis/plots/test_quantile_probability_plotter.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/tests/unit/analysis/plots/test_summary_table_plotter.py b/packages/openstef-beam/tests/unit/analysis/plots/test_summary_table_plotter.py index 756b50dc8..1c38373e5 100644 --- a/packages/openstef-beam/tests/unit/analysis/plots/test_summary_table_plotter.py +++ b/packages/openstef-beam/tests/unit/analysis/plots/test_summary_table_plotter.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/tests/unit/analysis/plots/test_windowed_metric_plotter.py b/packages/openstef-beam/tests/unit/analysis/plots/test_windowed_metric_plotter.py index f5a54c6ed..8b1097f09 100644 --- a/packages/openstef-beam/tests/unit/analysis/plots/test_windowed_metric_plotter.py +++ b/packages/openstef-beam/tests/unit/analysis/plots/test_windowed_metric_plotter.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/tests/unit/analysis/test_analysis_pipeline.py b/packages/openstef-beam/tests/unit/analysis/test_analysis_pipeline.py index 0c4f25b82..e6b6dfe6c 100644 --- a/packages/openstef-beam/tests/unit/analysis/test_analysis_pipeline.py +++ b/packages/openstef-beam/tests/unit/analysis/test_analysis_pipeline.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/tests/unit/analysis/visualizations/conftest.py b/packages/openstef-beam/tests/unit/analysis/visualizations/conftest.py index 126c9b923..50220bd94 100644 --- a/packages/openstef-beam/tests/unit/analysis/visualizations/conftest.py +++ b/packages/openstef-beam/tests/unit/analysis/visualizations/conftest.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/tests/unit/analysis/visualizations/test_grouped_target_metric_visualization.py b/packages/openstef-beam/tests/unit/analysis/visualizations/test_grouped_target_metric_visualization.py index 5b1bed99d..adf965f44 100644 --- a/packages/openstef-beam/tests/unit/analysis/visualizations/test_grouped_target_metric_visualization.py +++ b/packages/openstef-beam/tests/unit/analysis/visualizations/test_grouped_target_metric_visualization.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/tests/unit/analysis/visualizations/test_precision_recall_curve_visualization.py b/packages/openstef-beam/tests/unit/analysis/visualizations/test_precision_recall_curve_visualization.py index 11a0d176f..df6740b88 100644 --- a/packages/openstef-beam/tests/unit/analysis/visualizations/test_precision_recall_curve_visualization.py +++ b/packages/openstef-beam/tests/unit/analysis/visualizations/test_precision_recall_curve_visualization.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/tests/unit/analysis/visualizations/test_quantile_calibration_box_visualization.py b/packages/openstef-beam/tests/unit/analysis/visualizations/test_quantile_calibration_box_visualization.py index f28d414b2..480860433 100644 --- a/packages/openstef-beam/tests/unit/analysis/visualizations/test_quantile_calibration_box_visualization.py +++ b/packages/openstef-beam/tests/unit/analysis/visualizations/test_quantile_calibration_box_visualization.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/tests/unit/analysis/visualizations/test_quantile_probability_visualization.py b/packages/openstef-beam/tests/unit/analysis/visualizations/test_quantile_probability_visualization.py index 99532b06d..8d9f572c8 100644 --- a/packages/openstef-beam/tests/unit/analysis/visualizations/test_quantile_probability_visualization.py +++ b/packages/openstef-beam/tests/unit/analysis/visualizations/test_quantile_probability_visualization.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/tests/unit/analysis/visualizations/test_summary_table_visualization.py b/packages/openstef-beam/tests/unit/analysis/visualizations/test_summary_table_visualization.py index 8e335b0da..dd733592e 100644 --- a/packages/openstef-beam/tests/unit/analysis/visualizations/test_summary_table_visualization.py +++ b/packages/openstef-beam/tests/unit/analysis/visualizations/test_summary_table_visualization.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/tests/unit/analysis/visualizations/test_timeseries_visualization.py b/packages/openstef-beam/tests/unit/analysis/visualizations/test_timeseries_visualization.py index 768595157..eef82d89e 100644 --- a/packages/openstef-beam/tests/unit/analysis/visualizations/test_timeseries_visualization.py +++ b/packages/openstef-beam/tests/unit/analysis/visualizations/test_timeseries_visualization.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 @@ -146,12 +146,10 @@ def test_connect_gaps_parameter_integration( ): """Test that connect_gaps parameter works end-to-end for both True and False values.""" # Arrange - viz = TimeSeriesVisualization(name="test_viz") - viz.connect_gaps = connect_gaps_value + viz = TimeSeriesVisualization(name="test_viz", connect_gaps=connect_gaps_value) # Act with ( - patch.object(ForecastTimeSeriesPlotter, "__init__", return_value=None) as mock_init, patch.object(ForecastTimeSeriesPlotter, "plot", return_value=mock_plotly_figure), patch.object(ForecastTimeSeriesPlotter, "add_measurements"), patch.object(ForecastTimeSeriesPlotter, "add_model"), @@ -160,6 +158,5 @@ def test_connect_gaps_parameter_integration( result = viz.create_by_none(sample_evaluation_report, simple_target_metadata) # Assert - mock_init.assert_called_once_with(connect_gaps=connect_gaps_value) assert result.name == viz.name assert result.figure == mock_plotly_figure diff --git a/packages/openstef-beam/tests/unit/analysis/visualizations/test_windowed_metric_visualization.py b/packages/openstef-beam/tests/unit/analysis/visualizations/test_windowed_metric_visualization.py index a5d97f5d9..0d905fc67 100644 --- a/packages/openstef-beam/tests/unit/analysis/visualizations/test_windowed_metric_visualization.py +++ b/packages/openstef-beam/tests/unit/analysis/visualizations/test_windowed_metric_visualization.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/tests/unit/backtesting/test_backtest_event_generator.py b/packages/openstef-beam/tests/unit/backtesting/test_backtest_event_generator.py index 7d86f4004..b5497629d 100644 --- a/packages/openstef-beam/tests/unit/backtesting/test_backtest_event_generator.py +++ b/packages/openstef-beam/tests/unit/backtesting/test_backtest_event_generator.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/tests/unit/backtesting/test_backtest_pipeline.py b/packages/openstef-beam/tests/unit/backtesting/test_backtest_pipeline.py index 8fbea70b0..9e68fd5a1 100644 --- a/packages/openstef-beam/tests/unit/backtesting/test_backtest_pipeline.py +++ b/packages/openstef-beam/tests/unit/backtesting/test_backtest_pipeline.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/tests/unit/backtesting/test_batch_prediction.py b/packages/openstef-beam/tests/unit/backtesting/test_batch_prediction.py index e7ff1ddd4..09e6ac4f0 100644 --- a/packages/openstef-beam/tests/unit/backtesting/test_batch_prediction.py +++ b/packages/openstef-beam/tests/unit/backtesting/test_batch_prediction.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/tests/unit/benchmarking/storage/test_local_storage.py b/packages/openstef-beam/tests/unit/benchmarking/storage/test_local_storage.py index d789bbd4e..a666a79d6 100644 --- a/packages/openstef-beam/tests/unit/benchmarking/storage/test_local_storage.py +++ b/packages/openstef-beam/tests/unit/benchmarking/storage/test_local_storage.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/tests/unit/benchmarking/storage/test_s3_storage.py b/packages/openstef-beam/tests/unit/benchmarking/storage/test_s3_storage.py index e2edd2087..88c8bbec0 100644 --- a/packages/openstef-beam/tests/unit/benchmarking/storage/test_s3_storage.py +++ b/packages/openstef-beam/tests/unit/benchmarking/storage/test_s3_storage.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/tests/unit/benchmarking/test_benchmark_pipeline.py b/packages/openstef-beam/tests/unit/benchmarking/test_benchmark_pipeline.py index 1f18c83b8..fb73e9f0c 100644 --- a/packages/openstef-beam/tests/unit/benchmarking/test_benchmark_pipeline.py +++ b/packages/openstef-beam/tests/unit/benchmarking/test_benchmark_pipeline.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/tests/unit/benchmarking/test_target_provider.py b/packages/openstef-beam/tests/unit/benchmarking/test_target_provider.py index 7ee94db89..1692f92b4 100644 --- a/packages/openstef-beam/tests/unit/benchmarking/test_target_provider.py +++ b/packages/openstef-beam/tests/unit/benchmarking/test_target_provider.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/tests/unit/evaluation/__init__.py b/packages/openstef-beam/tests/unit/evaluation/__init__.py index 81747127d..72baaab86 100644 --- a/packages/openstef-beam/tests/unit/evaluation/__init__.py +++ b/packages/openstef-beam/tests/unit/evaluation/__init__.py @@ -1,3 +1,3 @@ -# SPDX-FileCopyrightText: 2017-2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2017-2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/tests/unit/evaluation/models/__init__.py b/packages/openstef-beam/tests/unit/evaluation/models/__init__.py index 81747127d..72baaab86 100644 --- a/packages/openstef-beam/tests/unit/evaluation/models/__init__.py +++ b/packages/openstef-beam/tests/unit/evaluation/models/__init__.py @@ -1,3 +1,3 @@ -# SPDX-FileCopyrightText: 2017-2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2017-2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/tests/unit/evaluation/models/test_window.py b/packages/openstef-beam/tests/unit/evaluation/models/test_window.py index dd6bf378b..084f67766 100644 --- a/packages/openstef-beam/tests/unit/evaluation/models/test_window.py +++ b/packages/openstef-beam/tests/unit/evaluation/models/test_window.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/tests/unit/evaluation/test_evaluation_pipeline.py b/packages/openstef-beam/tests/unit/evaluation/test_evaluation_pipeline.py index 8e70dd7ae..256a176b5 100644 --- a/packages/openstef-beam/tests/unit/evaluation/test_evaluation_pipeline.py +++ b/packages/openstef-beam/tests/unit/evaluation/test_evaluation_pipeline.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/tests/unit/evaluation/test_metric_provider.py b/packages/openstef-beam/tests/unit/evaluation/test_metric_provider.py index 9ecabdaa6..afd3acca6 100644 --- a/packages/openstef-beam/tests/unit/evaluation/test_metric_provider.py +++ b/packages/openstef-beam/tests/unit/evaluation/test_metric_provider.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/tests/unit/metrics/test_metrics_deterministic.py b/packages/openstef-beam/tests/unit/metrics/test_metrics_deterministic.py index 66960c087..b3b9ac35d 100644 --- a/packages/openstef-beam/tests/unit/metrics/test_metrics_deterministic.py +++ b/packages/openstef-beam/tests/unit/metrics/test_metrics_deterministic.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/tests/unit/metrics/test_metrics_probabilistic.py b/packages/openstef-beam/tests/unit/metrics/test_metrics_probabilistic.py index a05bfbd7f..c9bb5d63c 100644 --- a/packages/openstef-beam/tests/unit/metrics/test_metrics_probabilistic.py +++ b/packages/openstef-beam/tests/unit/metrics/test_metrics_probabilistic.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-beam/tests/utils/mocks.py b/packages/openstef-beam/tests/utils/mocks.py index 6943e49db..9d46a7b77 100644 --- a/packages/openstef-beam/tests/utils/mocks.py +++ b/packages/openstef-beam/tests/utils/mocks.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-core/README.md b/packages/openstef-core/README.md index b2d10e0ab..ed6c7ed13 100644 --- a/packages/openstef-core/README.md +++ b/packages/openstef-core/README.md @@ -1,7 +1,7 @@ -# openstef-core \ No newline at end of file +# openstef-core diff --git a/packages/openstef-core/pyproject.toml b/packages/openstef-core/pyproject.toml index 75f531b99..cc436ee40 100644 --- a/packages/openstef-core/pyproject.toml +++ b/packages/openstef-core/pyproject.toml @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 @@ -15,7 +15,7 @@ readme = "README.md" keywords = [ "energy", "forecasting", "machinelearning" ] license = "MPL-2.0" authors = [ - { name = "Alliander N.V", email = "short.term.energy.forecasts@alliander.com" }, + { name = "Alliander N.V", email = "openstef@lfenergy.org" }, ] requires-python = ">=3.12,<4.0" classifiers = [ diff --git a/packages/openstef-core/src/openstef_core/__init__.py b/packages/openstef-core/src/openstef_core/__init__.py index a174d8d83..b5c91ce93 100644 --- a/packages/openstef-core/src/openstef_core/__init__.py +++ b/packages/openstef-core/src/openstef_core/__init__.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2017-2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2017-2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 """Core functionality for OpenSTEF, a framework for short-term energy forecasting.""" diff --git a/packages/openstef-core/src/openstef_core/base_model.py b/packages/openstef-core/src/openstef_core/base_model.py index eb9b9fac5..0f44e6137 100644 --- a/packages/openstef-core/src/openstef_core/base_model.py +++ b/packages/openstef-core/src/openstef_core/base_model.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-core/src/openstef_core/datasets/__init__.py b/packages/openstef-core/src/openstef_core/datasets/__init__.py index fd6cd4b40..153ed6611 100644 --- a/packages/openstef-core/src/openstef_core/datasets/__init__.py +++ b/packages/openstef-core/src/openstef_core/datasets/__init__.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-core/src/openstef_core/datasets/mixins.py b/packages/openstef-core/src/openstef_core/datasets/mixins.py index 8c6feb579..613906f07 100644 --- a/packages/openstef-core/src/openstef_core/datasets/mixins.py +++ b/packages/openstef-core/src/openstef_core/datasets/mixins.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-core/src/openstef_core/datasets/timeseries_dataset.py b/packages/openstef-core/src/openstef_core/datasets/timeseries_dataset.py index 101bbb337..d4dfb63d2 100644 --- a/packages/openstef-core/src/openstef_core/datasets/timeseries_dataset.py +++ b/packages/openstef-core/src/openstef_core/datasets/timeseries_dataset.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-core/src/openstef_core/datasets/validated_datasets.py b/packages/openstef-core/src/openstef_core/datasets/validated_datasets.py index dd39f3ba4..bb06368d6 100644 --- a/packages/openstef-core/src/openstef_core/datasets/validated_datasets.py +++ b/packages/openstef-core/src/openstef_core/datasets/validated_datasets.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 @@ -250,12 +250,14 @@ def __init__( *, horizon_column: str = "horizon", available_at_column: str = "available_at", + standard_deviation_column: str = "stdev", ) -> None: if "forecast_start" in data.attrs: self.forecast_start = datetime.fromisoformat(data.attrs["forecast_start"]) else: self.forecast_start = forecast_start if forecast_start is not None else data.index.min().to_pydatetime() self.target_column = data.attrs.get("target_column", target_column) + self.standard_deviation_column = data.attrs.get("standard_deviation_column", standard_deviation_column) super().__init__( data=data, @@ -264,7 +266,8 @@ def __init__( available_at_column=available_at_column, ) - quantile_feature_names = [col for col in self.feature_names if col != target_column] + exclude_columns = {target_column, standard_deviation_column} + quantile_feature_names = [col for col in self.feature_names if col not in exclude_columns] if not all(Quantile.is_valid_quantile_string(col) for col in quantile_feature_names): raise ValueError("All feature names must be valid quantile strings.") @@ -296,6 +299,20 @@ def median_series(self) -> pd.Series: raise MissingColumnsError(missing_columns=[median_col]) return self.data[median_col] + @property + def standard_deviation_series(self) -> pd.Series: + """Extract the standard deviation series if it exists. + + Returns: + Time series containing standard deviation values with original datetime index. + + Raises: + MissingColumnsError: If the standard deviation column is not found. + """ + if self.standard_deviation_column not in self.data.columns: + raise MissingColumnsError(missing_columns=[self.standard_deviation_column]) + return self.data[self.standard_deviation_column] # pyright: ignore[reportUnknownVariableType] + @property def quantiles_data(self) -> pd.DataFrame: """Extract DataFrame containing only the quantile forecast columns. @@ -331,6 +348,7 @@ def to_pandas(self) -> pd.DataFrame: df = super().to_pandas() df.attrs["target_column"] = self.target_column df.attrs["forecast_start"] = self.forecast_start.isoformat() + df.attrs["standard_deviation_column"] = self.standard_deviation_column return df @classmethod diff --git a/packages/openstef-core/src/openstef_core/datasets/validation.py b/packages/openstef-core/src/openstef_core/datasets/validation.py index f4d176862..2f62abb5c 100644 --- a/packages/openstef-core/src/openstef_core/datasets/validation.py +++ b/packages/openstef-core/src/openstef_core/datasets/validation.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-core/src/openstef_core/datasets/versioned_timeseries_dataset.py b/packages/openstef-core/src/openstef_core/datasets/versioned_timeseries_dataset.py index 280db518f..e430e4a49 100644 --- a/packages/openstef-core/src/openstef_core/datasets/versioned_timeseries_dataset.py +++ b/packages/openstef-core/src/openstef_core/datasets/versioned_timeseries_dataset.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-core/src/openstef_core/exceptions.py b/packages/openstef-core/src/openstef_core/exceptions.py index 061f0b824..29ef4bcc6 100644 --- a/packages/openstef-core/src/openstef_core/exceptions.py +++ b/packages/openstef-core/src/openstef_core/exceptions.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-core/src/openstef_core/mixins/__init__.py b/packages/openstef-core/src/openstef_core/mixins/__init__.py index 6fc5a4605..0da051876 100644 --- a/packages/openstef-core/src/openstef_core/mixins/__init__.py +++ b/packages/openstef-core/src/openstef_core/mixins/__init__.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-core/src/openstef_core/mixins/predictor.py b/packages/openstef-core/src/openstef_core/mixins/predictor.py index 18b2b811e..4e237b234 100644 --- a/packages/openstef-core/src/openstef_core/mixins/predictor.py +++ b/packages/openstef-core/src/openstef_core/mixins/predictor.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-core/src/openstef_core/mixins/stateful.py b/packages/openstef-core/src/openstef_core/mixins/stateful.py index 49d3e45e3..71fe48286 100644 --- a/packages/openstef-core/src/openstef_core/mixins/stateful.py +++ b/packages/openstef-core/src/openstef_core/mixins/stateful.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-core/src/openstef_core/mixins/transform.py b/packages/openstef-core/src/openstef_core/mixins/transform.py index 1254c487b..0d3eaabc6 100644 --- a/packages/openstef-core/src/openstef_core/mixins/transform.py +++ b/packages/openstef-core/src/openstef_core/mixins/transform.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-core/src/openstef_core/testing.py b/packages/openstef-core/src/openstef_core/testing.py index 31486ebc4..692c8597f 100644 --- a/packages/openstef-core/src/openstef_core/testing.py +++ b/packages/openstef-core/src/openstef_core/testing.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-core/src/openstef_core/transforms/__init__.py b/packages/openstef-core/src/openstef_core/transforms/__init__.py index 76f9f4e72..06d309f12 100644 --- a/packages/openstef-core/src/openstef_core/transforms/__init__.py +++ b/packages/openstef-core/src/openstef_core/transforms/__init__.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-core/src/openstef_core/transforms/dataset_transforms.py b/packages/openstef-core/src/openstef_core/transforms/dataset_transforms.py index 659070d6f..87b7dba4e 100644 --- a/packages/openstef-core/src/openstef_core/transforms/dataset_transforms.py +++ b/packages/openstef-core/src/openstef_core/transforms/dataset_transforms.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-core/src/openstef_core/types.py b/packages/openstef-core/src/openstef_core/types.py index 030bff083..6c989c84d 100644 --- a/packages/openstef-core/src/openstef_core/types.py +++ b/packages/openstef-core/src/openstef_core/types.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-core/src/openstef_core/utils/__init__.py b/packages/openstef-core/src/openstef_core/utils/__init__.py index e39625757..b94b9e6b5 100644 --- a/packages/openstef-core/src/openstef_core/utils/__init__.py +++ b/packages/openstef-core/src/openstef_core/utils/__init__.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-core/src/openstef_core/utils/datetime.py b/packages/openstef-core/src/openstef_core/utils/datetime.py index 68fd65c49..8258798d7 100644 --- a/packages/openstef-core/src/openstef_core/utils/datetime.py +++ b/packages/openstef-core/src/openstef_core/utils/datetime.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-core/src/openstef_core/utils/invariants.py b/packages/openstef-core/src/openstef_core/utils/invariants.py index cf912f8d5..93c5745e8 100644 --- a/packages/openstef-core/src/openstef_core/utils/invariants.py +++ b/packages/openstef-core/src/openstef_core/utils/invariants.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-core/src/openstef_core/utils/itertools.py b/packages/openstef-core/src/openstef_core/utils/itertools.py index 89e4681a8..02a4d592b 100644 --- a/packages/openstef-core/src/openstef_core/utils/itertools.py +++ b/packages/openstef-core/src/openstef_core/utils/itertools.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-core/src/openstef_core/utils/multiprocessing.py b/packages/openstef-core/src/openstef_core/utils/multiprocessing.py index 517d51bfe..65394ff01 100644 --- a/packages/openstef-core/src/openstef_core/utils/multiprocessing.py +++ b/packages/openstef-core/src/openstef_core/utils/multiprocessing.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-core/src/openstef_core/utils/pandas.py b/packages/openstef-core/src/openstef_core/utils/pandas.py index 5388079e3..f714962e7 100644 --- a/packages/openstef-core/src/openstef_core/utils/pandas.py +++ b/packages/openstef-core/src/openstef_core/utils/pandas.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-core/src/openstef_core/utils/pydantic.py b/packages/openstef-core/src/openstef_core/utils/pydantic.py index 42316515a..e74eb81cc 100644 --- a/packages/openstef-core/src/openstef_core/utils/pydantic.py +++ b/packages/openstef-core/src/openstef_core/utils/pydantic.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-core/tests/__init__.py b/packages/openstef-core/tests/__init__.py index 81747127d..72baaab86 100644 --- a/packages/openstef-core/tests/__init__.py +++ b/packages/openstef-core/tests/__init__.py @@ -1,3 +1,3 @@ -# SPDX-FileCopyrightText: 2017-2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2017-2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-core/tests/unit/datasets/test_mixins.py b/packages/openstef-core/tests/unit/datasets/test_mixins.py index 053339759..80179aa49 100644 --- a/packages/openstef-core/tests/unit/datasets/test_mixins.py +++ b/packages/openstef-core/tests/unit/datasets/test_mixins.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-core/tests/unit/datasets/test_timeseries_dataset.py b/packages/openstef-core/tests/unit/datasets/test_timeseries_dataset.py index b9e034ae8..8a72ca53c 100644 --- a/packages/openstef-core/tests/unit/datasets/test_timeseries_dataset.py +++ b/packages/openstef-core/tests/unit/datasets/test_timeseries_dataset.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-core/tests/unit/datasets/test_validation.py b/packages/openstef-core/tests/unit/datasets/test_validation.py index 35fa21d70..7f751e4b8 100644 --- a/packages/openstef-core/tests/unit/datasets/test_validation.py +++ b/packages/openstef-core/tests/unit/datasets/test_validation.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-core/tests/unit/datasets/test_versioned_timeseries_dataset.py b/packages/openstef-core/tests/unit/datasets/test_versioned_timeseries_dataset.py index 98bc23cd7..fae83f8b1 100644 --- a/packages/openstef-core/tests/unit/datasets/test_versioned_timeseries_dataset.py +++ b/packages/openstef-core/tests/unit/datasets/test_versioned_timeseries_dataset.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-core/tests/unit/datasets/utils.py b/packages/openstef-core/tests/unit/datasets/utils.py index f30c08f94..40ed1bb7b 100644 --- a/packages/openstef-core/tests/unit/datasets/utils.py +++ b/packages/openstef-core/tests/unit/datasets/utils.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-core/tests/unit/mixins/test_stateful.py b/packages/openstef-core/tests/unit/mixins/test_stateful.py index a9d179ccb..75d118603 100644 --- a/packages/openstef-core/tests/unit/mixins/test_stateful.py +++ b/packages/openstef-core/tests/unit/mixins/test_stateful.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-core/tests/unit/mixins/test_transform.py b/packages/openstef-core/tests/unit/mixins/test_transform.py index d5c3bd253..0f2351b3d 100644 --- a/packages/openstef-core/tests/unit/mixins/test_transform.py +++ b/packages/openstef-core/tests/unit/mixins/test_transform.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-core/tests/unit/test_base_model.py b/packages/openstef-core/tests/unit/test_base_model.py index f83eef5b9..11cf42441 100644 --- a/packages/openstef-core/tests/unit/test_base_model.py +++ b/packages/openstef-core/tests/unit/test_base_model.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-core/tests/unit/test_types.py b/packages/openstef-core/tests/unit/test_types.py index 0eaf393bb..b1f3af49f 100644 --- a/packages/openstef-core/tests/unit/test_types.py +++ b/packages/openstef-core/tests/unit/test_types.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-core/tests/unit/utils/test_datetime.py b/packages/openstef-core/tests/unit/utils/test_datetime.py index 2ff5b7406..c99bf9dd4 100644 --- a/packages/openstef-core/tests/unit/utils/test_datetime.py +++ b/packages/openstef-core/tests/unit/utils/test_datetime.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-core/tests/unit/utils/test_itertools.py b/packages/openstef-core/tests/unit/utils/test_itertools.py index dfc5472e9..565c4082f 100644 --- a/packages/openstef-core/tests/unit/utils/test_itertools.py +++ b/packages/openstef-core/tests/unit/utils/test_itertools.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-core/tests/unit/utils/test_multiprocessing.py b/packages/openstef-core/tests/unit/utils/test_multiprocessing.py index fead7f859..edcbbebb4 100644 --- a/packages/openstef-core/tests/unit/utils/test_multiprocessing.py +++ b/packages/openstef-core/tests/unit/utils/test_multiprocessing.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/examples/tutorials/.gitkeep b/packages/openstef-meta/README.md similarity index 100% rename from examples/tutorials/.gitkeep rename to packages/openstef-meta/README.md diff --git a/packages/openstef-meta/pyproject.toml b/packages/openstef-meta/pyproject.toml new file mode 100644 index 000000000..0f620e63b --- /dev/null +++ b/packages/openstef-meta/pyproject.toml @@ -0,0 +1,37 @@ +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# +# SPDX-License-Identifier: MPL-2.0 + +[project] +name = "openstef-meta" +version = "0.0.0" +description = "Meta models for OpenSTEF" +readme = "README.md" +keywords = [ "energy", "forecasting", "machinelearning" ] +license = "MPL-2.0" +authors = [ + { name = "Alliander N.V", email = "short.term.energy.forecasts@alliander.com" }, +] +requires-python = ">=3.12,<4.0" +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", +] + +dependencies = [ + "openstef-beam>=4.0.0.dev0,<5", + "openstef-core>=4.0.0.dev0,<5", + "openstef-models>=4.0.0.dev0,<5", +] + +urls.Documentation = "https://openstef.github.io/openstef/index.html" +urls.Homepage = "https://lfenergy.org/projects/openstef/" +urls.Issues = "https://github.com/OpenSTEF/openstef/issues" +urls.Repository = "https://github.com/OpenSTEF/openstef" + +[tool.hatch.build.targets.wheel] +packages = [ "src/openstef_meta" ] diff --git a/packages/openstef-meta/src/openstef_meta/__init__.py b/packages/openstef-meta/src/openstef_meta/__init__.py new file mode 100644 index 000000000..ff5902981 --- /dev/null +++ b/packages/openstef-meta/src/openstef_meta/__init__.py @@ -0,0 +1,13 @@ +# SPDX-FileCopyrightText: 2017-2025 Contributors to the OpenSTEF project +# +# SPDX-License-Identifier: MPL-2.0 +"""Meta models for OpenSTEF.""" + +import logging + +# Set up logging configuration +root_logger = logging.getLogger(name=__name__) +if not root_logger.handlers: + root_logger.addHandler(logging.NullHandler()) + +__all__ = [] diff --git a/packages/openstef-meta/src/openstef_meta/examples/__init__.py b/packages/openstef-meta/src/openstef_meta/examples/__init__.py new file mode 100644 index 000000000..765b7c107 --- /dev/null +++ b/packages/openstef-meta/src/openstef_meta/examples/__init__.py @@ -0,0 +1,5 @@ +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# +# SPDX-License-Identifier: MPL-2.0 + +"""Examples for OpenSTEF Meta.""" diff --git a/packages/openstef-meta/src/openstef_meta/models/__init__.py b/packages/openstef-meta/src/openstef_meta/models/__init__.py new file mode 100644 index 000000000..13175057c --- /dev/null +++ b/packages/openstef-meta/src/openstef_meta/models/__init__.py @@ -0,0 +1,5 @@ +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# +# SPDX-License-Identifier: MPL-2.0 + +"""Meta Forecasting models.""" diff --git a/packages/openstef-meta/src/openstef_meta/models/ensemble_forecasting_model.py b/packages/openstef-meta/src/openstef_meta/models/ensemble_forecasting_model.py new file mode 100644 index 000000000..9299e879d --- /dev/null +++ b/packages/openstef-meta/src/openstef_meta/models/ensemble_forecasting_model.py @@ -0,0 +1,740 @@ +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# +# SPDX-License-Identifier: MPL-2.0 + +"""High-level forecasting model that orchestrates the complete prediction pipeline. + +Combines feature engineering, forecasting, and postprocessing into a unified interface. +Handles both single-horizon and multi-horizon forecasters while providing consistent +data transformation and validation. +""" + +import logging +from datetime import datetime, timedelta +from functools import partial +from typing import cast, override + +import pandas as pd +from pydantic import Field, PrivateAttr + +from openstef_beam.evaluation import EvaluationConfig, EvaluationPipeline, SubsetMetric +from openstef_beam.evaluation.metric_providers import MetricProvider, ObservedProbabilityProvider, R2Provider +from openstef_core.base_model import BaseModel +from openstef_core.datasets import ( + ForecastDataset, + ForecastInputDataset, + TimeSeriesDataset, +) +from openstef_core.datasets.timeseries_dataset import validate_horizons_present +from openstef_core.exceptions import NotFittedError +from openstef_core.mixins import Predictor, TransformPipeline +from openstef_meta.models.forecast_combiners.forecast_combiner import ForecastCombiner +from openstef_meta.utils.datasets import EnsembleForecastDataset +from openstef_models.models.forecasting import Forecaster +from openstef_models.models.forecasting.forecaster import ForecasterConfig +from openstef_models.models.forecasting_model import ModelFitResult +from openstef_models.utils.data_split import DataSplitter + +logger = logging.getLogger(__name__) + + +class EnsembleModelFitResult(BaseModel): + forecaster_fit_results: dict[str, ModelFitResult] = Field(description="ModelFitResult for each base Forecaster") + + combiner_fit_result: ModelFitResult = Field(description="ModelFitResult for the ForecastCombiner") + + # Make compatible with ModelFitResult interface + @property + def input_dataset(self) -> EnsembleForecastDataset: + """Returns the input dataset used for fitting the combiner.""" + return cast( + "EnsembleForecastDataset", + self.combiner_fit_result.input_dataset, + ) + + @property + def input_data_train(self) -> ForecastInputDataset: + """Returns the training input data used for fitting the combiner.""" + return self.combiner_fit_result.input_data_train + + @property + def input_data_val(self) -> ForecastInputDataset | None: + """Returns the validation input data used for fitting the combiner.""" + return self.combiner_fit_result.input_data_val + + @property + def input_data_test(self) -> ForecastInputDataset | None: + """Returns the test input data used for fitting the combiner.""" + return self.combiner_fit_result.input_data_test + + @property + def metrics_train(self) -> SubsetMetric: + """Returns the full metrics calculated during combiner fitting.""" + return self.combiner_fit_result.metrics_train + + @property + def metrics_val(self) -> SubsetMetric | None: + """Returns the full metrics calculated during combiner fitting.""" + return self.combiner_fit_result.metrics_val + + @property + def metrics_test(self) -> SubsetMetric | None: + """Returns the full metrics calculated during combiner fitting.""" + return self.combiner_fit_result.metrics_test + + @property + def metrics_full(self) -> SubsetMetric: + """Returns the full metrics calculated during combiner fitting.""" + return self.combiner_fit_result.metrics_full + + +class EnsembleForecastingModel(BaseModel, Predictor[TimeSeriesDataset, ForecastDataset]): + """Complete forecasting pipeline combining preprocessing, prediction, and postprocessing. + + Orchestrates the full forecasting workflow by managing feature engineering, + model training/prediction, and result postprocessing. Automatically handles + the differences between single-horizon and multi-horizon forecasters while + ensuring data consistency and validation throughout the pipeline. + + Invariants: + - fit() must be called before predict() + - Forecaster and preprocessing horizons must match during initialization + + Important: + The `cutoff_history` parameter is crucial when using lag-based features in + preprocessing. For example, a lag-14 transformation creates NaN values for + the first 14 days of data. Set `cutoff_history` to exclude these incomplete + rows from training. You must configure this manually based on your preprocessing + pipeline since lags cannot be automatically inferred from the transforms. + + Example: + Basic forecasting workflow: + + >>> from openstef_models.models.forecasting.constant_median_forecaster import ( + ... ConstantMedianForecaster, ConstantMedianForecasterConfig + ... ) + >>> from openstef_meta.models.forecast_combiners.learned_weights_combiner import WeightsCombiner + >>> from openstef_core.types import LeadTime + >>> + >>> # Note: This is a conceptual example showing the API structure + >>> # Real usage requires implemented forecaster classes + >>> forecaster_1 = ConstantMedianForecaster( + ... config=ConstantMedianForecasterConfig(horizons=[LeadTime.from_string("PT36H")]) + ... ) + >>> forecaster_2 = ConstantMedianForecaster( + ... config=ConstantMedianForecasterConfig(horizons=[LeadTime.from_string("PT36H")]) + ... ) + >>> combiner_config = WeightsCombiner.Config( + ... horizons=[LeadTime.from_string("PT36H")], + ... ) + >>> # Create and train model + >>> model = EnsembleForecastingModel( + ... forecasters={"constant_median": forecaster_1, "constant_median_2": forecaster_2}, + ... combiner=WeightsCombiner(config=combiner_config), + ... cutoff_history=timedelta(days=14), # Match your maximum lag in preprocessing + ... ) + >>> model.fit(training_data) # doctest: +SKIP + >>> + >>> # Generate forecasts + >>> forecasts = model.predict(new_data) # doctest: +SKIP + """ + + # Forecasting components + common_preprocessing: TransformPipeline[TimeSeriesDataset] = Field( + default_factory=TransformPipeline[TimeSeriesDataset], + description="Feature engineering pipeline for transforming raw input data into model-ready features.", + exclude=True, + ) + + model_specific_preprocessing: dict[str, TransformPipeline[TimeSeriesDataset]] = Field( + default_factory=dict, + description="Feature engineering pipeline for transforming raw input data into model-ready features.", + exclude=True, + ) + + forecasters: dict[str, Forecaster] = Field( + default=..., + description="Underlying forecasting algorithm, either single-horizon or multi-horizon.", + exclude=True, + ) + + combiner: ForecastCombiner = Field( + default=..., + description="Combiner to aggregate forecasts from multiple forecasters if applicable.", + exclude=True, + ) + + combiner_preprocessing: TransformPipeline[TimeSeriesDataset] = Field( + default_factory=TransformPipeline[TimeSeriesDataset], + description="Feature engineering for the forecast combiner.", + exclude=True, + ) + + postprocessing: TransformPipeline[ForecastDataset] = Field( + default_factory=TransformPipeline[ForecastDataset], + description="Postprocessing pipeline for transforming model outputs into final forecasts.", + exclude=True, + ) + target_column: str = Field( + default="load", + description="Name of the target variable column in datasets.", + ) + data_splitter: DataSplitter = Field( + default_factory=DataSplitter, + description="Data splitting strategy for train/validation/test sets.", + ) + cutoff_history: timedelta = Field( + default=timedelta(days=0), + description="Amount of historical data to exclude from training and prediction due to incomplete features " + "from lag-based preprocessing. When using lag transforms (e.g., lag-14), the first N days contain NaN values. " + "Set this to match your maximum lag duration (e.g., timedelta(days=14)). " + "Default of 0 assumes no invalid rows are created by preprocessing.", + ) + # Evaluation + evaluation_metrics: list[MetricProvider] = Field( + default_factory=lambda: [R2Provider(), ObservedProbabilityProvider()], + description="List of metric providers for evaluating model score.", + ) + # Metadata + tags: dict[str, str] = Field( + default_factory=dict, + description="Optional metadata tags for the model.", + ) + + _logger: logging.Logger = PrivateAttr(default=logging.getLogger(__name__)) + + @property + def config(self) -> list[ForecasterConfig]: + """Returns the configuration of the underlying forecaster.""" + return [x.config for x in self.forecasters.values()] + + @property + @override + def is_fitted(self) -> bool: + return all(f.is_fitted for f in self.forecasters.values()) and self.combiner.is_fitted + + @property + def forecaster_names(self) -> list[str]: + """Returns the names of the underlying forecasters.""" + return list(self.forecasters.keys()) + + @override + def fit( + self, + data: TimeSeriesDataset, + data_val: TimeSeriesDataset | None = None, + data_test: TimeSeriesDataset | None = None, + ) -> EnsembleModelFitResult: + """Train the forecasting model on the provided dataset. + + Fits the preprocessing pipeline and underlying forecaster. Handles both + single-horizon and multi-horizon forecasters appropriately. + + The data splitting follows this sequence: + 1. Split test set from full data (using test_splitter) + 2. Split validation from remaining train+val data (using val_splitter) + 3. Train on the final training set + + Args: + data: Historical time series data with features and target values. + data_val: Optional validation data. If provided, splitters are ignored for validation. + data_test: Optional test data. If provided, splitters are ignored for test. + + Returns: + FitResult containing training details and metrics. + """ + # Fit forecasters + train_ensemble, val_ensemble, test_ensemble, forecaster_fit_results = self._fit_forecasters( + data=data, + data_val=data_val, + data_test=data_test, + ) + + combiner_fit_result = self._fit_combiner( + train_ensemble_dataset=train_ensemble, + val_ensemble_dataset=val_ensemble, + test_ensemble_dataset=test_ensemble, + data=data, + data_val=data_val, + data_test=data_test, + ) + + return EnsembleModelFitResult( + forecaster_fit_results=forecaster_fit_results, + combiner_fit_result=combiner_fit_result, + ) + + @staticmethod + def _combine_datasets( + data: ForecastInputDataset, additional_features: ForecastInputDataset + ) -> ForecastInputDataset: + """Combine Forecaster learner predictions with additional features for ForecastCombiner input. + + Args: + data: ForecastInputDataset containing base Forecaster predictions. + additional_features: ForecastInputDataset containing additional features. + + Returns: + ForecastInputDataset with combined features. + """ + additional_df = additional_features.data.loc[ + :, [col for col in additional_features.data.columns if col not in data.data.columns] + ] + # Merge on index to combine datasets + combined_df = data.data.join(additional_df) + + return ForecastInputDataset( + data=combined_df, + sample_interval=data.sample_interval, + forecast_start=data.forecast_start, + ) + + def _transform_combiner_data(self, data: TimeSeriesDataset) -> ForecastInputDataset | None: + if len(self.combiner_preprocessing.transforms) == 0: + return None + combiner_data = self.combiner_preprocessing.transform(data) + return ForecastInputDataset.from_timeseries(combiner_data, target_column=self.target_column) + + def _fit_prepare_combiner_data( + self, + data: TimeSeriesDataset, + data_val: TimeSeriesDataset | None = None, + data_test: TimeSeriesDataset | None = None, + ) -> tuple[ForecastInputDataset | None, ForecastInputDataset | None, ForecastInputDataset | None]: + + if len(self.combiner_preprocessing.transforms) == 0: + return None, None, None + self.combiner_preprocessing.fit(data=data) + + input_data_train = self.combiner_preprocessing.transform(data) + input_data_val = self.combiner_preprocessing.transform(data_val) if data_val else None + input_data_test = self.combiner_preprocessing.transform(data_test) if data_test else None + + input_data_train, input_data_val, input_data_test = self.data_splitter.split_dataset( + data=input_data_train, data_val=input_data_val, data_test=input_data_test, target_column=self.target_column + ) + combiner_data = ForecastInputDataset.from_timeseries(input_data_train, target_column=self.target_column) + + combiner_data_val = ( + ForecastInputDataset.from_timeseries(input_data_val, target_column=self.target_column) + if input_data_val + else None + ) + + combiner_data_test = ( + ForecastInputDataset.from_timeseries(input_data_test, target_column=self.target_column) + if input_data_test + else None + ) + + return combiner_data, combiner_data_val, combiner_data_test + + def _fit_forecasters( + self, + data: TimeSeriesDataset, + data_val: TimeSeriesDataset | None = None, + data_test: TimeSeriesDataset | None = None, + ) -> tuple[ + EnsembleForecastDataset, + EnsembleForecastDataset | None, + EnsembleForecastDataset | None, + dict[str, ModelFitResult], + ]: + + predictions_train: dict[str, ForecastDataset] = {} + predictions_val: dict[str, ForecastDataset | None] = {} + predictions_test: dict[str, ForecastDataset | None] = {} + results: dict[str, ModelFitResult] = {} + + # Fit the feature engineering transforms + self.common_preprocessing.fit(data=data) + data_transformed = self.common_preprocessing.transform(data=data) + [ + self.model_specific_preprocessing[name].fit(data=data_transformed) + for name in self.model_specific_preprocessing + ] + logger.debug("Completed fitting preprocessing pipelines.") + + # Fit the forecasters + for name in self.forecasters: + logger.debug("Fitting Forecaster '%s'.", name) + predictions_train[name], predictions_val[name], predictions_test[name], results[name] = ( + self._fit_forecaster( + data=data, + data_val=data_val, + data_test=data_test, + forecaster_name=name, + ) + ) + + train_ensemble = EnsembleForecastDataset.from_forecast_datasets( + predictions_train, target_series=data.data[self.target_column] + ) + + if all(isinstance(v, ForecastDataset) for v in predictions_val.values()): + val_ensemble = EnsembleForecastDataset.from_forecast_datasets( + {k: v for k, v in predictions_val.items() if v is not None}, + target_series=data.data[self.target_column], + ) + else: + val_ensemble = None + + if all(isinstance(v, ForecastDataset) for v in predictions_test.values()): + test_ensemble = EnsembleForecastDataset.from_forecast_datasets( + {k: v for k, v in predictions_test.items() if v is not None}, + target_series=data.data[self.target_column], + ) + else: + test_ensemble = None + + return train_ensemble, val_ensemble, test_ensemble, results + + def _fit_forecaster( + self, + data: TimeSeriesDataset, + data_val: TimeSeriesDataset | None = None, + data_test: TimeSeriesDataset | None = None, + forecaster_name: str = "", + ) -> tuple[ + ForecastDataset, + ForecastDataset | None, + ForecastDataset | None, + ModelFitResult, + ]: + """Train the forecaster on the provided dataset. + + Args: + data: Historical time series data with features and target values. + data_val: Optional validation data. + data_test: Optional test data. + forecaster_name: Name of the forecaster to train. + + Returns: + ForecastDataset containing the trained forecaster's predictions. + """ + forecaster = self.forecasters[forecaster_name] + validate_horizons_present(data, forecaster.config.horizons) + + # Transform and split input data + input_data_train = self.prepare_input(data=data, forecaster_name=forecaster_name) + input_data_val = self.prepare_input(data=data_val, forecaster_name=forecaster_name) if data_val else None + input_data_test = self.prepare_input(data=data_test, forecaster_name=forecaster_name) if data_test else None + + # Drop target column nan's from training data. One can not train on missing targets. + target_dropna = partial(pd.DataFrame.dropna, subset=[self.target_column]) # pyright: ignore[reportUnknownMemberType] + input_data_train = input_data_train.pipe_pandas(target_dropna) + input_data_val = input_data_val.pipe_pandas(target_dropna) if input_data_val else None + input_data_test = input_data_test.pipe_pandas(target_dropna) if input_data_test else None + + # Transform the input data to a valid forecast input and split into train/val/test + input_data_train, input_data_val, input_data_test = self.data_splitter.split_dataset( + data=input_data_train, data_val=input_data_val, data_test=input_data_test, target_column=self.target_column + ) + + # Fit the model + logger.debug("Started fitting forecaster '%s'.", forecaster_name) + forecaster.fit(data=input_data_train, data_val=input_data_val) + logger.debug("Completed fitting forecaster '%s'.", forecaster_name) + + prediction_train = self._predict_forecaster(input_data=input_data_train, forecaster_name=forecaster_name) + metrics_train = self._calculate_score(prediction=prediction_train) + + if input_data_val is not None: + prediction_val = self._predict_forecaster(input_data=input_data_val, forecaster_name=forecaster_name) + metrics_val = self._calculate_score(prediction=prediction_val) + else: + prediction_val = None + metrics_val = None + + if input_data_test is not None: + prediction_test = self._predict_forecaster(input_data=input_data_test, forecaster_name=forecaster_name) + metrics_test = self._calculate_score(prediction=prediction_test) + else: + prediction_test = None + metrics_test = None + + result = ModelFitResult( + input_dataset=input_data_train, + input_data_train=input_data_train, + input_data_val=input_data_val, + input_data_test=input_data_test, + metrics_train=metrics_train, + metrics_val=metrics_val, + metrics_test=metrics_test, + metrics_full=metrics_train, + ) + + return prediction_train, prediction_val, prediction_test, result + + def _predict_forecaster(self, input_data: ForecastInputDataset, forecaster_name: str) -> ForecastDataset: + # Predict and restore target column + logger.debug("Predicting forecaster '%s'.", forecaster_name) + prediction_raw = self.forecasters[forecaster_name].predict(data=input_data) + prediction = self.postprocessing.transform(prediction_raw) + return restore_target(dataset=prediction, original_dataset=input_data, target_column=self.target_column) + + def _predict_forecasters( + self, + data: TimeSeriesDataset, + forecast_start: datetime | None = None, + ) -> EnsembleForecastDataset: + predictions: dict[str, ForecastDataset] = {} + for name in self.forecasters: + logger.debug("Generating predictions for forecaster '%s'.", name) + input_data = self.prepare_input(data=data, forecast_start=forecast_start, forecaster_name=name) + predictions[name] = self._predict_forecaster( + input_data=input_data, + forecaster_name=name, + ) + + return EnsembleForecastDataset.from_forecast_datasets(predictions, target_series=data.data[self.target_column]) + + def prepare_input( + self, + data: TimeSeriesDataset, + forecaster_name: str = "", + forecast_start: datetime | None = None, + ) -> ForecastInputDataset: + """Prepare input data for forecastingfiltering. + + Args: + data: Raw time series dataset to prepare for forecasting. + forecast_start: Optional start time for forecasts. If provided and earlier + than the cutoff time, overrides the cutoff for data filtering. + forecaster_name: Name of the forecaster for which to prepare input data. + + Returns: + Processed forecast input dataset ready for model prediction. + """ + logger.debug("Preparing input data for forecaster '%s'.", forecaster_name) + # Transform the data + input_data = self.common_preprocessing.transform(data=data) + if forecaster_name in self.model_specific_preprocessing: + logger.debug("Applying model-specific preprocessing for forecaster '%s'.", forecaster_name) + input_data = self.model_specific_preprocessing[forecaster_name].transform(data=input_data) + input_data = restore_target(dataset=input_data, original_dataset=data, target_column=self.target_column) + + # Cut away input history to avoid training on incomplete data + input_data_start = cast("pd.Series[pd.Timestamp]", input_data.index).min().to_pydatetime() + input_data_cutoff = input_data_start + self.cutoff_history + if forecast_start is not None and forecast_start < input_data_cutoff: + input_data_cutoff = forecast_start + self._logger.warning( + "Forecast start %s is after input data start + cutoff history %s. Using forecast start as cutoff.", + forecast_start, + input_data_cutoff, + ) + input_data = input_data.filter_by_range(start=input_data_cutoff) + + return ForecastInputDataset.from_timeseries( + dataset=input_data, + target_column=self.target_column, + forecast_start=forecast_start, + ) + + def _predict_transform_combiner( + self, ensemble_dataset: EnsembleForecastDataset, original_data: TimeSeriesDataset + ) -> ForecastDataset: + logger.debug("Predicting combiner.") + features = self._transform_combiner_data(data=original_data) + + return self._predict_combiner(ensemble_dataset, features) + + def _predict_combiner( + self, ensemble_dataset: EnsembleForecastDataset, features: ForecastInputDataset | None + ) -> ForecastDataset: + logger.debug("Predicting combiner.") + prediction_raw = self.combiner.predict(ensemble_dataset, additional_features=features) + prediction = self.postprocessing.transform(prediction_raw) + + return restore_target(dataset=prediction, original_dataset=ensemble_dataset, target_column=self.target_column) + + def _fit_combiner( + self, + data: TimeSeriesDataset, + train_ensemble_dataset: EnsembleForecastDataset, + data_val: TimeSeriesDataset | None = None, + data_test: TimeSeriesDataset | None = None, + val_ensemble_dataset: EnsembleForecastDataset | None = None, + test_ensemble_dataset: EnsembleForecastDataset | None = None, + ) -> ModelFitResult: + + features_train, features_val, features_test = self._fit_prepare_combiner_data( + data=data, data_val=data_val, data_test=data_test + ) + + logger.debug("Fitting combiner.") + self.combiner.fit( + data=train_ensemble_dataset, data_val=val_ensemble_dataset, additional_features=features_train + ) + + prediction_train = self._predict_combiner(train_ensemble_dataset, features=features_train) + metrics_train = self._calculate_score(prediction=prediction_train) + + if val_ensemble_dataset is not None: + prediction_val = self._predict_combiner(val_ensemble_dataset, features=features_val) + metrics_val = self._calculate_score(prediction=prediction_val) + else: + prediction_val = None + metrics_val = None + + if test_ensemble_dataset is not None: + prediction_test = self._predict_combiner(test_ensemble_dataset, features=features_test) + metrics_test = self._calculate_score(prediction=prediction_test) + else: + prediction_test = None + metrics_test = None + + return ModelFitResult( + input_dataset=train_ensemble_dataset, + input_data_train=train_ensemble_dataset.select_quantile(quantile=self.config[0].quantiles[0]), + input_data_val=val_ensemble_dataset.select_quantile(quantile=self.config[0].quantiles[0]) + if val_ensemble_dataset + else None, + input_data_test=test_ensemble_dataset.select_quantile(quantile=self.config[0].quantiles[0]) + if test_ensemble_dataset + else None, + metrics_train=metrics_train, + metrics_val=metrics_val, + metrics_test=metrics_test, + metrics_full=metrics_train, + ) + + def _predict_contributions_combiner( + self, ensemble_dataset: EnsembleForecastDataset, original_data: TimeSeriesDataset + ) -> pd.DataFrame: + + features = self._transform_combiner_data(data=original_data) + predictions = self.combiner.predict_contributions(ensemble_dataset, additional_features=features) + predictions[ensemble_dataset.target_column] = ensemble_dataset.target_series + return predictions + + def predict(self, data: TimeSeriesDataset, forecast_start: datetime | None = None) -> ForecastDataset: + """Generate forecasts for the provided dataset. + + Args: + data: Input time series dataset for prediction. + forecast_start: Optional start time for forecasts. + + Returns: + ForecastDataset containing the generated forecasts. + + Raises: + NotFittedError: If the model has not been fitted yet. + """ + if not self.is_fitted: + raise NotFittedError(self.__class__.__name__) + logger.debug("Generating predictions.") + + ensemble_predictions = self._predict_forecasters(data=data, forecast_start=forecast_start) + + # Predict and restore target column + return self._predict_transform_combiner( + ensemble_dataset=ensemble_predictions, + original_data=data, + ) + + def predict_contributions(self, data: TimeSeriesDataset, forecast_start: datetime | None = None) -> pd.DataFrame: + """Generate forecasts for the provided dataset. + + Args: + data: Input time series dataset for prediction. + forecast_start: Optional start time for forecasts. + + Returns: + ForecastDataset containing the generated forecasts. + + Raises: + NotFittedError: If the model has not been fitted yet. + """ + if not self.is_fitted: + raise NotFittedError(self.__class__.__name__) + + ensemble_predictions = self._predict_forecasters(data=data, forecast_start=forecast_start) + + return self._predict_contributions_combiner( + ensemble_dataset=ensemble_predictions, + original_data=data, + ) + + def score( + self, + data: TimeSeriesDataset, + ) -> SubsetMetric: + """Evaluate model performance on the provided dataset. + + Generates predictions for the dataset and calculates evaluation metrics + by comparing against ground truth values. Uses the configured evaluation + metrics to assess forecast quality at the maximum forecast horizon. + + Args: + data: Time series dataset containing both features and target values + for evaluation. + + Returns: + Evaluation metrics including configured providers (e.g., R2, observed + probability) computed at the maximum forecast horizon. + """ + prediction = self.predict(data=data) + + return self._calculate_score(prediction=prediction) + + def _calculate_score(self, prediction: ForecastDataset) -> SubsetMetric: + if prediction.target_series is None: + raise ValueError("Prediction dataset must contain target series for scoring.") + + # We need to make sure there are no NaNs in the target label for metric calculation + prediction = prediction.pipe_pandas(pd.DataFrame.dropna, subset=[self.target_column]) # pyright: ignore[reportUnknownArgumentType, reportUnknownMemberType] + + pipeline = EvaluationPipeline( + # Needs only one horizon since we are using only a single prediction step + # If a more comprehensive test is needed, a backtest should be run. + config=EvaluationConfig(available_ats=[], lead_times=[self.config[0].max_horizon]), + quantiles=self.config[0].quantiles, + # Similarly windowed metrics are not relevant for single predictions. + window_metric_providers=[], + global_metric_providers=self.evaluation_metrics, + ) + + evaluation_result = pipeline.run_for_subset( + filtering=self.config[0].max_horizon, + predictions=prediction, + ) + global_metric = evaluation_result.get_global_metric() + if not global_metric: + return SubsetMetric( + window="global", + timestamp=prediction.forecast_start, + metrics={}, + ) + + return global_metric + + +def restore_target[T: TimeSeriesDataset]( + dataset: T, + original_dataset: TimeSeriesDataset, + target_column: str, +) -> T: + """Restore the target column from the original dataset to the given dataset. + + Maps target values from the original dataset to the dataset using index alignment. + Ensures the target column is present in the dataset for downstream processing. + + Args: + dataset: Dataset to modify by adding the target column. + original_dataset: Source dataset containing the target values. + target_column: Name of the target column to restore. + + Returns: + Dataset with the target column restored from the original dataset. + """ + target_series = original_dataset.select_features([target_column]).select_version().data[target_column] + + def _transform_restore_target(df: pd.DataFrame) -> pd.DataFrame: + return df.assign(**{str(target_series.name): df.index.map(target_series)}) # pyright: ignore[reportUnknownMemberType] + + return dataset.pipe_pandas(_transform_restore_target) + + +__all__ = ["EnsembleForecastingModel", "ModelFitResult", "restore_target"] diff --git a/packages/openstef-meta/src/openstef_meta/models/forecast_combiners/__init__.py b/packages/openstef-meta/src/openstef_meta/models/forecast_combiners/__init__.py new file mode 100644 index 000000000..56a4cadff --- /dev/null +++ b/packages/openstef-meta/src/openstef_meta/models/forecast_combiners/__init__.py @@ -0,0 +1,32 @@ +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# +# SPDX-License-Identifier: MPL-2.0 + +"""Forecast Combiners.""" + +from .forecast_combiner import ForecastCombiner, ForecastCombinerConfig +from .learned_weights_combiner import ( + LGBMCombinerHyperParams, + LogisticCombinerHyperParams, + RFCombinerHyperParams, + WeightsCombiner, + WeightsCombinerConfig, + XGBCombinerHyperParams, +) +from .rules_combiner import RulesCombiner, RulesCombinerConfig +from .stacking_combiner import StackingCombiner, StackingCombinerConfig + +__all__ = [ + "ForecastCombiner", + "ForecastCombinerConfig", + "LGBMCombinerHyperParams", + "LogisticCombinerHyperParams", + "RFCombinerHyperParams", + "RulesCombiner", + "RulesCombinerConfig", + "StackingCombiner", + "StackingCombinerConfig", + "WeightsCombiner", + "WeightsCombinerConfig", + "XGBCombinerHyperParams", +] diff --git a/packages/openstef-meta/src/openstef_meta/models/forecast_combiners/forecast_combiner.py b/packages/openstef-meta/src/openstef_meta/models/forecast_combiners/forecast_combiner.py new file mode 100644 index 000000000..a8cd4864f --- /dev/null +++ b/packages/openstef-meta/src/openstef_meta/models/forecast_combiners/forecast_combiner.py @@ -0,0 +1,145 @@ +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# +# SPDX-License-Identifier: MPL-2.0 +"""Core meta model interfaces and configurations. + +Provides the fundamental building blocks for implementing meta models in OpenSTEF. +These mixins establish contracts that ensure consistent behavior across different meta model types +while ensuring full compatability with regular Forecasters. +""" + +from abc import abstractmethod +from typing import Self + +import pandas as pd +from pydantic import ConfigDict, Field + +from openstef_core.base_model import BaseConfig +from openstef_core.datasets import ForecastDataset, ForecastInputDataset +from openstef_core.mixins import HyperParams, Predictor +from openstef_core.types import LeadTime, Quantile +from openstef_meta.utils.datasets import EnsembleForecastDataset +from openstef_models.transforms.general.selector import Selector +from openstef_models.utils.feature_selection import FeatureSelection + +SELECTOR = Selector( + selection=FeatureSelection(include=None), +) + + +class ForecastCombinerConfig(BaseConfig): + """Hyperparameters for the Final Learner.""" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + hyperparams: HyperParams = Field( + description="Hyperparameters for the final learner.", + ) + + quantiles: list[Quantile] = Field( + default=[Quantile(0.5)], + description=( + "Probability levels for uncertainty estimation. Each quantile represents a confidence level " + "(e.g., 0.1 = 10th percentile, 0.5 = median, 0.9 = 90th percentile). " + "Models must generate predictions for all specified quantiles." + ), + min_length=1, + ) + + horizons: list[LeadTime] = Field( + default=..., + description=( + "Lead times for predictions, accounting for data availability and versioning cutoffs. " + "Each horizon defines how far ahead the model should predict." + ), + min_length=1, + ) + + @property + def max_horizon(self) -> LeadTime: + """Returns the maximum lead time (horizon) from the configured horizons. + + Useful for determining the furthest prediction distance required by the model. + This is commonly used for data preparation and validation logic. + + Returns: + The maximum lead time. + """ + return max(self.horizons) + + def with_horizon(self, horizon: LeadTime) -> Self: + """Create a new configuration with a different horizon. + + Useful for creating multiple forecaster instances for different prediction + horizons from a single base configuration. + + Args: + horizon: The new lead time to use for predictions. + + Returns: + New configuration instance with the specified horizon. + """ + return self.model_copy(update={"horizons": [horizon]}) + + +class ForecastCombiner(Predictor[EnsembleForecastDataset, ForecastDataset]): + """Combines base Forecaster predictions for each quantile into final predictions.""" + + config: ForecastCombinerConfig + + @abstractmethod + def fit( + self, + data: EnsembleForecastDataset, + data_val: EnsembleForecastDataset | None = None, + additional_features: ForecastInputDataset | None = None, + ) -> None: + """Fit the final learner using base Forecaster predictions. + + Args: + data: EnsembleForecastDataset + data_val: Optional EnsembleForecastDataset for validation during fitting. Will be ignored + additional_features: Optional ForecastInputDataset containing additional features for the final learner. + """ + raise NotImplementedError("Subclasses must implement the fit method.") + + def predict( + self, + data: EnsembleForecastDataset, + additional_features: ForecastInputDataset | None = None, + ) -> ForecastDataset: + """Generate final predictions based on base Forecaster predictions. + + Args: + data: EnsembleForecastDataset containing base Forecaster predictions. + data_val: Optional EnsembleForecastDataset for validation during prediction. Will be ignored + additional_features: Optional ForecastInputDataset containing additional features for the final learner. + + Returns: + ForecastDataset containing the final predictions. + """ + raise NotImplementedError("Subclasses must implement the predict method.") + + @property + @abstractmethod + def is_fitted(self) -> bool: + """Indicates whether the final learner has been fitted.""" + raise NotImplementedError("Subclasses must implement the is_fitted property.") + + @abstractmethod + def predict_contributions( + self, + data: EnsembleForecastDataset, + additional_features: ForecastInputDataset | None = None, + ) -> pd.DataFrame: + """Generate final predictions based on base Forecaster predictions. + + Args: + data: EnsembleForecastDataset containing base Forecaster predictions. + data_val: Optional EnsembleForecastDataset for validation during prediction. Will be ignored + additional_features: Optional ForecastInputDataset containing additional features for the final learner. + + Returns: + ForecastDataset containing the final contributions. + """ + raise NotImplementedError("Subclasses must implement the predict method.") diff --git a/packages/openstef-meta/src/openstef_meta/models/forecast_combiners/learned_weights_combiner.py b/packages/openstef-meta/src/openstef_meta/models/forecast_combiners/learned_weights_combiner.py new file mode 100644 index 000000000..d2b0fac48 --- /dev/null +++ b/packages/openstef-meta/src/openstef_meta/models/forecast_combiners/learned_weights_combiner.py @@ -0,0 +1,431 @@ +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# +# SPDX-License-Identifier: MPL-2.0 +"""Learned Weights Combiner. + +Forecast combiner that uses a classification approach to learn weights for base forecasters. +It is designed to efficiently combine predictions from multiple base forecasters by learning which +forecaster is likely to perform best under different conditions. The combiner can operate in two modes: +- Hard Selection: Selects the base forecaster with the highest predicted probability for each instance. +- Soft Selection: Uses the predicted probabilities as weights to combine base forecaster predictions. +""" + +import logging +from abc import abstractmethod +from typing import Literal, override + +import pandas as pd +from lightgbm import LGBMClassifier +from pydantic import Field +from sklearn.dummy import DummyClassifier +from sklearn.linear_model import LogisticRegression +from sklearn.preprocessing import LabelEncoder +from sklearn.utils.class_weight import compute_sample_weight # type: ignore +from xgboost import XGBClassifier + +from openstef_core.datasets import ForecastDataset, ForecastInputDataset +from openstef_core.exceptions import ( + NotFittedError, +) +from openstef_core.mixins.predictor import HyperParams +from openstef_core.types import LeadTime, Quantile +from openstef_meta.models.forecast_combiners.forecast_combiner import ( + ForecastCombiner, + ForecastCombinerConfig, +) +from openstef_meta.utils.datasets import EnsembleForecastDataset, combine_forecast_input_datasets + +logger = logging.getLogger(__name__) + + +# Base classes for Learned Weights Final Learner + +Classifier = LGBMClassifier | XGBClassifier | LogisticRegression | DummyClassifier +ClassifierNames = Literal["lgbm", "xgb", "logistic_regression", "dummy"] + + +class ClassifierParamsMixin: + """Hyperparameters for the Final Learner.""" + + @abstractmethod + def get_classifier(self) -> Classifier: + """Returns the classifier instance.""" + msg = "Subclasses must implement get_classifier method." + raise NotImplementedError(msg) + + +class LGBMCombinerHyperParams(HyperParams, ClassifierParamsMixin): + """Hyperparameters for Learned Weights Final Learner with LGBM Classifier.""" + + n_estimators: int = Field( + default=20, + description="Number of estimators for the LGBM Classifier. Defaults to 20.", + ) + + n_leaves: int = Field( + default=31, + description="Number of leaves for the LGBM Classifier. Defaults to 31.", + ) + + reg_alpha: float = Field( + default=0.0, + description="L1 regularization term on weights. Defaults to 0.0.", + ) + + reg_lambda: float = Field( + default=0.0, + description="L2 regularization term on weights. Defaults to 0.0.", + ) + + @override + def get_classifier(self) -> LGBMClassifier: + """Returns the LGBM Classifier.""" + return LGBMClassifier( + class_weight="balanced", + n_estimators=self.n_estimators, + num_leaves=self.n_leaves, + reg_alpha=self.reg_alpha, + reg_lambda=self.reg_lambda, + n_jobs=1, + ) + + +class RFCombinerHyperParams(HyperParams, ClassifierParamsMixin): + """Hyperparameters for Learned Weights Final Learner with LGBM Random Forest Classifier.""" + + n_estimators: int = Field( + default=20, + description="Number of estimators for the LGBM Classifier. Defaults to 20.", + ) + + n_leaves: int = Field( + default=31, + description="Number of leaves for the LGBM Classifier. Defaults to 31.", + ) + + bagging_freq: int = Field( + default=1, + description="Frequency for bagging in the Random Forest. Defaults to 1.", + ) + + bagging_fraction: float = Field( + default=0.8, + description="Fraction of data to be used for each iteration of the Random Forest. Defaults to 0.8.", + ) + + feature_fraction: float = Field( + default=1, + description="Fraction of features to be used for each iteration of the Random Forest. Defaults to 1.", + ) + + @override + def get_classifier(self) -> LGBMClassifier: + """Returns the Random Forest LGBMClassifier.""" + return LGBMClassifier( + boosting_type="rf", + class_weight="balanced", + n_estimators=self.n_estimators, + bagging_freq=self.bagging_freq, + bagging_fraction=self.bagging_fraction, + feature_fraction=self.feature_fraction, + num_leaves=self.n_leaves, + ) + + +# 3 XGB Classifier +class XGBCombinerHyperParams(HyperParams, ClassifierParamsMixin): + """Hyperparameters for Learned Weights Final Learner with LGBM Random Forest Classifier.""" + + n_estimators: int = Field( + default=20, + description="Number of estimators for the LGBM Classifier. Defaults to 20.", + ) + + @override + def get_classifier(self) -> XGBClassifier: + """Returns the XGBClassifier.""" + return XGBClassifier(n_estimators=self.n_estimators) + + +class LogisticCombinerHyperParams(HyperParams, ClassifierParamsMixin): + """Hyperparameters for Learned Weights Final Learner with LGBM Random Forest Classifier.""" + + fit_intercept: bool = Field( + default=True, + description="Whether to calculate the intercept for this model. Defaults to True.", + ) + + penalty: Literal["l1", "l2", "elasticnet"] = Field( + default="l2", + description="Specify the norm used in the penalization. Defaults to 'l2'.", + ) + + c: float = Field( + default=1.0, + description="Inverse of regularization strength; must be a positive float. Defaults to 1.0.", + ) + + @override + def get_classifier(self) -> LogisticRegression: + """Returns the LogisticRegression.""" + return LogisticRegression( + class_weight="balanced", + fit_intercept=self.fit_intercept, + penalty=self.penalty, + C=self.c, + ) + + +class WeightsCombinerConfig(ForecastCombinerConfig): + """Configuration for WeightsCombiner.""" + + hyperparams: HyperParams = Field( + default=LGBMCombinerHyperParams(), + description="Hyperparameters for the Weights Combiner.", + ) + + quantiles: list[Quantile] = Field( + default=[Quantile(0.5)], + description=( + "Probability levels for uncertainty estimation. Each quantile represents a confidence level " + "(e.g., 0.1 = 10th percentile, 0.5 = median, 0.9 = 90th percentile). " + "Models must generate predictions for all specified quantiles." + ), + min_length=1, + ) + + horizons: list[LeadTime] = Field( + default=..., + description=( + "Lead times for predictions, accounting for data availability and versioning cutoffs. " + "Each horizon defines how far ahead the model should predict." + ), + min_length=1, + ) + + hard_selection: bool = Field( + default=False, + description=( + "If True, the combiner will select the base model with the highest predicted probability " + "for each instance (hard selection). If False, it will use the predicted probabilities as " + "weights to combine base model predictions (soft selection)." + ), + ) + + @property + def get_classifier(self) -> Classifier: + """Returns the classifier instance from hyperparameters. + + Returns: + Classifier instance. + + Raises: + TypeError: If hyperparams do not implement ClassifierParamsMixin. + """ + if not isinstance(self.hyperparams, ClassifierParamsMixin): + msg = "hyperparams must implement ClassifierParamsMixin to get classifier." + raise TypeError(msg) + return self.hyperparams.get_classifier() + + +class WeightsCombiner(ForecastCombiner): + """Combines base Forecaster predictions with a classification approach. + + The classifier is used to predict model weights for each base forecaster. + Depending on the `hard_selection` parameter in the configuration, the combiner can either + select the base forecaster with the highest predicted probability (hard selection) or use + the predicted probabilities as weights to combine base forecaster predictions (soft selection). + """ + + Config = WeightsCombinerConfig + LGBMHyperParams = LGBMCombinerHyperParams + RFHyperParams = RFCombinerHyperParams + XGBHyperParams = XGBCombinerHyperParams + LogisticHyperParams = LogisticCombinerHyperParams + + def __init__(self, config: WeightsCombinerConfig) -> None: + """Initialize the Weigths Combiner.""" + self.quantiles = config.quantiles + self.config = config + self.hyperparams = config.hyperparams + self._is_fitted: bool = False + self._is_fitted = False + self._label_encoder = LabelEncoder() + self.hard_selection = config.hard_selection + + # Initialize a classifier per quantile + self.models: list[Classifier] = [config.get_classifier for _ in self.quantiles] + + @override + def fit( + self, + data: EnsembleForecastDataset, + data_val: EnsembleForecastDataset | None = None, + additional_features: ForecastInputDataset | None = None, + ) -> None: + + self._label_encoder.fit(data.forecaster_names) + + for i, q in enumerate(self.quantiles): + # Data preparation + dataset = data.select_quantile_classification(quantile=q) + combined_data = combine_forecast_input_datasets( + dataset=dataset, + other=additional_features, + ) + input_data = combined_data.input_data() + labels = combined_data.target_series + self._validate_labels(labels=labels, model_index=i) + labels = self._label_encoder.transform(labels) + + # Balance classes, adjust with sample weights + weights = compute_sample_weight("balanced", labels) * combined_data.sample_weight_series + + self.models[i].fit(X=input_data, y=labels, sample_weight=weights) # type: ignore + self._is_fitted = True + + @staticmethod + def _prepare_input_data( + dataset: ForecastInputDataset, additional_features: ForecastInputDataset | None + ) -> pd.DataFrame: + """Prepare input data by combining base predictions with additional features if provided. + + Args: + dataset: ForecastInputDataset containing base predictions. + additional_features: Optional ForecastInputDataset containing additional features. + + Returns: + pd.DataFrame: Combined DataFrame of base predictions and additional features if provided. + """ + df = dataset.input_data(start=dataset.index[0]) + if additional_features is not None: + df_a = additional_features.input_data(start=dataset.index[0]) + df = pd.concat( + [df, df_a], + axis=1, + join="inner", + ) + return df + + def _validate_labels(self, labels: pd.Series, model_index: int) -> None: + if len(labels.unique()) == 1: + msg = f"""Final learner for quantile {self.quantiles[model_index].format()} has + less than 2 classes in the target. + Switching to dummy classifier """ + logger.warning(msg=msg) + self.models[model_index] = DummyClassifier(strategy="most_frequent") + + def _predict_model_weights_quantile(self, base_predictions: pd.DataFrame, model_index: int) -> pd.DataFrame: + model = self.models[model_index] + if isinstance(model, DummyClassifier): + weights_array = pd.DataFrame(0, index=base_predictions.index, columns=self._label_encoder.classes_) + weights_array[self._label_encoder.classes_[0]] = 1.0 + else: + weights_array = model.predict_proba(base_predictions) # type: ignore + + return pd.DataFrame(weights_array, index=base_predictions.index, columns=self._label_encoder.classes_) # type: ignore + + def _generate_predictions_quantile( + self, + dataset: ForecastInputDataset, + additional_features: ForecastInputDataset | None, + model_index: int, + ) -> pd.Series: + + input_data = self._prepare_input_data( + dataset=dataset, + additional_features=additional_features, + ) + + weights = self._predict_model_weights_quantile(base_predictions=input_data, model_index=model_index) + + if self.hard_selection: + # If selection mode is hard, set the max weight to 1 and others to 0 + # Edge case if max weights are equal, distribute equally + weights = (weights == weights.max(axis=1).to_frame().to_numpy()) / weights.sum(axis=1).to_frame().to_numpy() + + return dataset.input_data().mul(weights).sum(axis=1) + + @override + def predict( + self, + data: EnsembleForecastDataset, + additional_features: ForecastInputDataset | None = None, + ) -> ForecastDataset: + if not self.is_fitted: + raise NotFittedError(self.__class__.__name__) + + # Generate predictions + predictions = pd.DataFrame({ + Quantile(q).format(): self._generate_predictions_quantile( + dataset=data.select_quantile(quantile=Quantile(q)), + additional_features=additional_features, + model_index=i, + ) + for i, q in enumerate(self.quantiles) + }) + target_series = data.target_series + if target_series is not None: + predictions[data.target_column] = target_series + + return ForecastDataset( + data=predictions, + sample_interval=data.sample_interval, + target_column=data.target_column, + forecast_start=data.forecast_start, + ) + + @override + def predict_contributions( + self, + data: EnsembleForecastDataset, + additional_features: ForecastInputDataset | None = None, + ) -> pd.DataFrame: + if not self.is_fitted: + raise NotFittedError(self.__class__.__name__) + + # Generate predictions + contribution_list = [ + self._generate_contributions_quantile( + dataset=data.select_quantile(quantile=Quantile(q)), + additional_features=additional_features, + model_index=i, + ) + for i, q in enumerate(self.quantiles) + ] + + contributions = pd.concat(contribution_list, axis=1) + + target_series = data.target_series + if target_series is not None: + contributions[data.target_column] = target_series + + return contributions + + def _generate_contributions_quantile( + self, + dataset: ForecastInputDataset, + additional_features: ForecastInputDataset | None, + model_index: int, + ) -> pd.DataFrame: + input_data = self._prepare_input_data( + dataset=dataset, + additional_features=additional_features, + ) + weights = self._predict_model_weights_quantile(base_predictions=input_data, model_index=model_index) + weights.columns = [f"{col}_{Quantile(self.quantiles[model_index]).format()}" for col in weights.columns] + return weights + + @property + @override + def is_fitted(self) -> bool: + return self._is_fitted + + +__all__ = [ + "LGBMCombinerHyperParams", + "LogisticCombinerHyperParams", + "RFCombinerHyperParams", + "WeightsCombiner", + "XGBCombinerHyperParams", +] diff --git a/packages/openstef-meta/src/openstef_meta/models/forecast_combiners/rules_combiner.py b/packages/openstef-meta/src/openstef_meta/models/forecast_combiners/rules_combiner.py new file mode 100644 index 000000000..93a12744f --- /dev/null +++ b/packages/openstef-meta/src/openstef_meta/models/forecast_combiners/rules_combiner.py @@ -0,0 +1,174 @@ +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# +# SPDX-License-Identifier: MPL-2.0 +"""Rules-based Meta Forecaster Module.""" + +import logging +from typing import cast, override + +import pandas as pd +from pydantic import Field, field_validator + +from openstef_core.datasets import ForecastDataset, ForecastInputDataset +from openstef_core.mixins import HyperParams +from openstef_core.types import LeadTime, Quantile +from openstef_meta.models.forecast_combiners.forecast_combiner import ForecastCombiner, ForecastCombinerConfig +from openstef_meta.utils.datasets import EnsembleForecastDataset +from openstef_meta.utils.decision_tree import Decision, DecisionTree + +logger = logging.getLogger(__name__) + + +class RulesLearnerHyperParams(HyperParams): + """HyperParams for Stacking Final Learner.""" + + decision_tree: DecisionTree = Field( + description="Decision tree defining the rules for the final learner.", + default=DecisionTree( + nodes=[Decision(idx=0, decision="LGBMForecaster")], + outcomes={"LGBMForecaster"}, + ), + ) + + +class RulesCombinerConfig(ForecastCombinerConfig): + """Configuration for Rules-based Forecast Combiner.""" + + hyperparams: HyperParams = Field( + description="Hyperparameters for the Rules-based final learner.", + default=RulesLearnerHyperParams(), + ) + + quantiles: list[Quantile] = Field( + default=[Quantile(0.5)], + description=( + "Probability levels for uncertainty estimation. Each quantile represents a confidence level " + "(e.g., 0.1 = 10th percentile, 0.5 = median, 0.9 = 90th percentile). " + "Models must generate predictions for all specified quantiles." + ), + min_length=1, + ) + + horizons: list[LeadTime] = Field( + default=..., + description=( + "Lead times for predictions, accounting for data availability and versioning cutoffs. " + "Each horizon defines how far ahead the model should predict." + ), + min_length=1, + ) + + @field_validator("hyperparams", mode="after") + @staticmethod + def _validate_hyperparams(v: HyperParams) -> HyperParams: + if not isinstance(v, RulesLearnerHyperParams): + raise TypeError("hyperparams must be an instance of RulesLearnerHyperParams.") + return v + + +class RulesCombiner(ForecastCombiner): + """Combines base Forecaster predictions per quantile into final predictions using hard-coded rules.""" + + Config = RulesCombinerConfig + + def __init__(self, config: RulesCombinerConfig) -> None: + """Initialize the Rules Learner. + + Args: + config: Configuration for the Rules Combiner. + """ + hyperparams = cast(RulesLearnerHyperParams, config.hyperparams) + self.tree = hyperparams.decision_tree + self.quantiles = config.quantiles + self.config = config + + @override + def fit( + self, + data: EnsembleForecastDataset, + data_val: EnsembleForecastDataset | None = None, + additional_features: ForecastInputDataset | None = None, + ) -> None: + # No fitting needed for rule-based final learner + # Check that additional features are provided + if additional_features is None: + raise ValueError("Additional features must be provided for RulesForecastCombiner prediction.") + + def _predict_tree(self, data: pd.DataFrame, columns: pd.Index) -> pd.DataFrame: + """Predict using the decision tree rules. + + Args: + data: DataFrame containing the additional features. + columns: Expected columns for the output DataFrame. + + Returns: + DataFrame with predictions for each quantile. + """ + predictions = data.apply(self.tree.get_decision, axis=1) + + return pd.get_dummies(predictions).reindex(columns=columns) + + @override + def predict( + self, + data: EnsembleForecastDataset, + additional_features: ForecastInputDataset | None = None, + ) -> ForecastDataset: + if additional_features is None: + raise ValueError("Additional features must be provided for RulesForecastCombiner prediction.") + + decisions = self._predict_tree( + additional_features.data, columns=data.select_quantile(quantile=self.quantiles[0]).data.columns + ) + + # Generate predictions + predictions: list[pd.DataFrame] = [] + for q in self.quantiles: + dataset = data.select_quantile(quantile=q) + preds = dataset.input_data().multiply(decisions).sum(axis=1) + + predictions.append(preds.to_frame(name=Quantile(q).format())) + + # Concatenate predictions along columns to form a DataFrame with quantile columns + df = pd.concat(predictions, axis=1) + + return ForecastDataset( + data=df, + sample_interval=data.sample_interval, + ) + + @override + def predict_contributions( + self, + data: EnsembleForecastDataset, + additional_features: ForecastInputDataset | None = None, + ) -> pd.DataFrame: + if additional_features is None: + raise ValueError("Additional features must be provided for RulesForecastCombiner prediction.") + + decisions = self._predict_tree( + additional_features.data, columns=data.select_quantile(quantile=self.quantiles[0]).data.columns + ) + + # Generate predictions + predictions: list[pd.DataFrame] = [] + for q in self.quantiles: + dataset = data.select_quantile(quantile=q) + preds = dataset.input_data().multiply(decisions).sum(axis=1) + + predictions.append(preds.to_frame(name=Quantile(q).format())) + + # Concatenate predictions along columns to form a DataFrame with quantile columns + return pd.concat(predictions, axis=1) + + @property + def is_fitted(self) -> bool: + """Check the Rules Final Learner is fitted.""" + return True + + +__all__ = [ + "RulesCombiner", + "RulesCombinerConfig", + "RulesLearnerHyperParams", +] diff --git a/packages/openstef-meta/src/openstef_meta/models/forecast_combiners/stacking_combiner.py b/packages/openstef-meta/src/openstef_meta/models/forecast_combiners/stacking_combiner.py new file mode 100644 index 000000000..d59811453 --- /dev/null +++ b/packages/openstef-meta/src/openstef_meta/models/forecast_combiners/stacking_combiner.py @@ -0,0 +1,245 @@ +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# +# SPDX-License-Identifier: MPL-2.0 +"""Stacking Forecast Combiner. + +This module implements a Stacking Combiner that integrates predictions from multiple base Forecasters. +It uses a regression approach to combine the predictions for each quantile into final forecasts. +""" + +import logging +from functools import partial +from typing import TYPE_CHECKING, cast, override + +import pandas as pd +from pydantic import Field, field_validator + +from openstef_core.datasets import ForecastDataset, ForecastInputDataset +from openstef_core.exceptions import ( + NotFittedError, +) +from openstef_core.mixins import HyperParams +from openstef_core.types import LeadTime, Quantile +from openstef_meta.models.forecast_combiners.forecast_combiner import ForecastCombiner, ForecastCombinerConfig +from openstef_meta.utils.datasets import EnsembleForecastDataset +from openstef_models.explainability.mixins import ExplainableForecaster +from openstef_models.models.forecasting.gblinear_forecaster import ( + GBLinearForecaster, + GBLinearHyperParams, +) +from openstef_models.models.forecasting.lgbm_forecaster import LGBMForecaster, LGBMHyperParams + +if TYPE_CHECKING: + from openstef_models.models.forecasting.forecaster import Forecaster + +logger = logging.getLogger(__name__) + +ForecasterHyperParams = GBLinearHyperParams | LGBMHyperParams +ForecasterType = GBLinearForecaster | LGBMForecaster + + +class StackingCombinerConfig(ForecastCombinerConfig): + """Configuration for the Stacking final learner.""" + + hyperparams: HyperParams = Field( + description="Hyperparameters for the Stacking Combiner.", + ) + + quantiles: list[Quantile] = Field( + default=[Quantile(0.5)], + description=( + "Probability levels for uncertainty estimation. Each quantile represents a confidence level " + "(e.g., 0.1 = 10th percentile, 0.5 = median, 0.9 = 90th percentile). " + "Models must generate predictions for all specified quantiles." + ), + min_length=1, + ) + + horizons: list[LeadTime] = Field( + default=..., + description=( + "Lead times for predictions, accounting for data availability and versioning cutoffs. " + "Each horizon defines how far ahead the model should predict." + ), + min_length=1, + ) + + @field_validator("hyperparams", mode="after") + @staticmethod + def validate_forecaster( + v: HyperParams, + ) -> HyperParams: + """Validate that the forecaster class is set in the hyperparameters. + + Args: + v: Hyperparameters to validate. + + Returns: + Validated hyperparameters. + + Raises: + ValueError: If the forecaster class is not set. + """ + if not hasattr(v, "forecaster_class"): + raise ValueError("forecaster_class must be set in hyperparameters for StackingCombinerConfig.") + return v + + +class StackingCombiner(ForecastCombiner): + """Combines base Forecaster predictions per quantile into final predictions using a regression approach.""" + + Config = StackingCombinerConfig + LGBMHyperParams = LGBMHyperParams + GBLinearHyperParams = GBLinearHyperParams + + def __init__( + self, + config: StackingCombinerConfig, + ) -> None: + """Initialize the Stacking final learner. + + Args: + config: Configuration for the Stacking combiner. + """ + forecaster_hyperparams = cast(ForecasterHyperParams, config.hyperparams) + self.quantiles = config.quantiles + self.config = config + self.hyperparams = forecaster_hyperparams + self._is_fitted: bool = False + + # Split forecaster per quantile + models: list[Forecaster] = [] + for q in self.quantiles: + forecaster_cls = forecaster_hyperparams.forecaster_class() + forecaster_config = forecaster_cls.Config( + horizons=[config.max_horizon], + quantiles=[q], + ) + if "hyperparams" in forecaster_cls.Config.model_fields: + forecaster_config = forecaster_config.model_copy(update={"hyperparams": forecaster_hyperparams}) + + model = forecaster_config.forecaster_from_config() + models.append(model) + self.models = models + + @staticmethod + def _combine_datasets( + data: ForecastInputDataset, additional_features: ForecastInputDataset + ) -> ForecastInputDataset: + """Combine base Forecaster predictions with additional features for final learner input. + + Args: + data: ForecastInputDataset containing base Forecaster predictions. + additional_features: ForecastInputDataset containing additional features. + + Returns: + ForecastInputDataset with combined features. + """ + additional_df = additional_features.data.loc[ + :, [col for col in additional_features.data.columns if col not in data.data.columns] + ] + # Merge on index to combine datasets + combined_df = data.data.join(additional_df) + + return ForecastInputDataset( + data=combined_df, + sample_interval=data.sample_interval, + forecast_start=data.forecast_start, + ) + + @override + def fit( + self, + data: EnsembleForecastDataset, + data_val: EnsembleForecastDataset | None = None, + additional_features: ForecastInputDataset | None = None, + ) -> None: + + for i, q in enumerate(self.quantiles): + if additional_features is not None: + dataset = data.select_quantile(quantile=q) + input_data = self._combine_datasets( + data=dataset, + additional_features=additional_features, + ) + else: + input_data = data.select_quantile(quantile=q) + + # Prepare input data by dropping rows with NaN target values + target_dropna = partial(pd.DataFrame.dropna, subset=[input_data.target_column]) # pyright: ignore[reportUnknownMemberType] + input_data = input_data.pipe_pandas(target_dropna) + + self.models[i].fit(data=input_data, data_val=None) + + @override + def predict( + self, + data: EnsembleForecastDataset, + additional_features: ForecastInputDataset | None = None, + ) -> ForecastDataset: + if not self.is_fitted: + raise NotFittedError(self.__class__.__name__) + + # Generate predictions + predictions: list[pd.DataFrame] = [] + for i, q in enumerate(self.quantiles): + if additional_features is not None: + input_data = self._combine_datasets( + data=data.select_quantile(quantile=q), + additional_features=additional_features, + ) + else: + input_data = data.select_quantile(quantile=q) + + if isinstance(self.models[i], GBLinearForecaster): + feature_cols = [x for x in input_data.data.columns if x != data.target_column] + feature_dropna = partial(pd.DataFrame.dropna, subset=feature_cols) # pyright: ignore[reportUnknownMemberType] + input_data = input_data.pipe_pandas(feature_dropna) + + p = self.models[i].predict(data=input_data).data + predictions.append(p) + + # Concatenate predictions along columns to form a DataFrame with quantile columns + df = pd.concat(predictions, axis=1) + + return ForecastDataset( + data=df, + sample_interval=data.sample_interval, + ) + + @override + def predict_contributions( + self, + data: EnsembleForecastDataset, + additional_features: ForecastInputDataset | None = None, + ) -> pd.DataFrame: + + predictions: list[pd.DataFrame] = [] + for i, q in enumerate(self.quantiles): + if additional_features is not None: + input_data = self._combine_datasets( + data=data.select_quantile(quantile=q), + additional_features=additional_features, + ) + else: + input_data = data.select_quantile(quantile=q) + model = self.models[i] + if not isinstance(model, ExplainableForecaster): + raise NotImplementedError( + "Predicting contributions is only supported for ExplainableForecaster models." + ) + p = model.predict_contributions(data=input_data, scale=True) + predictions.append(p) + + contributions = pd.concat(predictions, axis=1) + + target_series = data.target_series + if target_series is not None: + contributions[data.target_column] = target_series + + return contributions + + @property + def is_fitted(self) -> bool: + """Check the StackingForecastCombiner is fitted.""" + return all(x.is_fitted for x in self.models) diff --git a/packages/openstef-meta/src/openstef_meta/models/forecasting/__init__.py b/packages/openstef-meta/src/openstef_meta/models/forecasting/__init__.py new file mode 100644 index 000000000..fce9bcb92 --- /dev/null +++ b/packages/openstef-meta/src/openstef_meta/models/forecasting/__init__.py @@ -0,0 +1,12 @@ +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# +# SPDX-License-Identifier: MPL-2.0 +"""This module provides meta-forecasting models.""" + +from .residual_forecaster import ResidualForecaster, ResidualForecasterConfig, ResidualHyperParams + +__all__ = [ + "ResidualForecaster", + "ResidualForecasterConfig", + "ResidualHyperParams", +] diff --git a/packages/openstef-meta/src/openstef_meta/models/forecasting/residual_forecaster.py b/packages/openstef-meta/src/openstef_meta/models/forecasting/residual_forecaster.py new file mode 100644 index 000000000..de44e003c --- /dev/null +++ b/packages/openstef-meta/src/openstef_meta/models/forecasting/residual_forecaster.py @@ -0,0 +1,327 @@ +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# +# SPDX-License-Identifier: MPL-2.0 +"""Residual Forecaster. + +Provides method that attempts to combine the advantages of a linear model (Extraplolation) +and tree-based model (Non-linear patterns). This is achieved by training a primary model, +typically linear, followed by a secondary model that learns to predict the residuals (errors) of the primary model. +""" + +import logging +from typing import override + +import pandas as pd +from pydantic import Field, model_validator + +from openstef_core.datasets import ForecastDataset, ForecastInputDataset +from openstef_core.exceptions import ( + NotFittedError, +) +from openstef_core.mixins import HyperParams +from openstef_core.types import Quantile +from openstef_models.models.forecasting.forecaster import ( + Forecaster, + ForecasterConfig, +) +from openstef_models.models.forecasting.gblinear_forecaster import ( + GBLinearForecaster, + GBLinearHyperParams, +) +from openstef_models.models.forecasting.lgbm_forecaster import LGBMForecaster, LGBMHyperParams +from openstef_models.models.forecasting.lgbmlinear_forecaster import LGBMLinearForecaster, LGBMLinearHyperParams +from openstef_models.models.forecasting.xgboost_forecaster import XGBoostForecaster, XGBoostHyperParams + +logger = logging.getLogger(__name__) + +ResidualBaseForecaster = LGBMForecaster | LGBMLinearForecaster | XGBoostForecaster | GBLinearForecaster +ResidualBaseForecasterHyperParams = LGBMHyperParams | LGBMLinearHyperParams | XGBoostHyperParams | GBLinearHyperParams + + +class ResidualHyperParams(HyperParams): + """Hyperparameters for Stacked LGBM GBLinear Regressor.""" + + primary_hyperparams: ResidualBaseForecasterHyperParams = Field( + default=GBLinearHyperParams(), + description="Primary model hyperparams. Defaults to GBLinearHyperParams.", + ) + + secondary_hyperparams: ResidualBaseForecasterHyperParams = Field( + default=LGBMHyperParams(), + description="Hyperparameters for the final learner. Defaults to LGBMHyperparams.", + ) + + primary_name: str = Field( + default="primary_model", + description="Name identifier for the primary model.", + ) + + secondary_name: str = Field( + default="secondary_model", + description="Name identifier for the secondary model.", + ) + + @model_validator(mode="after") + def validate_names(self) -> "ResidualHyperParams": + """Validate that primary and secondary names are not the same. + + Raises: + ValueError: If primary and secondary names are the same. + + Returns: + ResidualHyperParams: The validated hyperparameters. + """ + if self.primary_name == self.secondary_name: + raise ValueError("Primary and secondary model names must be different.") + return self + + +class ResidualForecasterConfig(ForecasterConfig): + """Configuration for Hybrid-based forecasting models.""" + + hyperparams: ResidualHyperParams = ResidualHyperParams() + + verbosity: bool = Field( + default=True, + description="Enable verbose output from the Hybrid model (True/False).", + ) + + +class ResidualForecaster(Forecaster): + """MetaForecaster that implements residual modeling. + + It takes in a primary forecaster and a residual forecaster. The primary forecaster makes initial predictions, + and the residual forecaster models the residuals (errors) of the primary forecaster to improve overall accuracy. + """ + + Config = ResidualForecasterConfig + HyperParams = ResidualHyperParams + + def __init__(self, config: ResidualForecasterConfig) -> None: + """Initialize the Hybrid forecaster.""" + self._config = config + + self._primary_model: ResidualBaseForecaster = self._init_base_learners( + config=config, base_hyperparams=[config.hyperparams.primary_hyperparams] + )[0] + + self._secondary_model: list[ResidualBaseForecaster] = self._init_secondary_model( + hyperparams=config.hyperparams.secondary_hyperparams + ) + self.primary_name = config.hyperparams.primary_name + self.secondary_name = config.hyperparams.secondary_name + self._is_fitted = False + + def _init_secondary_model(self, hyperparams: ResidualBaseForecasterHyperParams) -> list[ResidualBaseForecaster]: + """Initialize secondary model for residual forecasting. + + Returns: + list[Forecaster]: List containing the initialized secondary model forecaster. + """ + models: list[ResidualBaseForecaster] = [] + # Different datasets per quantile, so we need a model per quantile + for q in self.config.quantiles: + config = self._config.model_copy(update={"quantiles": [q]}) + secondary_model = self._init_base_learners(config=config, base_hyperparams=[hyperparams])[0] + models.append(secondary_model) + + return models + + @staticmethod + def _init_base_learners( + config: ForecasterConfig, base_hyperparams: list[ResidualBaseForecasterHyperParams] + ) -> list[ResidualBaseForecaster]: + """Initialize base Forecaster based on provided hyperparameters. + + Returns: + list[Forecaster]: List of initialized base Forecaster forecasters. + """ + base_learners: list[ResidualBaseForecaster] = [] + horizons = config.horizons + quantiles = config.quantiles + + for hyperparams in base_hyperparams: + forecaster_cls = hyperparams.forecaster_class() + config = forecaster_cls.Config(horizons=horizons, quantiles=quantiles) + if "hyperparams" in forecaster_cls.Config.model_fields: + config = config.model_copy(update={"hyperparams": hyperparams}) + + base_learners.append(config.forecaster_from_config()) + + return base_learners + + @override + def fit(self, data: ForecastInputDataset, data_val: ForecastInputDataset | None = None) -> None: + """Fit the Hybrid model to the training data. + + Args: + data: Training data in the expected ForecastInputDataset format. + data_val: Validation data for tuning the model (optional, not used in this implementation). + + """ + # Fit primary model + self._primary_model.fit(data=data, data_val=data_val) + + # Reset forecast start date to ensure we fit on the full training set + full_dataset = ForecastInputDataset( + data=data.data, + sample_interval=data.sample_interval, + target_column=data.target_column, + forecast_start=data.index[0], + ) + + secondary_input = self._prepare_secondary_input( + quantiles=self.config.quantiles, + base_predictions=self._primary_model.predict(data=full_dataset), + data=data, + ) + # Predict primary model on validation data if provided + if data_val is not None: + full_val_dataset = ForecastInputDataset( + data=data_val.data, + sample_interval=data_val.sample_interval, + target_column=data_val.target_column, + forecast_start=data_val.index[0], + ) + + secondary_val_input = self._prepare_secondary_input( + quantiles=self.config.quantiles, + base_predictions=self._primary_model.predict(data=full_val_dataset), + data=data_val, + ) + # Fit secondary model on residuals + [ + self._secondary_model[i].fit(data=secondary_input[q], data_val=secondary_val_input[q]) + for i, q in enumerate(secondary_input) + ] + + else: + # Fit secondary model on residuals + [ + self._secondary_model[i].fit(data=secondary_input[q], data_val=None) + for i, q in enumerate(secondary_input) + ] + + self._is_fitted = True + + @property + @override + def is_fitted(self) -> bool: + """Check the ResidualForecaster is fitted.""" + return self._is_fitted + + @staticmethod + def _prepare_secondary_input( + quantiles: list[Quantile], + base_predictions: ForecastDataset, + data: ForecastInputDataset, + ) -> dict[Quantile, ForecastInputDataset]: + """Adjust target series to be residuals for secondary model training. + + Args: + quantiles: List of quantiles to prepare data for. + base_predictions: Predictions from the primary model. + data: Original input data. + + Returns: + dict[Quantile, ForecastInputDataset]: Prepared datasets for each quantile. + """ + predictions_quantiles: dict[Quantile, ForecastInputDataset] = {} + sample_interval = data.sample_interval + for q in quantiles: + predictions = base_predictions.data[q.format()] + df = data.data.copy() + df[data.target_column] = data.target_series - predictions + predictions_quantiles[q] = ForecastInputDataset( + data=df, + sample_interval=sample_interval, + target_column=data.target_column, + forecast_start=df.index[0], + ) + + return predictions_quantiles + + def _predict_secodary_model(self, data: ForecastInputDataset) -> ForecastDataset: + predictions: dict[str, pd.Series] = {} + for model in self._secondary_model: + pred = model.predict(data=data) + q = model.config.quantiles[0].format() + predictions[q] = pred.data[q] + + return ForecastDataset( + data=pd.DataFrame(predictions), + sample_interval=data.sample_interval, + ) + + def predict(self, data: ForecastInputDataset) -> ForecastDataset: + """Generate predictions using the ResidualForecaster model. + + Args: + data: Input data for prediction. + + Returns: + ForecastDataset containing the predictions. + + Raises: + NotFittedError: If the ResidualForecaster instance is not fitted yet. + """ + if not self.is_fitted: + raise NotFittedError("The ResidualForecaster instance is not fitted yet. Call 'fit' first.") + + primary_predictions = self._primary_model.predict(data=data).data + + secondary_predictions = self._predict_secodary_model(data=data).data + + final_predictions = primary_predictions + secondary_predictions + + return ForecastDataset( + data=final_predictions, + sample_interval=data.sample_interval, + ) + + def predict_contributions(self, data: ForecastInputDataset, *, scale: bool = True) -> pd.DataFrame: + """Generate prediction contributions using the ResidualForecaster model. + + Args: + data: Input data for prediction contributions. + scale: Whether to scale contributions to sum to 1. Defaults to True. + + Returns: + pd.DataFrame containing the prediction contributions. + """ + primary_predictions = self._primary_model.predict(data=data).data + + secondary_predictions = self._predict_secodary_model(data=data).data + + if not scale: + primary_contributions = primary_predictions + primary_name = self._primary_model.__class__.__name__ + primary_contributions.columns = [f"{primary_name}_{q}" for q in primary_contributions.columns] + + secondary_contributions = secondary_predictions + secondary_name = self._secondary_model[0].__class__.__name__ + secondary_contributions.columns = [f"{secondary_name}_{q}" for q in secondary_contributions.columns] + + return pd.concat([primary_contributions, secondary_contributions], axis=1) + + primary_contributions = primary_predictions.abs() / (primary_predictions.abs() + secondary_predictions.abs()) + primary_contributions.columns = [f"{self.primary_name}_{q}" for q in primary_contributions.columns] + + secondary_contributions = secondary_predictions.abs() / ( + primary_predictions.abs() + secondary_predictions.abs() + ) + secondary_contributions.columns = [f"{self.secondary_name}_{q}" for q in secondary_contributions.columns] + + return pd.concat([primary_contributions, secondary_contributions], axis=1) + + @property + def config(self) -> ResidualForecasterConfig: + """Get the configuration of the ResidualForecaster. + + Returns: + ResidualForecasterConfig: The configuration of the forecaster. + """ + return self._config + + +__all__ = ["ResidualForecaster", "ResidualForecasterConfig", "ResidualHyperParams"] diff --git a/packages/openstef-meta/src/openstef_meta/presets/__init__.py b/packages/openstef-meta/src/openstef_meta/presets/__init__.py new file mode 100644 index 000000000..ad62320c2 --- /dev/null +++ b/packages/openstef-meta/src/openstef_meta/presets/__init__.py @@ -0,0 +1,9 @@ +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# +# SPDX-License-Identifier: MPL-2.0 + +"""Package for preset forecasting workflows.""" + +from .forecasting_workflow import EnsembleForecastingModel, EnsembleWorkflowConfig, create_ensemble_workflow + +__all__ = ["EnsembleForecastingModel", "EnsembleWorkflowConfig", "create_ensemble_workflow"] diff --git a/packages/openstef-meta/src/openstef_meta/presets/forecasting_workflow.py b/packages/openstef-meta/src/openstef_meta/presets/forecasting_workflow.py new file mode 100644 index 000000000..52568b3a1 --- /dev/null +++ b/packages/openstef-meta/src/openstef_meta/presets/forecasting_workflow.py @@ -0,0 +1,515 @@ +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# +# SPDX-License-Identifier: MPL-2.0 + +"""Ensemble forecasting workflow preset. + +Mimics OpenSTEF-models forecasting workflow with ensemble capabilities. +""" + +from collections.abc import Sequence +from datetime import timedelta +from typing import TYPE_CHECKING, Literal, cast + +from pydantic import Field + +from openstef_beam.evaluation.metric_providers import ( + MetricDirection, + MetricProvider, + ObservedProbabilityProvider, + R2Provider, +) +from openstef_core.base_model import BaseConfig +from openstef_core.datasets.timeseries_dataset import TimeSeriesDataset +from openstef_core.mixins.transform import Transform, TransformPipeline +from openstef_core.types import LeadTime, Q, Quantile, QuantileOrGlobal +from openstef_meta.models.ensemble_forecasting_model import EnsembleForecastingModel +from openstef_meta.models.forecast_combiners.learned_weights_combiner import WeightsCombiner +from openstef_meta.models.forecast_combiners.rules_combiner import RulesCombiner +from openstef_meta.models.forecast_combiners.stacking_combiner import StackingCombiner +from openstef_meta.models.forecasting.residual_forecaster import ResidualForecaster +from openstef_models.integrations.mlflow import MLFlowStorage +from openstef_models.mixins.model_serializer import ModelIdentifier +from openstef_models.models.forecasting.gblinear_forecaster import GBLinearForecaster +from openstef_models.models.forecasting.lgbm_forecaster import LGBMForecaster +from openstef_models.models.forecasting.lgbmlinear_forecaster import LGBMLinearForecaster +from openstef_models.models.forecasting.xgboost_forecaster import XGBoostForecaster +from openstef_models.presets.forecasting_workflow import LocationConfig +from openstef_models.transforms.energy_domain import WindPowerFeatureAdder +from openstef_models.transforms.general import Clipper, EmptyFeatureRemover, SampleWeighter, Scaler +from openstef_models.transforms.general.imputer import Imputer +from openstef_models.transforms.general.nan_dropper import NaNDropper +from openstef_models.transforms.general.selector import Selector +from openstef_models.transforms.postprocessing import QuantileSorter +from openstef_models.transforms.time_domain import ( + CyclicFeaturesAdder, + DatetimeFeaturesAdder, + HolidayFeatureAdder, + RollingAggregatesAdder, +) +from openstef_models.transforms.time_domain.lags_adder import LagsAdder +from openstef_models.transforms.time_domain.rolling_aggregates_adder import AggregationFunction +from openstef_models.transforms.validation import CompletenessChecker, FlatlineChecker, InputConsistencyChecker +from openstef_models.transforms.weather_domain import ( + AtmosphereDerivedFeaturesAdder, + DaylightFeatureAdder, + RadiationDerivedFeaturesAdder, +) +from openstef_models.utils.data_split import DataSplitter +from openstef_models.utils.feature_selection import Exclude, FeatureSelection, Include +from openstef_models.workflows.custom_forecasting_workflow import CustomForecastingWorkflow, ForecastingCallback + +if TYPE_CHECKING: + from openstef_models.models.forecasting.forecaster import Forecaster + + +class EnsembleWorkflowConfig(BaseConfig): + """Configuration for ensemble forecasting workflows.""" + + model_id: ModelIdentifier + + # Ensemble configuration + ensemble_type: Literal["learned_weights", "stacking", "rules"] = Field(default="learned_weights") + base_models: Sequence[Literal["lgbm", "gblinear", "xgboost", "lgbm_linear"]] = Field(default=["lgbm", "gblinear"]) + combiner_model: Literal["lgbm", "rf", "xgboost", "logistic", "gblinear"] = Field(default="lgbm") + + # Forecast configuration + quantiles: list[Quantile] = Field( + default=[Q(0.5)], + description="List of quantiles to predict for probabilistic forecasting.", + ) + + sample_interval: timedelta = Field( + default=timedelta(minutes=15), + description="Time interval between consecutive data samples.", + ) + horizons: list[LeadTime] = Field( + default=[LeadTime.from_string("PT48H")], + description="List of forecast horizons to predict.", + ) + + location: LocationConfig = Field( + default=LocationConfig(), + description="Location information for the forecasting workflow.", + ) + + # Forecaster hyperparameters + xgboost_hyperparams: XGBoostForecaster.HyperParams = Field( + default=XGBoostForecaster.HyperParams(), + description="Hyperparameters for XGBoost forecaster.", + ) + gblinear_hyperparams: GBLinearForecaster.HyperParams = Field( + default=GBLinearForecaster.HyperParams(), + description="Hyperparameters for GBLinear forecaster.", + ) + + lgbm_hyperparams: LGBMForecaster.HyperParams = Field( + default=LGBMForecaster.HyperParams(), + description="Hyperparameters for LightGBM forecaster.", + ) + + lgbmlinear_hyperparams: LGBMLinearForecaster.HyperParams = Field( + default=LGBMLinearForecaster.HyperParams(), + description="Hyperparameters for LightGBM forecaster.", + ) + + residual_hyperparams: ResidualForecaster.HyperParams = Field( + default=ResidualForecaster.HyperParams(), + description="Hyperparameters for Residual forecaster.", + ) + + # Data properties + target_column: str = Field(default="load", description="Name of the target variable column in datasets.") + energy_price_column: str = Field( + default="day_ahead_electricity_price", + description="Name of the energy price column in datasets.", + ) + radiation_column: str = Field(default="radiation", description="Name of the radiation column in datasets.") + wind_speed_column: str = Field(default="windspeed", description="Name of the wind speed column in datasets.") + pressure_column: str = Field(default="pressure", description="Name of the pressure column in datasets.") + temperature_column: str = Field(default="temperature", description="Name of the temperature column in datasets.") + relative_humidity_column: str = Field( + default="relative_humidity", + description="Name of the relative humidity column in datasets.", + ) + predict_history: timedelta = Field( + default=timedelta(days=14), + description="Amount of historical data available at prediction time.", + ) + cutoff_history: timedelta = Field( + default=timedelta(days=0), + description="Amount of historical data to exclude from training and prediction due to incomplete features " + "from lag-based preprocessing. When using lag transforms (e.g., lag-14), the first N days contain NaN values. " + "Set this to match your maximum lag duration (e.g., timedelta(days=14)). " + "Default of 0 assumes no invalid rows are created by preprocessing. " + "Note: should be same as predict_history if you are using lags. We default to disabled to keep the same " + "behaviour as openstef 3.0.", + ) + + # Feature engineering and validation + completeness_threshold: float = Field( + default=0.5, + description="Minimum fraction of data that should be available for making a regular forecast.", + ) + flatliner_threshold: timedelta = Field( + default=timedelta(hours=24), + description="Number of minutes that the load has to be constant to detect a flatliner.", + ) + detect_non_zero_flatliner: bool = Field( + default=False, + description="If True, flatliners are also detected on non-zero values (median of the load).", + ) + rolling_aggregate_features: list[AggregationFunction] = Field( + default=[], + description="If not None, rolling aggregate(s) of load will be used as features in the model.", + ) + clip_features: FeatureSelection = Field( + default=FeatureSelection(include=None, exclude=None), + description="Feature selection for which features to clip.", + ) + sample_weight_scale_percentile: int = Field( + default=95, + description="Percentile of target values used as scaling reference. " + "Values are normalized relative to this percentile before weighting.", + ) + forecaster_sample_weight_exponent: dict[str, float] = Field( + default={"gblinear": 1.0, "lgbm": 0, "xgboost": 0, "lgbm_linear": 0}, + description="Exponent applied to scale the sample weights. " + "0=uniform weights, 1=linear scaling, >1=stronger emphasis on high values. " + "Note: Defaults to 1.0 for gblinear congestion models.", + ) + + forecast_combiner_sample_weight_exponent: float = Field( + default=0, + description="Exponent applied to scale the sample weights for the forecast combiner model. " + "0=uniform weights, 1=linear scaling, >1=stronger emphasis on high values.", + ) + + sample_weight_floor: float = Field( + default=0.1, + description="Minimum weight value to ensure all samples contribute to training.", + ) + + # Data splitting strategy + data_splitter: DataSplitter = Field( + default=DataSplitter( + # Copied from OpenSTEF3 pipeline defaults + val_fraction=0.15, + test_fraction=0.0, + stratification_fraction=0.15, + min_days_for_stratification=4, + ), + description="Configuration for splitting data into training, validation, and test sets.", + ) + + # Evaluation + evaluation_metrics: list[MetricProvider] = Field( + default_factory=lambda: [R2Provider(), ObservedProbabilityProvider()], + description="List of metric providers for evaluating model score.", + ) + + # Callbacks + mlflow_storage: MLFlowStorage | None = Field( + default_factory=MLFlowStorage, + description="Configuration for MLflow experiment tracking and model storage.", + ) + + model_reuse_enable: bool = Field( + default=True, + description="Whether to enable reuse of previously trained models.", + ) + model_reuse_max_age: timedelta = Field( + default=timedelta(days=7), + description="Maximum age of a model to be considered for reuse.", + ) + + model_selection_enable: bool = Field( + default=True, + description="Whether to enable automatic model selection based on performance.", + ) + model_selection_metric: tuple[QuantileOrGlobal, str, MetricDirection] = Field( + default=(Q(0.5), "R2", "higher_is_better"), + description="Metric to monitor for model performance when retraining.", + ) + model_selection_old_model_penalty: float = Field( + default=1.2, + description="Penalty to apply to the old model's metric to bias selection towards newer models.", + ) + + verbosity: Literal[0, 1, 2, 3, True] = Field( + default=0, description="Verbosity level. 0=silent, 1=warning, 2=info, 3=debug" + ) + + # Metadata + tags: dict[str, str] = Field( + default_factory=dict, + description="Optional metadata tags for the model.", + ) + + +# Build preprocessing components +def checks(config: EnsembleWorkflowConfig) -> list[Transform[TimeSeriesDataset, TimeSeriesDataset]]: + return [ + InputConsistencyChecker(), + FlatlineChecker( + load_column=config.target_column, + flatliner_threshold=config.flatliner_threshold, + detect_non_zero_flatliner=config.detect_non_zero_flatliner, + error_on_flatliner=False, + ), + CompletenessChecker(completeness_threshold=config.completeness_threshold), + ] + + +def feature_adders(config: EnsembleWorkflowConfig) -> list[Transform[TimeSeriesDataset, TimeSeriesDataset]]: + return [ + LagsAdder( + history_available=config.predict_history, + horizons=config.horizons, + add_trivial_lags=True, + target_column=config.target_column, + ), + WindPowerFeatureAdder( + windspeed_reference_column=config.wind_speed_column, + ), + AtmosphereDerivedFeaturesAdder( + pressure_column=config.pressure_column, + relative_humidity_column=config.relative_humidity_column, + temperature_column=config.temperature_column, + ), + RadiationDerivedFeaturesAdder( + coordinate=config.location.coordinate, + radiation_column=config.radiation_column, + ), + CyclicFeaturesAdder(), + DaylightFeatureAdder( + coordinate=config.location.coordinate, + ), + RollingAggregatesAdder( + feature=config.target_column, + aggregation_functions=config.rolling_aggregate_features, + horizons=config.horizons, + ), + ] + + +def feature_standardizers(config: EnsembleWorkflowConfig) -> list[Transform[TimeSeriesDataset, TimeSeriesDataset]]: + return cast( + list[Transform[TimeSeriesDataset, TimeSeriesDataset]], + [ + Clipper(selection=Include(config.energy_price_column).combine(config.clip_features), mode="standard"), + Scaler(selection=Exclude(config.target_column), method="standard"), + EmptyFeatureRemover(), + ], + ) + + +def create_ensemble_workflow(config: EnsembleWorkflowConfig) -> CustomForecastingWorkflow: # noqa: C901, PLR0912, PLR0915 + """Create an ensemble forecasting workflow from configuration. + + Args: + config (EnsembleWorkflowConfig): Configuration for the ensemble workflow. + + Returns: + CustomForecastingWorkflow: Configured ensemble forecasting workflow. + + Raises: + ValueError: If an unsupported base model or combiner type is specified. + """ + # Common preprocessing + common_preprocessing = TransformPipeline( + transforms=[ + *checks(config), + *feature_adders(config), + HolidayFeatureAdder(country_code=config.location.country_code), + DatetimeFeaturesAdder(onehot_encode=False), + *feature_standardizers(config), + ] + ) + + # Build forecasters and their processing pipelines + forecaster_preprocessing: dict[str, list[Transform[TimeSeriesDataset, TimeSeriesDataset]]] = {} + forecasters: dict[str, Forecaster] = {} + for model_type in config.base_models: + if model_type == "lgbm": + forecasters[model_type] = LGBMForecaster( + config=LGBMForecaster.Config(quantiles=config.quantiles, horizons=config.horizons) + ) + forecaster_preprocessing[model_type] = [ + SampleWeighter( + target_column=config.target_column, + weight_exponent=config.forecaster_sample_weight_exponent[model_type], + weight_floor=config.sample_weight_floor, + weight_scale_percentile=config.sample_weight_scale_percentile, + ), + ] + + elif model_type == "gblinear": + forecasters[model_type] = GBLinearForecaster( + config=GBLinearForecaster.Config(quantiles=config.quantiles, horizons=config.horizons) + ) + forecaster_preprocessing[model_type] = [ + SampleWeighter( + target_column=config.target_column, + weight_exponent=config.forecaster_sample_weight_exponent[model_type], + weight_floor=config.sample_weight_floor, + weight_scale_percentile=config.sample_weight_scale_percentile, + ), + # Remove lags + Selector( + selection=FeatureSelection( + exclude=set( + LagsAdder( + history_available=config.predict_history, + horizons=config.horizons, + add_trivial_lags=True, + target_column=config.target_column, + ).features_added() + ).difference({"load_lag_P7D"}) + ) + ), + # Remove holiday features to avoid linear dependencies + Selector( + selection=FeatureSelection( + exclude=set(HolidayFeatureAdder(country_code=config.location.country_code).features_added()) + ) + ), + Selector( + selection=FeatureSelection(exclude=set(DatetimeFeaturesAdder(onehot_encode=False).features_added())) + ), + Imputer( + selection=Exclude(config.target_column), + imputation_strategy="mean", + fill_future_values=Include(config.energy_price_column), + ), + NaNDropper( + selection=Exclude(config.target_column), + ), + ] + elif model_type == "xgboost": + forecasters[model_type] = XGBoostForecaster( + config=XGBoostForecaster.Config(quantiles=config.quantiles, horizons=config.horizons) + ) + forecaster_preprocessing[model_type] = [ + SampleWeighter( + target_column=config.target_column, + weight_exponent=config.forecaster_sample_weight_exponent[model_type], + weight_floor=config.sample_weight_floor, + weight_scale_percentile=config.sample_weight_scale_percentile, + ), + ] + elif model_type == "lgbm_linear": + forecasters[model_type] = LGBMLinearForecaster( + config=LGBMLinearForecaster.Config(quantiles=config.quantiles, horizons=config.horizons) + ) + forecaster_preprocessing[model_type] = [ + SampleWeighter( + target_column=config.target_column, + weight_exponent=config.forecaster_sample_weight_exponent[model_type], + weight_floor=config.sample_weight_floor, + weight_scale_percentile=config.sample_weight_scale_percentile, + ), + ] + else: + msg = f"Unsupported base model type: {model_type}" + raise ValueError(msg) + + # Build combiner + # Case: Ensemble type, combiner model + match (config.ensemble_type, config.combiner_model): + case ("learned_weights", "lgbm"): + combiner_hp = WeightsCombiner.LGBMHyperParams() + combiner_config = WeightsCombiner.Config( + hyperparams=combiner_hp, horizons=config.horizons, quantiles=config.quantiles + ) + combiner = WeightsCombiner( + config=combiner_config, + ) + case ("learned_weights", "rf"): + combiner_hp = WeightsCombiner.RFHyperParams() + combiner_config = WeightsCombiner.Config( + hyperparams=combiner_hp, horizons=config.horizons, quantiles=config.quantiles + ) + combiner = WeightsCombiner( + config=combiner_config, + ) + case ("learned_weights", "xgboost"): + combiner_hp = WeightsCombiner.XGBHyperParams() + combiner_config = WeightsCombiner.Config( + hyperparams=combiner_hp, horizons=config.horizons, quantiles=config.quantiles + ) + combiner = WeightsCombiner( + config=combiner_config, + ) + case ("learned_weights", "logistic"): + combiner_hp = WeightsCombiner.LogisticHyperParams() + combiner_config = WeightsCombiner.Config( + hyperparams=combiner_hp, horizons=config.horizons, quantiles=config.quantiles + ) + combiner = WeightsCombiner( + config=combiner_config, + ) + case ("stacking", "lgbm"): + combiner_hp = StackingCombiner.LGBMHyperParams() + combiner_config = StackingCombiner.Config( + hyperparams=combiner_hp, horizons=config.horizons, quantiles=config.quantiles + ) + combiner = StackingCombiner( + config=combiner_config, + ) + case ("stacking", "gblinear"): + combiner_hp = StackingCombiner.GBLinearHyperParams(reg_alpha=0.0, reg_lambda=0.0) + combiner_config = StackingCombiner.Config( + hyperparams=combiner_hp, horizons=config.horizons, quantiles=config.quantiles + ) + combiner = StackingCombiner( + config=combiner_config, + ) + case ("rules", _): + combiner_config = RulesCombiner.Config(horizons=config.horizons, quantiles=config.quantiles) + combiner = RulesCombiner( + config=combiner_config, + ) + case _: + msg = f"Unsupported ensemble and combiner combination: {config.ensemble_type}, {config.combiner_model}" + raise ValueError(msg) + + postprocessing = [QuantileSorter()] + + model_specific_preprocessing: dict[str, TransformPipeline[TimeSeriesDataset]] = { + name: TransformPipeline(transforms=transforms) for name, transforms in forecaster_preprocessing.items() + } + + if config.forecast_combiner_sample_weight_exponent != 0: + combiner_transforms = [ + SampleWeighter( + target_column=config.target_column, + weight_exponent=config.forecast_combiner_sample_weight_exponent, + weight_floor=config.sample_weight_floor, + weight_scale_percentile=config.sample_weight_scale_percentile, + ), + Selector(selection=Include("sample_weight", config.target_column)), + ] + else: + combiner_transforms = [] + + combiner_preprocessing: TransformPipeline[TimeSeriesDataset] = TransformPipeline(transforms=combiner_transforms) + + ensemble_model = EnsembleForecastingModel( + common_preprocessing=common_preprocessing, + model_specific_preprocessing=model_specific_preprocessing, + combiner_preprocessing=combiner_preprocessing, + postprocessing=TransformPipeline(transforms=postprocessing), + forecasters=forecasters, + combiner=combiner, + target_column=config.target_column, + data_splitter=config.data_splitter, + ) + + callbacks: list[ForecastingCallback] = [] + # TODO(Egor): Implement MLFlow for OpenSTEF-meta # noqa: TD003 + + return CustomForecastingWorkflow(model=ensemble_model, model_id=config.model_id, callbacks=callbacks) + + +__all__ = ["EnsembleWorkflowConfig", "create_ensemble_workflow"] diff --git a/packages/openstef-meta/src/openstef_meta/utils/__init__.py b/packages/openstef-meta/src/openstef_meta/utils/__init__.py new file mode 100644 index 000000000..a6b9e93a4 --- /dev/null +++ b/packages/openstef-meta/src/openstef_meta/utils/__init__.py @@ -0,0 +1,15 @@ +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# +# SPDX-License-Identifier: MPL-2.0 + +"""Utility functions and classes for OpenSTEF Meta.""" + +from .decision_tree import Decision, DecisionTree, Rule +from .pinball_errors import calculate_pinball_errors + +__all__ = [ + "Decision", + "DecisionTree", + "Rule", + "calculate_pinball_errors", +] diff --git a/packages/openstef-meta/src/openstef_meta/utils/datasets.py b/packages/openstef-meta/src/openstef_meta/utils/datasets.py new file mode 100644 index 000000000..e85c05b09 --- /dev/null +++ b/packages/openstef-meta/src/openstef_meta/utils/datasets.py @@ -0,0 +1,282 @@ +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# +# SPDX-License-Identifier: MPL-2.0 +"""Ensemble Forecast Dataset. + +Validated dataset for ensemble forecasters first stage output. +Implements methods to select quantile-specific ForecastInputDatasets for final learners. +Also supports constructing classifation targets based on pinball loss. +""" + +from datetime import datetime, timedelta +from typing import Self, override + +import pandas as pd + +from openstef_core.datasets.validated_datasets import ForecastDataset, ForecastInputDataset, TimeSeriesDataset +from openstef_core.types import Quantile +from openstef_meta.utils.pinball_errors import calculate_pinball_errors + +DEFAULT_TARGET_COLUMN = {Quantile(0.5): "load"} + + +def combine_forecast_input_datasets( + dataset: ForecastInputDataset, other: ForecastInputDataset | None, join: str = "inner" +) -> ForecastInputDataset: + """Combine multiple TimeSeriesDatasets into a single dataset. + + Args: + dataset: First ForecastInputDataset. + other: Second ForecastInputDataset or None. + join: Type of join to perform on the datasets. Defaults to "inner". + + Returns: + Combined ForecastDataset. + """ + if not isinstance(other, ForecastInputDataset): + return dataset + if join != "inner": + raise NotImplementedError("Only 'inner' join is currently supported.") + df_other = other.data + if dataset.target_column in df_other.columns: + df_other = df_other.drop(columns=[dataset.target_column]) + + df_one = dataset.data + df = pd.concat( + [df_one, df_other], + axis=1, + join="inner", + ) + + return ForecastInputDataset( + data=df, + sample_interval=dataset.sample_interval, + target_column=dataset.target_column, + sample_weight_column=dataset.sample_weight_column, + forecast_start=dataset.forecast_start, + ) + + +class EnsembleForecastDataset(TimeSeriesDataset): + """First stage output format for ensemble forecasters.""" + + forecast_start: datetime + quantiles: list[Quantile] + forecaster_names: list[str] + target_column: str + + @override + def __init__( + self, + data: pd.DataFrame, + sample_interval: timedelta = timedelta(minutes=15), + forecast_start: datetime | None = None, + target_column: str = "load", + *, + horizon_column: str = "horizon", + available_at_column: str = "available_at", + ) -> None: + if "forecast_start" in data.attrs: + self.forecast_start = datetime.fromisoformat(data.attrs["forecast_start"]) + else: + self.forecast_start = forecast_start if forecast_start is not None else data.index.min().to_pydatetime() + self.target_column = data.attrs.get("target_column", target_column) + + super().__init__( + data=data, + sample_interval=sample_interval, + horizon_column=horizon_column, + available_at_column=available_at_column, + ) + quantile_feature_names = [col for col in self.feature_names if col != target_column] + + self.forecaster_names, self.quantiles = self.get_learner_and_quantile(pd.Index(quantile_feature_names)) + n_cols = len(self.forecaster_names) * len(self.quantiles) + if len(data.columns) not in {n_cols + 1, n_cols}: + raise ValueError("Data columns do not match the expected number based on base Forecasters and quantiles.") + + @property + def target_series(self) -> pd.Series | None: + """Return the target series if available.""" + if self.target_column in self.data.columns: + return self.data[self.target_column] + return None + + @staticmethod + def get_learner_and_quantile(feature_names: pd.Index) -> tuple[list[str], list[Quantile]]: + """Extract base Forecaster names and quantiles from feature names. + + Args: + feature_names: Index of feature names in the dataset. + + Returns: + Tuple containing a list of base Forecaster names and a list of quantiles. + + Raises: + ValueError: If an invalid base Forecaster name is found in a feature name. + """ + forecasters: set[str] = set() + quantiles: set[Quantile] = set() + + for feature_name in feature_names: + quantile_part = "_".join(feature_name.split("_")[-2:]) + learner_part = feature_name[: -(len(quantile_part) + 1)] + if not Quantile.is_valid_quantile_string(quantile_part): + msg = f"Column has no valid quantile string: {feature_name}" + raise ValueError(msg) + + forecasters.add(learner_part) + quantiles.add(Quantile.parse(quantile_part)) + + return list(forecasters), list(quantiles) + + @staticmethod + def get_quantile_feature_name(feature_name: str) -> tuple[str, Quantile]: + """Generate the feature name for a given base Forecaster and quantile. + + Args: + feature_name: Feature name string in the format "model_Quantile". + + Returns: + Tuple containing the base Forecaster name and Quantile object. + """ + learner_part, quantile_part = feature_name.split("_", maxsplit=1) + return learner_part, Quantile.parse(quantile_part) + + @classmethod + def from_forecast_datasets( + cls, + datasets: dict[str, ForecastDataset], + target_series: pd.Series | None = None, + sample_weights: pd.Series | None = None, + ) -> Self: + """Create an EnsembleForecastDataset from multiple ForecastDatasets. + + Args: + datasets: Dict of ForecastDatasets to combine. + target_series: Optional target series to include in the dataset. + sample_weights: Optional sample weights series to include in the dataset. + + Returns: + EnsembleForecastDataset combining all input datasets. + """ + ds1 = next(iter(datasets.values())) + additional_columns: dict[str, pd.Series] = {} + if isinstance(ds1.target_series, pd.Series): + additional_columns[ds1.target_column] = ds1.target_series + elif target_series is not None: + additional_columns[ds1.target_column] = target_series + + sample_weight_column = "sample_weight" + if sample_weights is not None: + additional_columns[sample_weight_column] = sample_weights + + combined_data = pd.DataFrame({ + f"{learner}_{q.format()}": ds.data[q.format()] for learner, ds in datasets.items() for q in ds.quantiles + }).assign(**additional_columns) + + return cls( + data=combined_data, + sample_interval=ds1.sample_interval, + forecast_start=ds1.forecast_start, + target_column=ds1.target_column, + ) + + @staticmethod + def _prepare_classification(data: pd.DataFrame, target: pd.Series, quantile: Quantile) -> pd.Series: + """Prepare data for classification tasks by converting quantile columns to binary indicators. + + Args: + data: DataFrame containing quantile predictions. + target: Series containing true target values. + quantile: Quantile for which to prepare classification data. + + Returns: + Series with categorical indicators of best-performing base Forecasters. + """ + + # Calculate pinball loss for each base Forecaster + def column_pinball_losses(preds: pd.Series) -> pd.Series: + return calculate_pinball_errors(y_true=target, y_pred=preds, quantile=quantile) + + pinball_losses = data.apply(column_pinball_losses) + + return pinball_losses.idxmin(axis=1) + + def select_quantile_classification(self, quantile: Quantile) -> ForecastInputDataset: + """Select classification target for a specific quantile. + + Args: + quantile: Quantile to select. + + Returns: + Series containing binary indicators of best-performing base Forecasters for the specified quantile. + + Raises: + ValueError: If the target column is not found in the dataset. + """ + if self.target_column not in self.data.columns: + msg = f"Target column '{self.target_column}' not found in dataset." + raise ValueError(msg) + + selected_columns = [f"{learner}_{quantile.format()}" for learner in self.forecaster_names] + prediction_data = self.data[selected_columns].copy() + prediction_data.columns = self.forecaster_names + + target = self._prepare_classification( + data=prediction_data, + target=self.data[self.target_column], + quantile=quantile, + ) + prediction_data[self.target_column] = target + return ForecastInputDataset( + data=prediction_data, + sample_interval=self.sample_interval, + target_column=self.target_column, + forecast_start=self.forecast_start, + ) + + def select_quantile(self, quantile: Quantile) -> ForecastInputDataset: + """Select data for a specific quantile. + + Args: + quantile: Quantile to select. + + Returns: + ForecastInputDataset containing base predictions for the specified quantile. + """ + selected_columns = [f"{learner}_{quantile.format()}" for learner in self.forecaster_names] + selected_columns.append(self.target_column) + prediction_data = self.data[selected_columns].copy() + prediction_data.columns = [*self.forecaster_names, self.target_column] + + return ForecastInputDataset( + data=prediction_data, + sample_interval=self.sample_interval, + target_column=self.target_column, + forecast_start=self.forecast_start, + ) + + def select_forecaster(self, forecaster_name: str) -> ForecastDataset: + """Select data for a specific base Forecaster across all quantiles. + + Args: + forecaster_name: Name of the base Forecaster to select. + + Returns: + ForecastDataset containing predictions from the specified base Forecaster. + """ + selected_columns = [ + f"{forecaster_name}_{q.format()}" for q in self.quantiles if f"{forecaster_name}_{q.format()}" in self.data + ] + prediction_data = self.data[selected_columns].copy() + prediction_data.columns = [q.format() for q in self.quantiles] + + prediction_data[self.target_column] = self.data[self.target_column] + + return ForecastDataset( + data=prediction_data, + sample_interval=self.sample_interval, + forecast_start=self.forecast_start, + target_column=self.target_column, + ) diff --git a/packages/openstef-meta/src/openstef_meta/utils/decision_tree.py b/packages/openstef-meta/src/openstef_meta/utils/decision_tree.py new file mode 100644 index 000000000..8e3940dfa --- /dev/null +++ b/packages/openstef-meta/src/openstef_meta/utils/decision_tree.py @@ -0,0 +1,143 @@ +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# +# SPDX-License-Identifier: MPL-2.0 +"""A simple decision tree implementation for making decisions based on feature rules.""" + +from typing import Literal + +import pandas as pd +from pydantic import BaseModel, Field, model_validator + + +class Node(BaseModel): + """A node in the decision tree, either a rule or a decision.""" + + idx: int = Field( + description="Index of the rule in the decision tree.", + ) + + +class Rule(Node): + """A single rule in the decision tree.""" + + idx: int = Field( + description="Index of the decision in the decision tree.", + ) + + rule_type: Literal["greater_than", "less_than"] = Field( + ..., + description="Type of the rule to apply.", + ) + feature_name: str = Field( + ..., + description="Name of the feature to which the rule applies.", + ) + + threshold: float | int = Field( + ..., + description="Threshold value for the rule.", + ) + + next_true: int = Field( + ..., + description="Index of the next rule if the condition is true.", + ) + + next_false: int = Field( + ..., + description="Index of the next rule if the condition is false.", + ) + + +class Decision(Node): + """A leaf decision in the decision tree.""" + + idx: int = Field( + description="Index of the decision in the decision tree.", + ) + + decision: str = Field( + ..., + description="The prediction value at this leaf.", + ) + + +class DecisionTree(BaseModel): + """A simple decision tree defined by a list of rules.""" + + nodes: list[Node] = Field( + ..., + description="List of rules that define the decision tree.", + ) + + outcomes: set[str] = Field( + ..., + description="Set of possible outcomes from the decision tree.", + ) + + @model_validator(mode="after") + def validate_tree_structure(self) -> "DecisionTree": + """Validate that the tree structure is correct. + + Raises: + ValueError: If tree is not built correctly. + + Returns: + The validated DecisionTree instance. + """ + node_idx = {node.idx for node in self.nodes} + if node_idx != set(range(len(self.nodes))): + raise ValueError("Rule indices must be consecutive starting from 0.") + + for node in self.nodes: + if isinstance(node, Rule): + if node.next_true not in node_idx: + msg = f"next_true index {node.next_true} not found in nodes." + raise ValueError(msg) + if node.next_false not in node_idx: + msg = f"next_false index {node.next_false} not found in nodes." + raise ValueError(msg) + if isinstance(node, Decision) and node.decision not in self.outcomes: + msg = f"Decision '{node.decision}' not in defined outcomes {self.outcomes}." + raise ValueError(msg) + + return self + + def get_decision(self, row: pd.Series) -> str: + """Get decision from the decision tree based on input features. + + Args: + row: Series containing feature values. + + Returns: + The decision outcome as a string. + + Raises: + ValueError: If the tree structure is invalid. + TypeError: If a node type is invalid. + """ + current_idx = 0 + while True: + current_node = self.nodes[current_idx] + if isinstance(current_node, Decision): + return current_node.decision + if isinstance(current_node, Rule): + feature_value = row[current_node.feature_name] + if current_node.rule_type == "greater_than": + if feature_value > current_node.threshold: + current_idx = current_node.next_true + else: + current_idx = current_node.next_false + elif current_node.rule_type == "less_than": + if feature_value < current_node.threshold: + current_idx = current_node.next_true + else: + current_idx = current_node.next_false + else: + msg = f"Invalid rule type '{current_node.rule_type}' at index {current_idx}." + raise ValueError(msg) + else: + msg = f"Invalid node type at index {current_idx}." + raise TypeError(msg) + + __all__ = ["Node", "Rule", "Decision", "DecisionTree"] diff --git a/packages/openstef-meta/src/openstef_meta/utils/pinball_errors.py b/packages/openstef-meta/src/openstef_meta/utils/pinball_errors.py new file mode 100644 index 000000000..08e1c7704 --- /dev/null +++ b/packages/openstef-meta/src/openstef_meta/utils/pinball_errors.py @@ -0,0 +1,32 @@ +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# +# SPDX-License-Identifier: MPL-2.0 + +"""Utility functions for calculating pinball loss errors. + +This module provides a function to compute the pinball loss for quantile regression. +""" + +import numpy as np +import pandas as pd + + +def calculate_pinball_errors(y_true: pd.Series, y_pred: pd.Series, quantile: float) -> pd.Series: + """Calculate pinball loss for given true and predicted values. + + Args: + y_true: True values as a pandas Series. + y_pred: Predicted values as a pandas Series. + quantile: Quantile value. + + Returns: + A pandas Series containing the pinball loss for each sample. + """ + errors = y_true - y_pred + pinball_loss = np.where( + errors >= 0, + quantile * errors, # Under-prediction + (quantile - 1) * errors, # Over-prediction + ) + + return pd.Series(pinball_loss, index=y_true.index) diff --git a/packages/openstef-meta/tests/regression/__init__.py b/packages/openstef-meta/tests/regression/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/packages/openstef-meta/tests/regression/test_ensemble_forecasting_model.py b/packages/openstef-meta/tests/regression/test_ensemble_forecasting_model.py new file mode 100644 index 000000000..23835d6e7 --- /dev/null +++ b/packages/openstef-meta/tests/regression/test_ensemble_forecasting_model.py @@ -0,0 +1,100 @@ +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# +# SPDX-License-Identifier: MPL-2.0 + +from datetime import timedelta +from typing import cast + +import numpy as np +import pandas as pd +import pytest + +from openstef_core.datasets.validated_datasets import TimeSeriesDataset +from openstef_core.types import LeadTime, Q +from openstef_meta.models.ensemble_forecasting_model import EnsembleForecastingModel +from openstef_meta.presets import EnsembleWorkflowConfig, create_ensemble_workflow +from openstef_models.models.forecasting_model import ForecastingModel +from openstef_models.presets import ForecastingWorkflowConfig, create_forecasting_workflow + + +@pytest.fixture +def sample_timeseries_dataset() -> TimeSeriesDataset: + """Create sample time series data with typical energy forecasting features.""" + n_samples = 25 + rng = np.random.default_rng(seed=42) + + data = pd.DataFrame( + { + "load": 100.0 + rng.normal(10.0, 5.0, n_samples), + "temperature": 20.0 + rng.normal(1.0, 0.5, n_samples), + "radiation": rng.uniform(0.0, 500.0, n_samples), + }, + index=pd.date_range("2025-01-01 10:00", periods=n_samples, freq="h", tz="UTC"), + ) + + return TimeSeriesDataset(data, timedelta(hours=1)) + + +@pytest.fixture +def config() -> EnsembleWorkflowConfig: + return EnsembleWorkflowConfig( + model_id="ensemble_model_", + ensemble_type="learned_weights", + base_models=["gblinear", "lgbm"], + combiner_model="lgbm", + quantiles=[Q(0.1), Q(0.5), Q(0.9)], + horizons=[LeadTime.from_string("PT36H")], + forecaster_sample_weight_exponent={"gblinear": 1, "lgbm": 0}, + ) + + +@pytest.fixture +def create_models( + config: EnsembleWorkflowConfig, +) -> tuple[EnsembleForecastingModel, dict[str, ForecastingModel]]: + + ensemble_model = cast(EnsembleForecastingModel, create_ensemble_workflow(config=config).model) + + base_models: dict[str, ForecastingModel] = {} + for forecaster_name in config.base_models: + model_config = ForecastingWorkflowConfig( + model_id=f"{forecaster_name}_model_", + model=forecaster_name, # type: ignore + quantiles=config.quantiles, + horizons=config.horizons, + sample_weight_exponent=config.forecaster_sample_weight_exponent[forecaster_name], + ) + base_model = create_forecasting_workflow(config=model_config).model + base_models[forecaster_name] = cast(ForecastingModel, base_model) + + return ensemble_model, base_models + + +def test_preprocessing( + sample_timeseries_dataset: TimeSeriesDataset, + create_models: tuple[EnsembleForecastingModel, dict[str, ForecastingModel]], +) -> None: + + ensemble_model, base_models = create_models + + ensemble_model.common_preprocessing.fit(data=sample_timeseries_dataset) + + # Check all base models + for name, model in base_models.items(): + # Ensemble model + common_ensemble = ensemble_model.common_preprocessing.transform( + data=sample_timeseries_dataset.copy_with(sample_timeseries_dataset.data) + ) + ensemble_model.model_specific_preprocessing[name].fit(data=common_ensemble) + transformed_ensemble = ensemble_model.model_specific_preprocessing[name].transform(data=common_ensemble) + # Base model + model.preprocessing.fit(data=sample_timeseries_dataset) + transformed_base = model.preprocessing.transform(data=sample_timeseries_dataset) + # Compare + pd.testing.assert_frame_equal( + transformed_ensemble.data, + transformed_base.data, + check_dtype=False, + check_index_type=False, + check_column_type=False, + ) diff --git a/packages/openstef-meta/tests/unit/models/__init__.py b/packages/openstef-meta/tests/unit/models/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/packages/openstef-meta/tests/unit/models/conftest.py b/packages/openstef-meta/tests/unit/models/conftest.py new file mode 100644 index 000000000..968e68d8c --- /dev/null +++ b/packages/openstef-meta/tests/unit/models/conftest.py @@ -0,0 +1,59 @@ +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# +# SPDX-License-Identifier: MPL-2.0 + +from datetime import datetime, timedelta + +import numpy as np +import pandas as pd +import pytest + +from openstef_core.datasets import ForecastInputDataset + + +@pytest.fixture +def sample_forecast_input_dataset() -> ForecastInputDataset: + """Create sample input dataset for forecaster training and prediction.""" + rng = np.random.default_rng(42) + num_samples = 14 + start_date = datetime.fromisoformat("2025-01-01T00:00:00") + + feature_1 = rng.normal(loc=0, scale=1, size=num_samples) + feature_2 = rng.normal(loc=0, scale=1, size=num_samples) + feature_3 = rng.uniform(low=-1, high=1, size=num_samples) + + return ForecastInputDataset( + data=pd.DataFrame( + { + "load": (feature_1 + feature_2 + feature_3) / 3, + "feature1": feature_1, + "feature2": feature_2, + "feature3": feature_3, + }, + index=pd.date_range(start=start_date, periods=num_samples, freq="1d"), + ), + sample_interval=timedelta(days=1), + target_column="load", + forecast_start=start_date + timedelta(days=num_samples // 2), + ) + + +@pytest.fixture +def sample_dataset_with_weights(sample_forecast_input_dataset: ForecastInputDataset) -> ForecastInputDataset: + """Create sample dataset with sample weights by adding weights to the base dataset.""" + rng = np.random.default_rng(42) + num_samples = len(sample_forecast_input_dataset.data) + + # Create varied sample weights (some high, some low) + sample_weights = rng.uniform(low=0.1, high=2.0, size=num_samples) + + # Add sample weights to existing data + data_with_weights = sample_forecast_input_dataset.data.copy() + data_with_weights["sample_weight"] = sample_weights + + return ForecastInputDataset( + data=data_with_weights, + sample_interval=sample_forecast_input_dataset.sample_interval, + target_column=sample_forecast_input_dataset.target_column, + forecast_start=sample_forecast_input_dataset.forecast_start, + ) diff --git a/packages/openstef-meta/tests/unit/models/forecast_combiners/__init__.py b/packages/openstef-meta/tests/unit/models/forecast_combiners/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/packages/openstef-meta/tests/unit/models/forecast_combiners/conftest.py b/packages/openstef-meta/tests/unit/models/forecast_combiners/conftest.py new file mode 100644 index 000000000..cf4edb982 --- /dev/null +++ b/packages/openstef-meta/tests/unit/models/forecast_combiners/conftest.py @@ -0,0 +1,59 @@ +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# +# SPDX-License-Identifier: MPL-2.0 + +from collections.abc import Callable +from datetime import timedelta + +import numpy as np +import pandas as pd +import pytest + +from openstef_core.datasets.validated_datasets import ForecastDataset +from openstef_meta.utils.datasets import EnsembleForecastDataset + + +@pytest.fixture +def forecast_dataset_factory() -> Callable[[], ForecastDataset]: + def _make() -> ForecastDataset: + rng = np.random.default_rng() + coef = rng.normal(0, 1, 3) + + df = pd.DataFrame( + data={ + "quantile_P10": np.array([1, 2, 3]) * coef[0], + "quantile_P50": np.array([1, 2, 3]) * coef[1], + "quantile_P90": np.array([1, 2, 3]) * coef[2], + "load": [100, 200, 300], + }, + index=pd.to_datetime([ + "2023-01-01T10:00:00", + "2023-01-01T11:00:00", + "2023-01-01T12:00:00", + ]), + ) + df += rng.normal(0, 1, df.shape) # Add slight noise to avoid perfect predictions + + df["available_at"] = pd.to_datetime([ + "2023-01-01T09:50:00", + "2023-01-01T10:55:00", + "2023-01-01T12:10:00", + ]) + + return ForecastDataset( + data=df, + sample_interval=timedelta(hours=1), + target_column="load", + ) + + return _make + + +@pytest.fixture +def ensemble_dataset(forecast_dataset_factory: Callable[[], ForecastDataset]) -> EnsembleForecastDataset: + base_learner_output = { + "GBLinearForecaster": forecast_dataset_factory(), + "LGBMForecaster": forecast_dataset_factory(), + } + + return EnsembleForecastDataset.from_forecast_datasets(base_learner_output) diff --git a/packages/openstef-meta/tests/unit/models/forecast_combiners/test_learned_weights_combiner.py b/packages/openstef-meta/tests/unit/models/forecast_combiners/test_learned_weights_combiner.py new file mode 100644 index 000000000..ac7a4c380 --- /dev/null +++ b/packages/openstef-meta/tests/unit/models/forecast_combiners/test_learned_weights_combiner.py @@ -0,0 +1,95 @@ +# # SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# # +# # SPDX-License-Identifier: MPL-2.0 + +from datetime import timedelta + +import pytest + +from openstef_core.exceptions import NotFittedError +from openstef_core.types import LeadTime, Q +from openstef_meta.models.forecast_combiners.learned_weights_combiner import ( + WeightsCombiner, + WeightsCombinerConfig, +) +from openstef_meta.utils.datasets import EnsembleForecastDataset + + +@pytest.fixture(params=["lgbm", "xgboost", "rf", "logistic"]) +def classifier(request: pytest.FixtureRequest) -> str: + """Fixture to provide different classifier types for LearnedWeightsCombiner tests.""" + return request.param + + +@pytest.fixture +def config(classifier: str) -> WeightsCombinerConfig: + """Fixture to create WeightsCombinerConfig based on the classifier type.""" + if classifier == "lgbm": + hp = WeightsCombiner.LGBMHyperParams(n_leaves=5, n_estimators=10) + elif classifier == "xgboost": + hp = WeightsCombiner.XGBHyperParams(n_estimators=10) + elif classifier == "rf": + hp = WeightsCombiner.RFHyperParams(n_estimators=10, n_leaves=5) + elif classifier == "logistic": + hp = WeightsCombiner.LogisticHyperParams() + else: + msg = f"Unsupported classifier type: {classifier}" + raise ValueError(msg) + + return WeightsCombiner.Config( + hyperparams=hp, quantiles=[Q(0.1), Q(0.5), Q(0.9)], horizons=[LeadTime(timedelta(days=1))] + ) + + +@pytest.fixture +def forecaster(config: WeightsCombinerConfig) -> WeightsCombiner: + return WeightsCombiner(config) + + +def test_initialization(forecaster: WeightsCombiner): + assert isinstance(forecaster, WeightsCombiner) + + +def test_quantile_weights_combiner__fit_predict( + ensemble_dataset: EnsembleForecastDataset, + config: WeightsCombinerConfig, +): + """Test basic fit and predict workflow with comprehensive output validation.""" + # Arrange + expected_quantiles = config.quantiles + forecaster = WeightsCombiner(config=config) + + # Act + forecaster.fit(ensemble_dataset) + result = forecaster.predict(ensemble_dataset) + + # Assert + # Basic functionality + assert forecaster.is_fitted, "Model should be fitted after calling fit()" + + # Check that necessary quantiles are present + expected_columns = [q.format() for q in expected_quantiles] + expected_columns.append("load") + assert list(result.data.columns) == expected_columns, ( + f"Expected columns {expected_columns}, got {list(result.data.columns)}" + ) + + # Forecast data quality + assert not result.data.isna().any().any(), "Forecast should not contain NaN or None values" + + # Since forecast is deterministic with fixed random seed, check value spread (vectorized) + # All quantiles should have some variation (not all identical values) + stds = result.data.std() + assert (stds > 0).all(), f"All columns should have variation, got stds: {dict(stds)}" + + +def test_weights_combiner_not_fitted_error( + ensemble_dataset: EnsembleForecastDataset, + config: WeightsCombinerConfig, +): + """Test that NotFittedError is raised when predicting before fitting.""" + # Arrange + forecaster = WeightsCombiner(config=config) + # Act & Assert + with pytest.raises(NotFittedError): + forecaster.predict(ensemble_dataset) diff --git a/packages/openstef-meta/tests/unit/models/forecast_combiners/test_rules_combiner.py b/packages/openstef-meta/tests/unit/models/forecast_combiners/test_rules_combiner.py new file mode 100644 index 000000000..aa08bf59a --- /dev/null +++ b/packages/openstef-meta/tests/unit/models/forecast_combiners/test_rules_combiner.py @@ -0,0 +1,62 @@ +# # SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# # +# # SPDX-License-Identifier: MPL-2.0 + +from datetime import timedelta + +import pytest + +from openstef_core.types import LeadTime, Q +from openstef_meta.models.forecast_combiners.rules_combiner import ( + RulesCombiner, + RulesCombinerConfig, +) +from openstef_meta.utils.datasets import EnsembleForecastDataset + + +@pytest.fixture +def config() -> RulesCombinerConfig: + """Fixture to create RulesCombinerConfig.""" + return RulesCombiner.Config( + quantiles=[Q(0.1), Q(0.5), Q(0.9)], + horizons=[LeadTime(timedelta(days=1))], + ) + + +@pytest.fixture +def forecaster(config: RulesCombinerConfig) -> RulesCombiner: + return RulesCombiner(config=config) + + +def test_initialization(forecaster: RulesCombiner): + assert isinstance(forecaster, RulesCombiner) + + +def test_quantile_weights_combiner__fit_predict( + ensemble_dataset: EnsembleForecastDataset, + config: RulesCombinerConfig, +): + """Test basic fit and predict workflow with comprehensive output validation.""" + # Arrange + expected_quantiles = config.quantiles + forecaster = RulesCombiner(config=config) + additional_features = ensemble_dataset.select_quantile(Q(0.5)) + additional_features.data = additional_features.data.drop(columns=additional_features.target_column) + additional_features.data.columns = ["feature1", "feature2"] + + # Act + forecaster.fit(ensemble_dataset, additional_features=additional_features) + result = forecaster.predict(ensemble_dataset, additional_features=additional_features) + + # Assert + # Basic functionality + assert forecaster.is_fitted, "Model should be fitted after calling fit()" + + # Check that necessary quantiles are present + expected_columns = [q.format() for q in expected_quantiles] + assert list(result.data.columns) == expected_columns, ( + f"Expected columns {expected_columns}, got {list(result.data.columns)}" + ) + + # Forecast data quality + assert not result.data.isna().any().any(), "Forecast should not contain NaN or None values" diff --git a/packages/openstef-meta/tests/unit/models/forecast_combiners/test_stacking_combiner.py b/packages/openstef-meta/tests/unit/models/forecast_combiners/test_stacking_combiner.py new file mode 100644 index 000000000..cb182e242 --- /dev/null +++ b/packages/openstef-meta/tests/unit/models/forecast_combiners/test_stacking_combiner.py @@ -0,0 +1,103 @@ +# # SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# # +# # SPDX-License-Identifier: MPL-2.0 + +from datetime import timedelta + +import pandas as pd +import pytest + +from openstef_core.exceptions import NotFittedError +from openstef_core.types import LeadTime, Q +from openstef_meta.models.forecast_combiners.stacking_combiner import ( + StackingCombiner, + StackingCombinerConfig, +) +from openstef_meta.utils.datasets import EnsembleForecastDataset + + +@pytest.fixture(params=["lgbm", "gblinear"]) +def regressor(request: pytest.FixtureRequest) -> str: + """Fixture to provide different regressor types for Stacking tests.""" + return request.param + + +@pytest.fixture +def config(regressor: str) -> StackingCombinerConfig: + """Fixture to create StackingCombinerConfig based on the regressor type.""" + if regressor == "lgbm": + hp = StackingCombiner.LGBMHyperParams(num_leaves=5, n_estimators=10) + elif regressor == "gblinear": + hp = StackingCombiner.GBLinearHyperParams(n_steps=10) + else: + msg = f"Unsupported regressor type: {regressor}" + raise ValueError(msg) + + return StackingCombiner.Config( + hyperparams=hp, quantiles=[Q(0.1), Q(0.5), Q(0.9)], horizons=[LeadTime(timedelta(days=1))] + ) + + +@pytest.fixture +def forecaster(config: StackingCombinerConfig) -> StackingCombiner: + return StackingCombiner(config) + + +def test_initialization(forecaster: StackingCombiner): + assert isinstance(forecaster, StackingCombiner) + + +def test_quantile_weights_combiner__fit_predict( + ensemble_dataset: EnsembleForecastDataset, + config: StackingCombinerConfig, +): + """Test basic fit and predict workflow with comprehensive output validation.""" + # Arrange + expected_quantiles = config.quantiles + forecaster = StackingCombiner(config=config) + + # Act + forecaster.fit(ensemble_dataset) + result = forecaster.predict(ensemble_dataset) + + # Assert + # Basic functionality + assert forecaster.is_fitted, "Model should be fitted after calling fit()" + + # Check that necessary quantiles are present + expected_columns = [q.format() for q in expected_quantiles] + assert list(result.data.columns) == expected_columns, ( + f"Expected columns {expected_columns}, got {list(result.data.columns)}" + ) + + # Forecast data quality + assert not result.data.isna().any().any(), "Forecast should not contain NaN or None values" + + +def test_stacking_combiner_not_fitted_error( + ensemble_dataset: EnsembleForecastDataset, + config: StackingCombinerConfig, +): + """Test that NotFittedError is raised when predicting before fitting.""" + # Arrange + forecaster = StackingCombiner(config=config) + # Act & Assert + with pytest.raises(NotFittedError): + forecaster.predict(ensemble_dataset) + + +def test_stacking_combiner_predict_contributions( + ensemble_dataset: EnsembleForecastDataset, + config: StackingCombinerConfig, +): + """Test that predict_contributions method returns contributions with correct shape.""" + # Arrange + forecaster = StackingCombiner(config=config) + forecaster.fit(ensemble_dataset) + + # Act + contributions = forecaster.predict_contributions(ensemble_dataset) + + # Assert + assert isinstance(contributions, pd.DataFrame), "Contributions should be returned as a DataFrame." + assert len(contributions.columns) == (len(ensemble_dataset.quantiles) * len(ensemble_dataset.forecaster_names)) + 1 diff --git a/packages/openstef-meta/tests/unit/models/forecasting/__init__.py b/packages/openstef-meta/tests/unit/models/forecasting/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/packages/openstef-meta/tests/unit/models/forecasting/test_residual_forecaster.py b/packages/openstef-meta/tests/unit/models/forecasting/test_residual_forecaster.py new file mode 100644 index 000000000..0f319552e --- /dev/null +++ b/packages/openstef-meta/tests/unit/models/forecasting/test_residual_forecaster.py @@ -0,0 +1,173 @@ +# # SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# # +# # SPDX-License-Identifier: MPL-2.0 + +from datetime import timedelta + +import pytest + +from openstef_core.datasets import ForecastInputDataset +from openstef_core.exceptions import NotFittedError +from openstef_core.types import LeadTime, Q +from openstef_meta.models.forecasting.residual_forecaster import ( + ResidualBaseForecasterHyperParams, + ResidualForecaster, + ResidualForecasterConfig, + ResidualHyperParams, +) +from openstef_models.models.forecasting.gblinear_forecaster import GBLinearHyperParams +from openstef_models.models.forecasting.lgbm_forecaster import LGBMHyperParams +from openstef_models.models.forecasting.lgbmlinear_forecaster import LGBMLinearHyperParams +from openstef_models.models.forecasting.xgboost_forecaster import XGBoostHyperParams + + +@pytest.fixture(params=["gblinear", "lgbmlinear"]) +def primary_model(request: pytest.FixtureRequest) -> ResidualBaseForecasterHyperParams: + """Fixture to provide different primary models types.""" + learner_type = request.param + if learner_type == "gblinear": + return GBLinearHyperParams() + if learner_type == "lgbm": + return LGBMHyperParams() + if learner_type == "lgbmlinear": + return LGBMLinearHyperParams() + return XGBoostHyperParams() + + +@pytest.fixture(params=["gblinear", "lgbm", "lgbmlinear", "xgboost"]) +def secondary_model(request: pytest.FixtureRequest) -> ResidualBaseForecasterHyperParams: + """Fixture to provide different secondary models types.""" + learner_type = request.param + if learner_type == "gblinear": + return GBLinearHyperParams() + if learner_type == "lgbm": + return LGBMHyperParams() + if learner_type == "lgbmlinear": + return LGBMLinearHyperParams() + return XGBoostHyperParams() + + +@pytest.fixture +def base_config( + primary_model: ResidualBaseForecasterHyperParams, + secondary_model: ResidualBaseForecasterHyperParams, +) -> ResidualForecasterConfig: + """Base configuration for Residual forecaster tests.""" + + params = ResidualHyperParams( + primary_hyperparams=primary_model, + secondary_hyperparams=secondary_model, + ) + return ResidualForecasterConfig( + quantiles=[Q(0.1), Q(0.5), Q(0.9)], + horizons=[LeadTime(timedelta(days=1))], + hyperparams=params, + verbosity=False, + ) + + +def test_residual_forecaster_fit_predict( + sample_forecast_input_dataset: ForecastInputDataset, + base_config: ResidualForecasterConfig, +): + """Test basic fit and predict workflow with comprehensive output validation.""" + # Arrange + expected_quantiles = base_config.quantiles + forecaster = ResidualForecaster(config=base_config) + + # Act + forecaster.fit(sample_forecast_input_dataset) + result = forecaster.predict(sample_forecast_input_dataset) + + # Assert + # Basic functionality + assert forecaster.is_fitted, "Model should be fitted after calling fit()" + + # Check that necessary quantiles are present + expected_columns = [q.format() for q in expected_quantiles] + assert list(result.data.columns) == expected_columns, ( + f"Expected columns {expected_columns}, got {list(result.data.columns)}" + ) + + # Forecast data quality + assert not result.data.isna().any().any(), "Forecast should not contain NaN or None values" + + +def test_residual_forecaster_predict_not_fitted_raises_error( + sample_forecast_input_dataset: ForecastInputDataset, + base_config: ResidualForecasterConfig, +): + """Test that predict() raises NotFittedError when called before fit().""" + # Arrange + forecaster = ResidualForecaster(config=base_config) + + # Act & Assert + with pytest.raises(NotFittedError, match="ResidualForecaster"): + forecaster.predict(sample_forecast_input_dataset) + + +def test_residual_forecaster_with_sample_weights( + sample_dataset_with_weights: ForecastInputDataset, + base_config: ResidualForecasterConfig, +): + """Test that forecaster works with sample weights and produces different results.""" + # Arrange + forecaster_with_weights = ResidualForecaster(config=base_config) + + # Create dataset without weights for comparison + data_without_weights = ForecastInputDataset( + data=sample_dataset_with_weights.data.drop(columns=["sample_weight"]), + sample_interval=sample_dataset_with_weights.sample_interval, + target_column=sample_dataset_with_weights.target_column, + forecast_start=sample_dataset_with_weights.forecast_start, + ) + forecaster_without_weights = ResidualForecaster(config=base_config) + + # Act + forecaster_with_weights.fit(sample_dataset_with_weights) + forecaster_without_weights.fit(data_without_weights) + + # Predict using data without sample_weight column (since that's used for training, not prediction) + result_with_weights = forecaster_with_weights.predict(data_without_weights) + result_without_weights = forecaster_without_weights.predict(data_without_weights) + + # Assert + # Both should produce valid forecasts + assert not result_with_weights.data.isna().any().any(), "Weighted forecast should not contain NaN values" + assert not result_without_weights.data.isna().any().any(), "Unweighted forecast should not contain NaN values" + + # Sample weights should affect the model, so results should be different + # (This is a statistical test - with different weights, predictions should differ) + differences = (result_with_weights.data - result_without_weights.data).abs() + assert differences.sum().sum() > 0, "Sample weights should affect model predictions" + + +def test_residual_forecaster_predict_contributions( + sample_forecast_input_dataset: ForecastInputDataset, + base_config: ResidualForecasterConfig, +): + """Test basic fit and predict workflow with output validation.""" + # Arrange + expected_quantiles = base_config.quantiles + forecaster = ResidualForecaster(config=base_config) + + # Act + forecaster.fit(sample_forecast_input_dataset) + result = forecaster.predict_contributions(sample_forecast_input_dataset, scale=True) + + # Assert + # Basic functionality + assert forecaster.is_fitted, "Model should be fitted after calling fit()" + + # Check that necessary quantiles are present + base_models = [forecaster.primary_name, forecaster.secondary_name] + expected_columns = [f"{col}_{q.format()}" for col in base_models for q in expected_quantiles] + assert sorted(result.columns) == sorted(expected_columns), ( + f"Expected columns {expected_columns}, got {list(result.columns)}" + ) + + # Contributions should sum to 1.0 per quantile + for q in expected_quantiles: + quantile_cols = [col for col in result.columns if col.endswith(f"_{q.format()}")] + col_sums = result[quantile_cols].sum(axis=1) + assert all(abs(col_sums - 1.0) < 1e-6), f"Contributions for quantile {q.format()} should sum to 1.0" diff --git a/packages/openstef-meta/tests/unit/models/test_ensemble_forecasting_model.py b/packages/openstef-meta/tests/unit/models/test_ensemble_forecasting_model.py new file mode 100644 index 000000000..84f14cef7 --- /dev/null +++ b/packages/openstef-meta/tests/unit/models/test_ensemble_forecasting_model.py @@ -0,0 +1,279 @@ +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# +# SPDX-License-Identifier: MPL-2.0 + +import pickle # noqa: S403 - Controlled test +from datetime import datetime, timedelta +from typing import override + +import numpy as np +import pandas as pd +import pytest + +from openstef_core.datasets import ForecastInputDataset +from openstef_core.datasets.timeseries_dataset import TimeSeriesDataset +from openstef_core.datasets.validated_datasets import ForecastDataset +from openstef_core.exceptions import NotFittedError +from openstef_core.mixins.predictor import HyperParams +from openstef_core.mixins.transform import TransformPipeline +from openstef_core.testing import assert_timeseries_equal, create_synthetic_forecasting_dataset +from openstef_core.types import LeadTime, Q +from openstef_meta.models.ensemble_forecasting_model import EnsembleForecastingModel +from openstef_meta.models.forecast_combiners.forecast_combiner import ForecastCombiner, ForecastCombinerConfig +from openstef_meta.utils.datasets import EnsembleForecastDataset +from openstef_models.models.forecasting import Forecaster, ForecasterConfig +from openstef_models.transforms.postprocessing.quantile_sorter import QuantileSorter +from openstef_models.transforms.time_domain.lags_adder import LagsAdder + + +class SimpleForecaster(Forecaster): + """Simple test forecaster that returns predictable values for testing.""" + + def __init__(self, config: ForecasterConfig): + self._config = config + self._is_fitted = False + + @property + def config(self) -> ForecasterConfig: + return self._config + + @property + @override + def is_fitted(self) -> bool: + return self._is_fitted + + @override + def fit(self, data: ForecastInputDataset, data_val: ForecastInputDataset | None = None) -> None: + self._is_fitted = True + + @override + def predict(self, data: ForecastInputDataset) -> ForecastDataset: + # Return predictable forecast values + forecast_values = {quantile: 100.0 + quantile * 10 for quantile in self.config.quantiles} + return ForecastDataset( + pd.DataFrame( + { + quantile.format(): [forecast_values[quantile]] * len(data.index) + for quantile in self.config.quantiles + }, + index=data.index, + ), + data.sample_interval, + data.forecast_start, + ) + + +class SimpleCombiner(ForecastCombiner): + """Simple combiner that averages base Forecaster predictions.""" + + def __init__(self, config: ForecastCombinerConfig): + self._config = config + self._is_fitted = False + self.quantiles = config.quantiles + + def fit( + self, + data: EnsembleForecastDataset, + data_val: EnsembleForecastDataset | None = None, + additional_features: ForecastInputDataset | None = None, + ) -> None: + self._is_fitted = True + + def predict( + self, + data: EnsembleForecastDataset, + additional_features: ForecastInputDataset | None = None, + ) -> ForecastDataset: + if not self._is_fitted: + raise NotFittedError("Combiner must be fitted before prediction.") + + combined_data = pd.DataFrame(index=data.data.index) + for quantile in self.quantiles: + quantile_cols = [col for col in data.data.columns if col.endswith(quantile.format())] + combined_data[quantile.format()] = data.data[quantile_cols].mean(axis=1) + + return ForecastDataset( + data=combined_data, + sample_interval=data.sample_interval, + forecast_start=data.forecast_start, + ) + + @property + def is_fitted(self) -> bool: + return self._is_fitted + + +@pytest.fixture +def sample_timeseries_dataset() -> TimeSeriesDataset: + """Create sample time series data with typical energy forecasting features.""" + n_samples = 25 + rng = np.random.default_rng(seed=42) + + data = pd.DataFrame( + { + "load": 100.0 + rng.normal(10.0, 5.0, n_samples), + "temperature": 20.0 + rng.normal(1.0, 0.5, n_samples), + "radiation": rng.uniform(0.0, 500.0, n_samples), + }, + index=pd.date_range("2025-01-01 10:00", periods=n_samples, freq="h"), + ) + + return TimeSeriesDataset(data, timedelta(hours=1)) + + +@pytest.fixture +def model() -> EnsembleForecastingModel: + """Create a simple EnsembleForecastingModel for testing.""" + # Arrange + horizons = [LeadTime(timedelta(hours=1))] + quantiles = [Q(0.3), Q(0.5), Q(0.7)] + config = ForecasterConfig(quantiles=quantiles, horizons=horizons) + forecasters: dict[str, Forecaster] = { + "forecaster_1": SimpleForecaster(config=config), + "forecaster_2": SimpleForecaster(config=config), + } + combiner_config = ForecastCombinerConfig(quantiles=quantiles, horizons=horizons, hyperparams=HyperParams()) + + combiner = SimpleCombiner( + config=combiner_config, + ) + + # Act + return EnsembleForecastingModel( + forecasters=forecasters, combiner=combiner, common_preprocessing=TransformPipeline() + ) + + +def test_forecasting_model__init__uses_defaults(model: EnsembleForecastingModel): + """Test initialization uses default preprocessing and postprocessing when not provided.""" + + # Assert - Check that components are assigned correctly + assert model.common_preprocessing is not None + assert model.postprocessing is not None + assert model.target_column == "load" # Default value + assert model.forecaster_names == ["forecaster_1", "forecaster_2"] + + +def test_forecasting_model__fit(sample_timeseries_dataset: TimeSeriesDataset, model: EnsembleForecastingModel): + """Test that fit correctly orchestrates preprocessing and forecaster calls, and returns metrics.""" + + # Act + result = model.fit(data=sample_timeseries_dataset) + + # Assert - Model is fitted and returns metrics + assert model.is_fitted + assert result is not None + + +def test_forecasting_model__predict(sample_timeseries_dataset: TimeSeriesDataset, model: EnsembleForecastingModel): + """Test that predict correctly orchestrates preprocessing and forecaster calls.""" + + # Fit the model first + model.fit(data=sample_timeseries_dataset) + forecast_start = datetime.fromisoformat("2025-01-01T12:00:00") + + # Act + result = model.predict(data=sample_timeseries_dataset, forecast_start=forecast_start) + + # Assert - Prediction returns a forecast dataset with expected properties + assert isinstance(result, ForecastDataset) + assert result.sample_interval == sample_timeseries_dataset.sample_interval + assert result.quantiles == [Q(0.3), Q(0.5), Q(0.7)] + assert result.forecast_start >= forecast_start + assert not result.data.empty + assert not result.data.isna().any().any() + + +def test_forecasting_model__predict__raises_error_when_not_fitted( + sample_timeseries_dataset: TimeSeriesDataset, model: EnsembleForecastingModel +): + """Test predict raises NotFittedError when model is not fitted.""" + + # Act & Assert + with pytest.raises(NotFittedError): + model.predict(data=sample_timeseries_dataset) + + +def test_forecasting_model__score__returns_metrics( + sample_timeseries_dataset: TimeSeriesDataset, model: EnsembleForecastingModel +): + """Test that score evaluates model and returns metrics.""" + + model.fit(data=sample_timeseries_dataset) + + # Act + metrics = model.score(data=sample_timeseries_dataset) + + # Assert - Metrics are calculated for the median quantile + assert metrics.metrics is not None + assert all(x in metrics.metrics for x in [Q(0.3), Q(0.5), Q(0.7)]) + # R2 metric should be present (default evaluation metric) + assert "R2" in metrics.metrics[Q(0.5)] + + +def test_forecasting_model__pickle_roundtrip(): + """Test that ForecastingModel with preprocessing and postprocessing can be pickled and unpickled. + + This verifies that the entire forecasting pipeline, including transforms and forecaster, + can be serialized and deserialized while maintaining functionality. + """ + # Arrange - create synthetic dataset + dataset = create_synthetic_forecasting_dataset( + length=timedelta(days=30), + sample_interval=timedelta(hours=1), + random_seed=42, + ) + + # Create forecasting model with preprocessing and postprocessing + # Arrange + horizons = [LeadTime(timedelta(hours=1))] + quantiles = [Q(0.3), Q(0.5), Q(0.7)] + config = ForecasterConfig(quantiles=quantiles, horizons=horizons) + forecasters: dict[str, Forecaster] = { + "forecaster_1": SimpleForecaster(config=config), + "forecaster_2": SimpleForecaster(config=config), + } + combiner_config = ForecastCombinerConfig(quantiles=quantiles, horizons=horizons, hyperparams=HyperParams()) + + combiner = SimpleCombiner( + config=combiner_config, + ) + + original_model = EnsembleForecastingModel( + forecasters=forecasters, + combiner=combiner, + common_preprocessing=TransformPipeline( + transforms=[ + LagsAdder( + history_available=timedelta(days=14), + horizons=horizons, + max_day_lags=7, + add_trivial_lags=True, + add_autocorr_lags=False, + ), + ] + ), + postprocessing=TransformPipeline(transforms=[QuantileSorter()]), + cutoff_history=timedelta(days=7), + target_column="load", + ) + + # Fit the original model + original_model.fit(data=dataset) + + # Get predictions from original model + expected_predictions = original_model.predict(data=dataset) + + # Act - pickle and unpickle the model + pickled = pickle.dumps(original_model) + restored_model = pickle.loads(pickled) # noqa: S301 - Controlled test + + # Assert - verify the restored model is the correct type + assert isinstance(restored_model, EnsembleForecastingModel) + assert restored_model.is_fitted + assert restored_model.target_column == original_model.target_column + assert restored_model.cutoff_history == original_model.cutoff_history + + # Verify predictions match using pandas testing utilities + actual_predictions = restored_model.predict(data=dataset) + assert_timeseries_equal(actual_predictions, expected_predictions) diff --git a/packages/openstef-meta/tests/unit/utils/__init__.py b/packages/openstef-meta/tests/unit/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/packages/openstef-meta/tests/unit/utils/test_datasets.py b/packages/openstef-meta/tests/unit/utils/test_datasets.py new file mode 100644 index 000000000..efb64f3ea --- /dev/null +++ b/packages/openstef-meta/tests/unit/utils/test_datasets.py @@ -0,0 +1,117 @@ +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# +# SPDX-License-Identifier: MPL-2.0 + +from collections.abc import Callable +from datetime import timedelta + +import numpy as np +import pandas as pd +import pytest + +from openstef_core.datasets.validated_datasets import ForecastDataset, ForecastInputDataset, TimeSeriesDataset +from openstef_core.types import Quantile +from openstef_meta.utils.datasets import EnsembleForecastDataset + + +@pytest.fixture +def simple_dataset() -> TimeSeriesDataset: + return TimeSeriesDataset( + data=pd.DataFrame( + data={ + "available_at": pd.to_datetime([ + "2023-01-01T09:50:00", # lead time = 10:00 - 09:50 = +10min + "2023-01-01T10:55:00", # lead time = 11:00 - 10:55 = +5min + "2023-01-01T12:10:00", # lead time = 12:00 - 12:10 = -10min + "2023-01-01T13:20:00", # lead time = 13:00 - 13:20 = -20min + "2023-01-01T14:15:00", # lead time = 14:00 - 14:15 = -15min + "2023-01-01T14:30:00", # lead time = 14:00 - 14:30 = -30min + ]), + "value1": [10, 20, 30, 40, 50, 55], # 55 should override 50 for 14:00 + }, + index=pd.to_datetime([ + "2023-01-01T10:00:00", + "2023-01-01T11:00:00", + "2023-01-01T12:00:00", + "2023-01-01T13:00:00", + # Duplicate timestamp with different availability + "2023-01-01T14:00:00", + "2023-01-01T14:00:00", + ]), + ), + sample_interval=timedelta(hours=1), + ) + + +@pytest.fixture +def forecast_dataset_factory() -> Callable[[], ForecastDataset]: + def _make() -> ForecastDataset: + rng = np.random.default_rng() + df = pd.DataFrame( + data={ + "quantile_P10": [90, 180, 270], + "quantile_P50": [100, 200, 300], + "quantile_P90": [110, 220, 330], + "load": [100, 200, 300], + }, + index=pd.to_datetime([ + "2023-01-01T10:00:00", + "2023-01-01T11:00:00", + "2023-01-01T12:00:00", + ]), + ) + df += rng.normal(0, 1, df.shape) # Add slight noise to avoid perfect predictions + + df["available_at"] = pd.to_datetime([ + "2023-01-01T09:50:00", + "2023-01-01T10:55:00", + "2023-01-01T12:10:00", + ]) + + return ForecastDataset( + data=df, + sample_interval=timedelta(hours=1), + target_column="load", + ) + + return _make + + +@pytest.fixture +def base_predictions( + forecast_dataset_factory: Callable[[], ForecastDataset], +) -> dict[str, ForecastDataset]: + return { + "model_1": forecast_dataset_factory(), + "model_2": forecast_dataset_factory(), + } + + +@pytest.fixture +def ensemble_dataset(base_predictions: dict[str, ForecastDataset]) -> EnsembleForecastDataset: + return EnsembleForecastDataset.from_forecast_datasets(base_predictions) + + +def test_from_ensemble_output(ensemble_dataset: EnsembleForecastDataset): + + assert isinstance(ensemble_dataset, EnsembleForecastDataset) + assert ensemble_dataset.data.shape == (3, 7) # 3 timestamps, 2 learners * 3 quantiles + target + assert set(ensemble_dataset.forecaster_names) == {"model_1", "model_2"} + assert set(ensemble_dataset.quantiles) == {Quantile(0.1), Quantile(0.5), Quantile(0.9)} + + +def test_select_quantile(ensemble_dataset: EnsembleForecastDataset): + + dataset = ensemble_dataset.select_quantile(Quantile(0.5)) + + assert isinstance(dataset, ForecastInputDataset) + assert dataset.data.shape == (3, 3) # 3 timestamps, 2 learners * 1 quantiles + target + + +def test_select_quantile_classification(ensemble_dataset: EnsembleForecastDataset): + + dataset = ensemble_dataset.select_quantile_classification(Quantile(0.5)) + + assert isinstance(dataset, ForecastInputDataset) + assert dataset.data.shape == (3, 3) # 3 timestamps, 2 learners * 1 quantiles + target + assert all(dataset.target_series.apply(lambda x: x in {"model_1", "model_2"})) # type: ignore diff --git a/packages/openstef-meta/tests/unit/utils/test_decision_tree.py b/packages/openstef-meta/tests/unit/utils/test_decision_tree.py new file mode 100644 index 000000000..f40bdb220 --- /dev/null +++ b/packages/openstef-meta/tests/unit/utils/test_decision_tree.py @@ -0,0 +1,45 @@ +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# +# SPDX-License-Identifier: MPL-2.0 + +import pandas as pd +import pytest + +from openstef_meta.utils.decision_tree import Decision, DecisionTree, Node, Rule + + +@pytest.fixture +def sample_dataset() -> pd.DataFrame: + data = { + "feature_1": [1, 2, 3, 4, 5], + "feature_2": [10, 20, 30, 40, 50], + } + return pd.DataFrame(data) + + +@pytest.fixture +def simple_decision_tree() -> DecisionTree: + nodes: list[Node] = [ + Rule( + idx=0, + rule_type="less_than", + feature_name="feature_1", + threshold=3, + next_true=1, + next_false=2, + ), + Decision(idx=1, decision="Class_A"), + Decision(idx=2, decision="Class_B"), + ] + return DecisionTree(nodes=nodes, outcomes={"Class_A", "Class_B"}) + + +def test_decision_tree_prediction(sample_dataset: pd.DataFrame, simple_decision_tree: DecisionTree): + + decisions = sample_dataset.apply(simple_decision_tree.get_decision, axis=1) + + expected_decisions = pd.Series( + ["Class_A", "Class_A", "Class_B", "Class_B", "Class_B"], + ) + + pd.testing.assert_series_equal(decisions, expected_decisions) diff --git a/packages/openstef-models/README.md b/packages/openstef-models/README.md index 59f9ae249..8f42bd62f 100644 --- a/packages/openstef-models/README.md +++ b/packages/openstef-models/README.md @@ -1,7 +1,7 @@ -# openstef-model \ No newline at end of file +# openstef-model diff --git a/packages/openstef-models/pyproject.toml b/packages/openstef-models/pyproject.toml index e12214954..1b2f9d54b 100644 --- a/packages/openstef-models/pyproject.toml +++ b/packages/openstef-models/pyproject.toml @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 @@ -15,7 +15,7 @@ readme = "README.md" keywords = [ "energy", "forecasting", "machinelearning" ] license = "MPL-2.0" authors = [ - { name = "Alliander N.V", email = "short.term.energy.forecasts@alliander.com" }, + { name = "Alliander N.V", email = "openstef@lfenergy.org" }, ] requires-python = ">=3.12,<4.0" classifiers = [ @@ -29,13 +29,15 @@ classifiers = [ dependencies = [ "holidays>=0.79", + "lightgbm>=4.6", "mlflow-skinny>=3,<4", "openstef-beam>=4.0.0.dev0,<5", "openstef-core>=4.0.0.dev0,<5", "pvlib>=0.13", "pycountry>=24.6.1", - "scikit-learn>=1.7.1,<2", + "scikit-learn>=1.7.1,<1.8", "scipy>=1.16.3,<2", + "skops>=0.13", ] optional-dependencies.xgb-cpu = [ diff --git a/packages/openstef-models/src/openstef_models/__init__.py b/packages/openstef-models/src/openstef_models/__init__.py index e659c6c12..b4e55ee27 100644 --- a/packages/openstef-models/src/openstef_models/__init__.py +++ b/packages/openstef-models/src/openstef_models/__init__.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2017-2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2017-2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 """Core models for OpenSTEF.""" diff --git a/packages/openstef-models/src/openstef_models/explainability/__init__.py b/packages/openstef-models/src/openstef_models/explainability/__init__.py index 6f0cf7494..a444c4f38 100644 --- a/packages/openstef-models/src/openstef_models/explainability/__init__.py +++ b/packages/openstef-models/src/openstef_models/explainability/__init__.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/src/openstef_models/explainability/mixins.py b/packages/openstef-models/src/openstef_models/explainability/mixins.py index 9969b4993..2e1fa81ca 100644 --- a/packages/openstef-models/src/openstef_models/explainability/mixins.py +++ b/packages/openstef-models/src/openstef_models/explainability/mixins.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 @@ -13,6 +13,7 @@ import pandas as pd import plotly.graph_objects as go +from openstef_core.datasets.validated_datasets import ForecastInputDataset from openstef_core.types import Q, Quantile from openstef_models.explainability.plotters.feature_importance_plotter import FeatureImportancePlotter @@ -44,6 +45,19 @@ def feature_importances(self) -> pd.DataFrame: """ raise NotImplementedError + @abstractmethod + def predict_contributions(self, data: ForecastInputDataset, *, scale: bool) -> pd.DataFrame: + """Get feature contributions for each prediction. + + Args: + data: Input dataset for which to compute feature contributions. + scale: Whether to scale contributions to sum to the prediction value. + + Returns: + DataFrame with contributions per feature. + """ + raise NotImplementedError + def plot_feature_importances(self, quantile: Quantile = Q(0.5)) -> go.Figure: """Create interactive treemap visualization of feature importances. diff --git a/packages/openstef-models/src/openstef_models/explainability/plotters/__init__.py b/packages/openstef-models/src/openstef_models/explainability/plotters/__init__.py index e38073f6b..b3d603eec 100644 --- a/packages/openstef-models/src/openstef_models/explainability/plotters/__init__.py +++ b/packages/openstef-models/src/openstef_models/explainability/plotters/__init__.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/src/openstef_models/explainability/plotters/feature_importance_plotter.py b/packages/openstef-models/src/openstef_models/explainability/plotters/feature_importance_plotter.py index a2a9ca8d0..98dc15de6 100644 --- a/packages/openstef-models/src/openstef_models/explainability/plotters/feature_importance_plotter.py +++ b/packages/openstef-models/src/openstef_models/explainability/plotters/feature_importance_plotter.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/src/openstef_models/integrations/__init__.py b/packages/openstef-models/src/openstef_models/integrations/__init__.py index 7cb6b280f..261a35cca 100644 --- a/packages/openstef-models/src/openstef_models/integrations/__init__.py +++ b/packages/openstef-models/src/openstef_models/integrations/__init__.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/src/openstef_models/integrations/joblib/__init__.py b/packages/openstef-models/src/openstef_models/integrations/joblib/__init__.py index f7e538ab2..10fddd524 100644 --- a/packages/openstef-models/src/openstef_models/integrations/joblib/__init__.py +++ b/packages/openstef-models/src/openstef_models/integrations/joblib/__init__.py @@ -6,7 +6,7 @@ single-machine deployments. """ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/src/openstef_models/integrations/joblib/joblib_model_serializer.py b/packages/openstef-models/src/openstef_models/integrations/joblib/joblib_model_serializer.py index 100641ebe..01eabc5ce 100644 --- a/packages/openstef-models/src/openstef_models/integrations/joblib/joblib_model_serializer.py +++ b/packages/openstef-models/src/openstef_models/integrations/joblib/joblib_model_serializer.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 """Local model storage implementation using joblib serialization. diff --git a/packages/openstef-models/src/openstef_models/integrations/mlflow/__init__.py b/packages/openstef-models/src/openstef_models/integrations/mlflow/__init__.py index 78da566cb..a64f59c51 100644 --- a/packages/openstef-models/src/openstef_models/integrations/mlflow/__init__.py +++ b/packages/openstef-models/src/openstef_models/integrations/mlflow/__init__.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/src/openstef_models/integrations/mlflow/mlflow_storage.py b/packages/openstef-models/src/openstef_models/integrations/mlflow/mlflow_storage.py index 8fe559c03..3d17004d8 100644 --- a/packages/openstef-models/src/openstef_models/integrations/mlflow/mlflow_storage.py +++ b/packages/openstef-models/src/openstef_models/integrations/mlflow/mlflow_storage.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 @@ -19,9 +19,11 @@ from mlflow import MlflowClient from mlflow.entities import Metric, Param, Run +from mlflow.exceptions import MlflowException from pydantic import Field, PrivateAttr from openstef_core.base_model import BaseConfig +from openstef_core.exceptions import ModelNotFoundError from openstef_core.mixins import HyperParams from openstef_models.integrations.joblib import JoblibModelSerializer from openstef_models.mixins import ModelIdentifier, ModelSerializer @@ -35,12 +37,17 @@ class MLFlowStorage(BaseConfig): before uploading to MLflow tracking server. """ - tracking_uri: str = Field(default="./mlflow") - local_artifacts_path: Path = Field(default=Path("./mlflow_artifacts_local")) - experiment_name_prefix: str = Field(default="") + tracking_uri: str = Field(default="./mlflow", description="MLflow tracking server URI.") + local_artifacts_path: Path = Field( + default=Path("./mlflow_artifacts_local"), description="Local path for storing MLflow artifacts before upload." + ) + experiment_name_prefix: str = Field(default="", description="Prefix for MLflow experiment names.") # Artifact subdirectories - data_path: str = Field(default="data") - model_path: str = Field(default="model") + data_path: str = Field(default="data", description="Subdirectory for storing training data artifacts.") + model_path: str = Field(default="model", description="Subdirectory for storing model artifacts.") + enable_mlflow_stdout: bool = Field( + default=False, description="Keep MLflow stdout messages which circumvent standard logging." + ) model_serializer: ModelSerializer = Field(default_factory=JoblibModelSerializer) @@ -49,7 +56,10 @@ class MLFlowStorage(BaseConfig): @override def model_post_init(self, context: Any) -> None: - os.environ.setdefault("MLFLOW_ENABLE_ARTIFACTS_PROGRESS_BAR", "false") + if not self.enable_mlflow_stdout: + # Suppress MLflow's stdout messages (emoji URLs) + os.environ.setdefault("MLFLOW_SUPPRESS_PRINTING_URL_TO_STDOUT", "true") + os.environ.setdefault("MLFLOW_ENABLE_ARTIFACTS_PROGRESS_BAR", "false") self._client = MlflowClient(tracking_uri=self.tracking_uri) def create_run( @@ -173,6 +183,40 @@ def search_latest_runs( max_results=limit, ) + def search_run( + self, + model_id: ModelIdentifier, + run_name: str, + ) -> Run | None: + """Search for a specific run of a model by its name in MLflow. + + Queries MLflow for a run matching the provided run name. + Returns None if no experiment or run exists for the model. + + Args: + model_id: Model identifier to search runs for. + run_name: Name of the run to search for. + + Returns: + The matching Run object if found, otherwise None. + """ + # Get related experiment + experiment = self._client.get_experiment_by_name(name=f"{self.experiment_name_prefix}{model_id}") + if experiment is None: + return None + + # Search for the run by name + runs = self._client.search_runs( + experiment_ids=[experiment.experiment_id], + filter_string=f"attribute.run_name = '{run_name}'", + order_by=["start_time DESC"], + max_results=1, + ) + + if runs: + return runs[0] + return None + def save_run_model(self, model_id: ModelIdentifier, run_id: str, model: object) -> None: """Save a trained model to local artifacts directory for the run. @@ -193,7 +237,7 @@ def save_run_model(self, model_id: ModelIdentifier, run_id: str, model: object) with Path(model_path / f"model.{self.model_serializer.extension}").open("wb") as f: self.model_serializer.serialize(model, file=f) - def load_run_model(self, run_id: str) -> object: + def load_run_model(self, run_id: str, model_id: ModelIdentifier) -> object: """Load a trained model from MLflow artifacts. Downloads model artifacts from MLflow and deserializes them into the @@ -201,15 +245,21 @@ def load_run_model(self, run_id: str) -> object: Args: run_id: MLflow run ID containing the model artifacts. + model_id: Model identifier for locating artifact paths. Returns: Model instance with restored state from the run. + + Raises: + ModelNotFoundError: If the model artifacts cannot be found in MLflow. """ - # Download and load the model - with TemporaryDirectory() as tmpdir: - self._client.download_artifacts(run_id=run_id, path=self.model_path, dst_path=tmpdir) - with (Path(tmpdir) / self.model_path / f"model.{self.model_serializer.extension}").open("rb") as f: - model = cast(Any, self.model_serializer.deserialize(file=f)) + try: + with TemporaryDirectory() as tmpdir: + self._client.download_artifacts(run_id=run_id, path=self.model_path, dst_path=tmpdir) + with (Path(tmpdir) / self.model_path / f"model.{self.model_serializer.extension}").open("rb") as f: + model = cast(Any, self.model_serializer.deserialize(file=f)) + except (MlflowException, FileNotFoundError) as e: + raise ModelNotFoundError(model_id=model_id) from e return model diff --git a/packages/openstef-models/src/openstef_models/integrations/mlflow/mlflow_storage_callback.py b/packages/openstef-models/src/openstef_models/integrations/mlflow/mlflow_storage_callback.py index fd59cd600..0d66be6e1 100644 --- a/packages/openstef-models/src/openstef_models/integrations/mlflow/mlflow_storage_callback.py +++ b/packages/openstef-models/src/openstef_models/integrations/mlflow/mlflow_storage_callback.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 @@ -19,9 +19,16 @@ from openstef_beam.evaluation.metric_providers import MetricDirection from openstef_core.base_model import BaseConfig from openstef_core.datasets.timeseries_dataset import TimeSeriesDataset -from openstef_core.datasets.versioned_timeseries_dataset import VersionedTimeSeriesDataset -from openstef_core.exceptions import ModelNotFoundError, SkipFitting +from openstef_core.datasets.versioned_timeseries_dataset import ( + VersionedTimeSeriesDataset, +) +from openstef_core.exceptions import ( + MissingColumnsError, + ModelNotFoundError, + SkipFitting, +) from openstef_core.types import Q, QuantileOrGlobal +from openstef_meta.models.ensemble_forecasting_model import EnsembleForecastingModel from openstef_models.explainability import ExplainableForecaster from openstef_models.integrations.mlflow.mlflow_storage import MLFlowStorage from openstef_models.mixins.callbacks import WorkflowContext @@ -72,20 +79,34 @@ def on_fit_start( return # Find the latest successful run for this model - runs = self.storage.search_latest_runs(model_id=context.workflow.model_id) - run = next(iter(runs), None) + if context.workflow.run_name is not None: + run = self.storage.search_run( + model_id=context.workflow.model_id, + run_name=context.workflow.run_name, + ) + else: + runs = self.storage.search_latest_runs(model_id=context.workflow.model_id) + run = next(iter(runs), None) if run is not None: # Check if the run is recent enough to skip re-fitting now = datetime.now(tz=UTC) - run_end_datetime = datetime.fromtimestamp(cast(float, run.info.end_time) / 1000, tz=UTC) + end_time_millis = cast(float | None, run.info.end_time) + run_end_datetime = ( + datetime.fromtimestamp(end_time_millis / 1000, tz=UTC) + if end_time_millis is not None + else None + ) self._logger.info( "Found previous MLflow run %s for model %s ended at %s", cast(str, run.info.run_id), context.workflow.model_id, run_end_datetime, ) - if (now - run_end_datetime) <= self.model_reuse_max_age: + if ( + run_end_datetime is not None + and (now - run_end_datetime) <= self.model_reuse_max_age + ): raise SkipFitting("Model is recent enough, skipping re-fit.") @override @@ -97,29 +118,46 @@ def on_fit_end( if self.model_selection_enable: self._run_model_selection(workflow=context.workflow, result=result) + if isinstance(context.workflow.model, EnsembleForecastingModel): + raise NotImplementedError( + "MLFlowStorageCallback does not yet support EnsembleForecastingWorkflow model storage." + ) + # Create a new run run = self.storage.create_run( model_id=context.workflow.model_id, tags=context.workflow.model.tags, hyperparams=context.workflow.model.forecaster.hyperparams, + run_name=context.workflow.run_name, + experiment_tags=context.workflow.experiment_tags, ) run_id: str = run.info.run_id - self._logger.info("Created MLflow run %s for model %s", run_id, context.workflow.model_id) + self._logger.info( + "Created MLflow run %s for model %s", run_id, context.workflow.model_id + ) # Store the model input - run_path = self.storage.get_artifacts_path(model_id=context.workflow.model_id, run_id=run_id) + run_path = self.storage.get_artifacts_path( + model_id=context.workflow.model_id, run_id=run_id + ) data_path = run_path / self.storage.data_path data_path.mkdir(parents=True, exist_ok=True) result.input_dataset.to_parquet(path=data_path / "data.parquet") self._logger.info("Stored training data at %s for run %s", data_path, run_id) # Store feature importance plot if enabled - if self.store_feature_importance_plot and isinstance(context.workflow.model.forecaster, ExplainableForecaster): + if self.store_feature_importance_plot and isinstance( + context.workflow.model.forecaster, ExplainableForecaster + ): fig = context.workflow.model.forecaster.plot_feature_importances() fig.write_html(data_path / "feature_importances.html") # pyright: ignore[reportUnknownMemberType] # Store the trained model - self.storage.save_run_model(model_id=context.workflow.model_id, run_id=run_id, model=context.workflow.model) + self.storage.save_run_model( + model_id=context.workflow.model_id, + run_id=run_id, + model=context.workflow.model, + ) self._logger.info("Stored trained model for run %s", run_id) # Format the metrics for MLflow @@ -128,15 +166,23 @@ def on_fit_end( if result.metrics_val is not None: metrics.update(_metrics_to_dict(metrics=result.metrics_val, prefix="val_")) if result.metrics_test is not None: - metrics.update(_metrics_to_dict(metrics=result.metrics_test, prefix="test_")) + metrics.update( + _metrics_to_dict(metrics=result.metrics_test, prefix="test_") + ) # Mark the run as finished - self.storage.finalize_run(model_id=context.workflow.model_id, run_id=run_id, metrics=metrics) - self._logger.info("Stored MLflow run %s for model %s", run_id, context.workflow.model_id) + self.storage.finalize_run( + model_id=context.workflow.model_id, run_id=run_id, metrics=metrics + ) + self._logger.info( + "Stored MLflow run %s for model %s", run_id, context.workflow.model_id + ) @override def on_predict_start( - self, context: WorkflowContext[CustomForecastingWorkflow], data: VersionedTimeSeriesDataset | TimeSeriesDataset + self, + context: WorkflowContext[CustomForecastingWorkflow], + data: VersionedTimeSeriesDataset | TimeSeriesDataset, ): if context.workflow.model.is_fitted: return @@ -149,7 +195,11 @@ def on_predict_start( # Load the model from the latest run run_id: str = run.info.run_id - old_model = self.storage.load_run_model(run_id=run_id) + + old_model = self.storage.load_run_model( + run_id=run_id, model_id=context.workflow.model_id + ) + if not isinstance(old_model, ForecastingModel): self._logger.warning( "Loaded model from run %s is not a ForecastingModel, cannot use for prediction", @@ -158,39 +208,132 @@ def on_predict_start( return context.workflow.model = old_model - self._logger.info("Loaded model from MLflow run %s for model %s", run_id, context.workflow.model_id) + self._logger.info( + "Loaded model from MLflow run %s for model %s", + run_id, + context.workflow.model_id, + ) - def _run_model_selection(self, workflow: CustomForecastingWorkflow, result: ModelFitResult) -> None: + def _run_model_selection( + self, workflow: CustomForecastingWorkflow, result: ModelFitResult + ) -> None: # Find the latest successful run for this model runs = self.storage.search_latest_runs(model_id=workflow.model_id) run = next(iter(runs), None) if run is None: return - # Backup the new model + run_id = cast(str, run.info.run_id) + + if not self._check_tags_compatible( + run_tags=run.data.tags, # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] + new_tags=workflow.model.tags, + run_id=run_id, + ): + return + new_model = workflow.model new_metrics = result.metrics_full - # Restore the old model and evaluate - old_model = self.storage.load_run_model(run_id=cast(str, run.info.run_id)) - if not isinstance(old_model, ForecastingModel): - self._logger.warning( - "Loaded old model from run %s is not a ForecastingModel, skipping model selection", - cast(str, run.info.run_id), - ) + old_model = self._try_load_model( + run_id=run_id, + workflow=workflow, + ) + + if old_model is None: return - old_metrics = old_model.score(result.input_dataset) - if self._check_is_new_model_better(old_metrics=old_metrics, new_metrics=new_metrics): + old_metrics = self._try_evaluate_model( + run_id=run_id, + old_model=old_model, + input_data=result.input_dataset, + ) + + if old_metrics is None: + return + + if self._check_is_new_model_better( + old_metrics=old_metrics, new_metrics=new_metrics + ): workflow.model = new_model else: workflow.model = old_model self._logger.info( "New model did not improve %s metric from previous run %s, reusing old model", self.model_selection_metric, - cast(str, run.info.run_id), + run_id, + ) + raise SkipFitting( + "New model did not improve monitored metric, skipping re-fit." ) - raise SkipFitting("New model did not improve monitored metric, skipping re-fit.") + + def _try_load_model( + self, + run_id: str, + workflow: CustomForecastingWorkflow, + ) -> ForecastingModel | None: + try: + old_model = self.storage.load_run_model( + run_id=run_id, model_id=workflow.model_id + ) + except ModelNotFoundError: + self._logger.warning( + "Could not load model from previous run %s for model %s, skipping model selection", + run_id, + workflow.model_id, + ) + return None + + if not isinstance(old_model, ForecastingModel): + self._logger.warning( + "Loaded old model from run %s is not a ForecastingModel, skipping model selection", + run_id, + ) + return None + + return old_model + + def _try_evaluate_model( + self, + run_id: str, + old_model: ForecastingModel, + input_data: TimeSeriesDataset, + ) -> SubsetMetric | None: + try: + return old_model.score(input_data) + except (MissingColumnsError, ValueError) as e: + self._logger.warning( + "Could not evaluate old model from run %s, skipping model selection: %s", + run_id, + e, + ) + return None + + def _check_tags_compatible( + self, run_tags: dict[str, str], new_tags: dict[str, str], run_id: str + ) -> bool: + """Check if model tags are compatible, excluding mlflow.runName. + + Returns: + True if tags are compatible, False otherwise. + """ + old_tags = {k: v for k, v in run_tags.items() if k != "mlflow.runName"} + + if old_tags == new_tags: + return True + + differences = { + k: (old_tags.get(k), new_tags.get(k)) + for k in old_tags.keys() | new_tags.keys() + if old_tags.get(k) != new_tags.get(k) + } + + self._logger.info( + "Model tags changed since run %s, skipping model selection. Changes: %s", + run_id, + differences, + ) + return False def _check_is_new_model_better( self, @@ -220,9 +363,13 @@ def _check_is_new_model_better( ) match direction: - case "higher_is_better" if new_metric >= old_metric / self.model_selection_old_model_penalty: + case "higher_is_better" if ( + new_metric >= old_metric / self.model_selection_old_model_penalty + ): return True - case "lower_is_better" if new_metric <= old_metric / self.model_selection_old_model_penalty: + case "lower_is_better" if ( + new_metric <= old_metric / self.model_selection_old_model_penalty + ): return True case _: return False diff --git a/packages/openstef-models/src/openstef_models/integrations/skops/__init__.py b/packages/openstef-models/src/openstef_models/integrations/skops/__init__.py new file mode 100644 index 000000000..16fcbd789 --- /dev/null +++ b/packages/openstef-models/src/openstef_models/integrations/skops/__init__.py @@ -0,0 +1,15 @@ +"""Joblib-based model storage integration. + +Provides local file-based model persistence using Skops for serialization. +This integration provides a safe way for storing and loading ForecastingModel instances on +the local filesystem, making it suitable for development, testing, and +single-machine deployments. +""" + +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# +# SPDX-License-Identifier: MPL-2.0 + +from .skops_model_serializer import SkopsModelSerializer + +__all__ = ["SkopsModelSerializer"] diff --git a/packages/openstef-models/src/openstef_models/integrations/skops/skops_model_serializer.py b/packages/openstef-models/src/openstef_models/integrations/skops/skops_model_serializer.py new file mode 100644 index 000000000..6296d3abb --- /dev/null +++ b/packages/openstef-models/src/openstef_models/integrations/skops/skops_model_serializer.py @@ -0,0 +1,105 @@ +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# +# SPDX-License-Identifier: MPL-2.0 +"""Local model storage implementation using joblib serialization. + +Provides file-based persistence for ForecastingModel instances using joblib's +pickle-based serialization. This storage backend is suitable for development, +testing, and single-machine deployments where models need to be persisted +to the local filesystem. +""" + +from typing import BinaryIO, ClassVar, override + +from openstef_core.exceptions import MissingExtraError +from openstef_models.mixins.model_serializer import ModelSerializer + +try: + from skops.io import dump, get_untrusted_types, load +except ImportError as e: + raise MissingExtraError("joblib", package="openstef-models") from e + + +class SkopsModelSerializer(ModelSerializer): + """File-based model storage using joblib serialization. + + Provides persistent storage for ForecastingModel instances on the local + filesystem. Models are serialized using joblib and stored as pickle files + in the specified directory. + + This storage implementation is suitable for development, testing, and + single-machine deployments where simple file-based persistence is sufficient. + + Note: + joblib.dump() and joblib.load() are based on the Python pickle serialization model, + which means that arbitrary Python code can be executed when loading a serialized object + with joblib.load(). + + joblib.load() should therefore never be used to load objects from an untrusted source + or otherwise you will introduce a security vulnerability in your program. + + Invariants: + - Models are stored as .pkl files in the configured storage directory + - Model files use the pattern: {model_id}.pkl + - Storage directory is created automatically if it doesn't exist + - Load operations fail with ModelNotFoundError if model file doesn't exist + + Example: + Basic usage with model persistence: + + >>> from pathlib import Path + >>> from openstef_models.models.forecasting_model import ForecastingModel + >>> storage = LocalModelStorage(storage_dir=Path("./models")) # doctest: +SKIP + >>> storage.save_model("my_model", my_forecasting_model) # doctest: +SKIP + >>> loaded_model = storage.load_model("my_model") # doctest: +SKIP + """ + + extension: ClassVar[str] = ".skops" + + @override + def serialize(self, model: object, file: BinaryIO) -> None: + dump(model, file) # type: ignore[reportUnknownMemberType] + + @staticmethod + def _get_stateful_types() -> set[str]: + return { + "tests.unit.integrations.skops.test_skops_model_serializer.SimpleSerializableModel", + "openstef_core.mixins.predictor.BatchPredictor", + "openstef_models.models.forecasting.forecaster.Forecaster", + "openstef_models.models.forecasting.xgboost_forecaster.XGBoostForecaster", + "openstef_models.models.component_splitting_model.ComponentSplittingModel", + "openstef_core.mixins.transform.TransformPipeline", + "openstef_core.mixins.transform.TransformPipeline[EnergyComponentDataset]", + "openstef_core.mixins.transform.TransformPipeline[TimeSeriesDataset]", + "openstef_models.models.forecasting.lgbm_forecaster.LGBMForecaster", + "openstef_models.models.component_splitting.component_splitter.ComponentSplitter", + "openstef_models.models.forecasting_model.ForecastingModel", + "openstef_core.mixins.transform.Transform", + "openstef_core.mixins.transform.TransformPipeline[ForecastDataset]", + "openstef_core.mixins.predictor.Predictor", + "openstef_models.models.forecasting.lgbmlinear_forecaster.LGBMLinearForecaster", + } + + @override + def deserialize(self, file: BinaryIO) -> object: + """Load a model's state from a binary file and restore it. + + Returns: + The restored model instance. + + Raises: + ValueError: If no safe types are found in the serialized model. + """ + safe_types = self._get_stateful_types() + + # Weak security measure that checks a safe class is present. + # Can be improved to ensure no unsafe classes are present. + model_types: set[str] = set(get_untrusted_types(file=file)) # type: ignore + + if len(safe_types.intersection(model_types)) == 0: + raise ValueError("Deserialization aborted: No safe types found in the serialized model.") + + return load(file, trusted=list(model_types)) # type: ignore[reportUnknownMemberType] + + +__all__ = ["SkopsModelSerializer"] diff --git a/packages/openstef-models/src/openstef_models/mixins/__init__.py b/packages/openstef-models/src/openstef_models/mixins/__init__.py index f505ac4b1..cc3a0ac25 100644 --- a/packages/openstef-models/src/openstef_models/mixins/__init__.py +++ b/packages/openstef-models/src/openstef_models/mixins/__init__.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/src/openstef_models/mixins/callbacks.py b/packages/openstef-models/src/openstef_models/mixins/callbacks.py index acc399cda..5f744e47c 100644 --- a/packages/openstef-models/src/openstef_models/mixins/callbacks.py +++ b/packages/openstef-models/src/openstef_models/mixins/callbacks.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/src/openstef_models/mixins/model_serializer.py b/packages/openstef-models/src/openstef_models/mixins/model_serializer.py index ab00993f7..31bf67cff 100644 --- a/packages/openstef-models/src/openstef_models/mixins/model_serializer.py +++ b/packages/openstef-models/src/openstef_models/mixins/model_serializer.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 @@ -34,6 +34,7 @@ class ModelSerializer(BaseConfig, ABC): See Also: JoblibModelSerializer: Concrete implementation using joblib. + SkopsModelSerializer: Concrete implementation using skops. """ extension: ClassVar[str] diff --git a/packages/openstef-models/src/openstef_models/models/__init__.py b/packages/openstef-models/src/openstef_models/models/__init__.py index 9d4b0e8d2..766194fe5 100644 --- a/packages/openstef-models/src/openstef_models/models/__init__.py +++ b/packages/openstef-models/src/openstef_models/models/__init__.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/src/openstef_models/models/component_splitting/__init__.py b/packages/openstef-models/src/openstef_models/models/component_splitting/__init__.py index aa39c8b7d..d70b53096 100644 --- a/packages/openstef-models/src/openstef_models/models/component_splitting/__init__.py +++ b/packages/openstef-models/src/openstef_models/models/component_splitting/__init__.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/src/openstef_models/models/component_splitting/component_splitter.py b/packages/openstef-models/src/openstef_models/models/component_splitting/component_splitter.py index 7aa0182f2..43a0f8f72 100644 --- a/packages/openstef-models/src/openstef_models/models/component_splitting/component_splitter.py +++ b/packages/openstef-models/src/openstef_models/models/component_splitting/component_splitter.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/src/openstef_models/models/component_splitting/constant_component_splitter.py b/packages/openstef-models/src/openstef_models/models/component_splitting/constant_component_splitter.py index 0bf4a1784..d841fc082 100644 --- a/packages/openstef-models/src/openstef_models/models/component_splitting/constant_component_splitter.py +++ b/packages/openstef-models/src/openstef_models/models/component_splitting/constant_component_splitter.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/src/openstef_models/models/component_splitting/linear_component_splitter.py b/packages/openstef-models/src/openstef_models/models/component_splitting/linear_component_splitter.py index db0618d24..029f42c41 100644 --- a/packages/openstef-models/src/openstef_models/models/component_splitting/linear_component_splitter.py +++ b/packages/openstef-models/src/openstef_models/models/component_splitting/linear_component_splitter.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 @@ -136,13 +136,6 @@ def _create_input_features(self, data: TimeSeriesDataset) -> pd.DataFrame: radiation_col = self.config.radiation_column wind_col = self.config.windspeed_100m_column - # Validate required columns - required_cols = [source_col, radiation_col, wind_col] - missing_cols = [col for col in required_cols if col not in df.columns] - if missing_cols: - error_msg = f"Missing required columns for linear model prediction: {missing_cols}" - raise ValueError(error_msg) - # Create feature dataframe with the expected column names input_df = pd.DataFrame( { @@ -196,9 +189,9 @@ def predict(self, data: TimeSeriesDataset) -> EnergyComponentDataset: index=input_df.index, ) - # Clip wind and solar components to be non-negative - forecasts[EnergyComponentType.SOLAR] = forecasts[EnergyComponentType.SOLAR].clip(lower=0.0) - forecasts[EnergyComponentType.WIND] = forecasts[EnergyComponentType.WIND].clip(lower=0.0) + # Clip wind and solar components to be strictly negative + forecasts[EnergyComponentType.SOLAR] = forecasts[EnergyComponentType.SOLAR].clip(upper=0.0) + forecasts[EnergyComponentType.WIND] = forecasts[EnergyComponentType.WIND].clip(upper=0.0) # Calculate "other" component as residual forecasts[EnergyComponentType.OTHER] = ( diff --git a/packages/openstef-models/src/openstef_models/models/component_splitting/linear_component_splitter_model/linear_component_splitter_model.z.license b/packages/openstef-models/src/openstef_models/models/component_splitting/linear_component_splitter_model/linear_component_splitter_model.z.license index 37e10dd31..7d320d6e2 100644 --- a/packages/openstef-models/src/openstef_models/models/component_splitting/linear_component_splitter_model/linear_component_splitter_model.z.license +++ b/packages/openstef-models/src/openstef_models/models/component_splitting/linear_component_splitter_model/linear_component_splitter_model.z.license @@ -1,3 +1,3 @@ -SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/src/openstef_models/models/component_splitting_model.py b/packages/openstef-models/src/openstef_models/models/component_splitting_model.py index 653d60eb7..4fbb6ecc5 100644 --- a/packages/openstef-models/src/openstef_models/models/component_splitting_model.py +++ b/packages/openstef-models/src/openstef_models/models/component_splitting_model.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/src/openstef_models/models/forecasting/__init__.py b/packages/openstef-models/src/openstef_models/models/forecasting/__init__.py index adb81d012..1623e576e 100644 --- a/packages/openstef-models/src/openstef_models/models/forecasting/__init__.py +++ b/packages/openstef-models/src/openstef_models/models/forecasting/__init__.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/src/openstef_models/models/forecasting/base_case_forecaster.py b/packages/openstef-models/src/openstef_models/models/forecasting/base_case_forecaster.py index d7e01c965..4b021d2b3 100644 --- a/packages/openstef-models/src/openstef_models/models/forecasting/base_case_forecaster.py +++ b/packages/openstef-models/src/openstef_models/models/forecasting/base_case_forecaster.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 @@ -189,6 +189,23 @@ def predict(self, data: ForecastInputDataset) -> ForecastDataset: sample_interval=data.sample_interval, ) + @override + def predict_contributions(self, data: ForecastInputDataset, *, scale: bool = True) -> pd.DataFrame: + """Generate feature contributions. + + Args: + data: The forecast input dataset containing target variable history. + scale: Whether to scale contributions to sum to 1. Defaults to True. + + Returns: + pd.DataFrame containing the prediction contributions. + """ + return pd.DataFrame( + data=1.0, + index=data.index, + columns=["load_" + quantile.format() for quantile in self.config.quantiles], + ) + @property @override def feature_importances(self) -> pd.DataFrame: diff --git a/packages/openstef-models/src/openstef_models/models/forecasting/constant_median_forecaster.py b/packages/openstef-models/src/openstef_models/models/forecasting/constant_median_forecaster.py index 930881a55..e516472a2 100644 --- a/packages/openstef-models/src/openstef_models/models/forecasting/constant_median_forecaster.py +++ b/packages/openstef-models/src/openstef_models/models/forecasting/constant_median_forecaster.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 @@ -141,3 +141,20 @@ def feature_importances(self) -> pd.DataFrame: index=["load"], columns=[quantile.format() for quantile in self.config.quantiles], ) + + @override + def predict_contributions(self, data: ForecastInputDataset, *, scale: bool = True) -> pd.DataFrame: + """Generate feature contributions. + + Args: + data: The forecast input dataset containing target variable history. + scale: Whether to scale contributions to sum to 1. Defaults to True. + + Returns: + pd.DataFrame containing the prediction contributions. + """ + return pd.DataFrame( + data=1.0, + index=data.index, + columns=["load_" + quantile.format() for quantile in self.config.quantiles], + ) diff --git a/packages/openstef-models/src/openstef_models/models/forecasting/flatliner_forecaster.py b/packages/openstef-models/src/openstef_models/models/forecasting/flatliner_forecaster.py index 51a5b5ed0..e4ab21437 100644 --- a/packages/openstef-models/src/openstef_models/models/forecasting/flatliner_forecaster.py +++ b/packages/openstef-models/src/openstef_models/models/forecasting/flatliner_forecaster.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 @@ -11,6 +11,7 @@ from typing import override import pandas as pd +from pydantic import Field from openstef_core.datasets.validated_datasets import ForecastDataset, ForecastInputDataset from openstef_models.explainability.mixins import ExplainableForecaster @@ -20,18 +21,24 @@ class FlatlinerForecasterConfig(ForecasterConfig): """Configuration for flatliner forecaster.""" + predict_median: bool = Field( + default=False, + description="If True, predict the median of load measurements instead of zero.", + ) + MODEL_CODE_VERSION = 1 class FlatlinerForecaster(Forecaster, ExplainableForecaster): - """Flatliner forecaster that predicts a flatline of zeros. + """Flatliner forecaster that predicts a flatline of zeros or median. - A simple forecasting model that always predicts zero for all horizons and quantiles. + A simple forecasting model that always predicts zero (or the median of historical + load measurements if configured) for all horizons and quantiles. Invariants: - Configuration quantiles determine the number of prediction outputs - - Zeros are predicted for all horizons and quantiles + - Zeros (or median values) are predicted for all horizons and quantiles Example: >>> from openstef_core.types import LeadTime, Quantile @@ -52,6 +59,7 @@ class FlatlinerForecaster(Forecaster, ExplainableForecaster): Config = FlatlinerForecasterConfig _config: FlatlinerForecasterConfig + _median_value: float | None def __init__( self, @@ -63,6 +71,7 @@ def __init__( config: Configuration specifying quantiles and horizons. """ self._config = config or FlatlinerForecasterConfig() + self._median_value = None @property @override @@ -72,6 +81,9 @@ def config(self) -> FlatlinerForecasterConfig: @property @override def is_fitted(self) -> bool: + # When predict_median is True, the model needs to be fitted to compute the median + if self._config.predict_median: + return self._median_value is not None return True @override @@ -80,15 +92,18 @@ def fit( data: ForecastInputDataset, data_val: ForecastInputDataset | None = None, ) -> None: - pass + if self._config.predict_median: + self._median_value = float(data.target_series.median()) @override def predict(self, data: ForecastInputDataset) -> ForecastDataset: forecast_index = data.create_forecast_range(horizon=self.config.max_horizon) + prediction_value = self._median_value if self._config.predict_median else 0.0 + return ForecastDataset( data=pd.DataFrame( - data={quantile.format(): 0.0 for quantile in self.config.quantiles}, + data={quantile.format(): prediction_value for quantile in self.config.quantiles}, index=forecast_index, ), sample_interval=data.sample_interval, @@ -102,3 +117,13 @@ def feature_importances(self) -> pd.DataFrame: index=["load"], columns=[quantile.format() for quantile in self.config.quantiles], ) + + @override + def predict_contributions(self, data: ForecastInputDataset, *, scale: bool = True) -> pd.DataFrame: + + forecast_index = data.create_forecast_range(horizon=self.config.max_horizon) + + return pd.DataFrame( + data={quantile.format(): 0.0 for quantile in self.config.quantiles}, + index=forecast_index, + ) diff --git a/packages/openstef-models/src/openstef_models/models/forecasting/forecaster.py b/packages/openstef-models/src/openstef_models/models/forecasting/forecaster.py index d796b0ef3..54e96a723 100644 --- a/packages/openstef-models/src/openstef_models/models/forecasting/forecaster.py +++ b/packages/openstef-models/src/openstef_models/models/forecasting/forecaster.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 @@ -111,6 +111,15 @@ def with_horizon(self, horizon: LeadTime) -> Self: """ return self.model_copy(update={"horizons": [horizon]}) + @classmethod + def forecaster_class(cls) -> type["Forecaster"]: + """Get the associated Forecaster class for this configuration. + + Returns: + The Forecaster class that uses this configuration. + """ + raise NotImplementedError("Subclasses must implement forecaster_class") + class ConfigurableForecaster: @property @@ -197,6 +206,25 @@ class Forecaster(BatchPredictor[ForecastInputDataset, ForecastDataset], Configur ... ) """ + @abstractmethod + def __init__(self, config: ForecasterConfig) -> None: + """Initialize the forecaster with the given configuration. + + Args: + config: Configuration object specifying quantiles, horizons, and batching support. + """ + raise NotImplementedError("Subclasses must implement __init__") + + @property + @abstractmethod + def config(self) -> ForecasterConfig: + """Access the model's configuration parameters. + + Returns: + Configuration object containing fundamental model parameters. + """ + raise NotImplementedError("Subclasses must implement config") + __all__ = [ "Forecaster", diff --git a/packages/openstef-models/src/openstef_models/models/forecasting/gblinear_forecaster.py b/packages/openstef-models/src/openstef_models/models/forecasting/gblinear_forecaster.py index 002cada6f..f08dc4269 100644 --- a/packages/openstef-models/src/openstef_models/models/forecasting/gblinear_forecaster.py +++ b/packages/openstef-models/src/openstef_models/models/forecasting/gblinear_forecaster.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 @@ -93,6 +93,15 @@ class GBLinearHyperParams(HyperParams): description="Training will stop if performance doesn't improve for this many rounds. Requires validation data.", ) + @classmethod + def forecaster_class(cls) -> "type[GBLinearForecaster]": + """Forecaster class for these hyperparams. + + Returns: + Forecaster class associated with this configuration. + """ + return GBLinearForecaster + class GBLinearForecasterConfig(ForecasterConfig): """Configuration for GBLinear forecaster.""" @@ -114,9 +123,17 @@ class GBLinearForecasterConfig(ForecasterConfig): default="cpu", description="Device for XGBoost computation. Options: 'cpu', 'cuda', 'cuda:', 'gpu'" ) verbosity: Literal[0, 1, 2, 3, True] = Field( - default=1, description="Verbosity level. 0=silent, 1=warning, 2=info, 3=debug" + default=0, description="Verbosity level. 0=silent, 1=warning, 2=info, 3=debug" ) + def forecaster_from_config(self) -> "GBLinearForecaster": + """Create a GBLinearForecaster instance from this configuration. + + Returns: + Forecaster instance associated with this configuration. + """ + return GBLinearForecaster(config=self) + MODEL_CODE_VERSION = 1 @@ -255,7 +272,7 @@ def fit(self, data: ForecastInputDataset, data_val: ForecastInputDataset | None raise InputValidationError("The input data is empty after dropping NaN values.") # Fit the scalers - self._target_scaler.fit(data.target_series.to_frame()) + self._target_scaler.fit(data.target_series.to_frame().to_numpy()) # Prepare training data input_data, target, sample_weight = self._prepare_fit_input(data) @@ -309,6 +326,47 @@ def predict(self, data: ForecastInputDataset) -> ForecastDataset: sample_interval=data.sample_interval, ) + def predict_contributions(self, data: ForecastInputDataset, *, scale: bool = True) -> pd.DataFrame: + """Get feature contributions for each prediction. + + Args: + data: Input dataset for which to compute feature contributions. + scale: If True, scale contributions to sum to 1.0 per quantile. + + Returns: + DataFrame with contributions per feature. + """ + # Get input features for prediction + input_data: pd.DataFrame = data.input_data(start=data.forecast_start) + xgb_input: xgb.DMatrix = xgb.DMatrix(data=input_data) + + # Generate predictions + booster = self._gblinear_model.get_booster() + predictions_array: np.ndarray = booster.predict(xgb_input, pred_contribs=True, strict_shape=True)[:, :, :-1] + + # Remove last column + contribs = predictions_array / np.sum(predictions_array, axis=-1, keepdims=True) + + # Flatten to 2D array, name columns accordingly + contribs = contribs.reshape(contribs.shape[0], -1) + df = pd.DataFrame( + data=contribs, + index=input_data.index, + columns=[ + f"{feature}_{quantile.format()}" for feature in input_data.columns for quantile in self.config.quantiles + ], + ) + + if scale: + # Scale contributions so that they sum to 1.0 per quantile and are positive + for q in self.config.quantiles: + quantile_cols = [col for col in df.columns if col.endswith(f"_{q.format()}")] + row_sums = df[quantile_cols].abs().sum(axis=1) + df[quantile_cols] = df[quantile_cols].abs().div(row_sums, axis=0) + + # Construct DataFrame with appropriate quantile columns + return df + @property @override def feature_importances(self) -> pd.DataFrame: diff --git a/packages/openstef-models/src/openstef_models/models/forecasting/lgbm_forecaster.py b/packages/openstef-models/src/openstef_models/models/forecasting/lgbm_forecaster.py new file mode 100644 index 000000000..5868289d3 --- /dev/null +++ b/packages/openstef-models/src/openstef_models/models/forecasting/lgbm_forecaster.py @@ -0,0 +1,371 @@ +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# +# SPDX-License-Identifier: MPL-2.0 + +"""LightGBM-based forecasting models for probabilistic energy forecasting. + +Provides gradient boosting tree models using LightGBM for multi-quantile energy +forecasting. Optimized for time series data with specialized loss functions and +comprehensive hyperparameter control for production forecasting workflows. +""" + +from typing import TYPE_CHECKING, Literal, override + +import numpy as np +import pandas as pd +from lightgbm import LGBMRegressor +from pydantic import Field + +from openstef_core.datasets import ForecastDataset, ForecastInputDataset +from openstef_core.exceptions import ( + NotFittedError, +) +from openstef_core.mixins import HyperParams +from openstef_models.explainability.mixins import ExplainableForecaster +from openstef_models.models.forecasting.forecaster import Forecaster, ForecasterConfig +from openstef_models.utils.multi_quantile_regressor import MultiQuantileRegressor + +if TYPE_CHECKING: + import numpy.typing as npt + + +class LGBMHyperParams(HyperParams): + """LightGBM hyperparameters for gradient boosting tree models. + + Example: + Creating custom hyperparameters for deep trees with regularization: + + >>> hyperparams = LGBMHyperParams( + ... n_estimators=200, + ... max_depth=8, + ... learning_rate=0.1, + ... reg_alpha=0.1, + ... reg_lambda=1.0, + ... ) + + Note: + These parameters are optimized for probabilistic forecasting with + quantile regression. The default objective function is specialized + for magnitude-weighted pinball loss. + """ + + # Core Tree Boosting Parameters + n_estimators: int = Field( + default=100, + description="Number of boosting rounds/trees to fit. Higher values may improve performance but " + "increase training time and risk overfitting.", + ) + learning_rate: float = Field( + default=0.49, # 0.3 + alias="eta", + description="Step size shrinkage used to prevent overfitting. Range: [0,1]. Lower values require " + "more boosting rounds.", + ) + max_depth: int = Field( + default=2, # 8, + description="Maximum depth of trees. Higher values capture more complex patterns but risk " + "overfitting. Range: [1,∞]", + ) + min_child_weight: float = Field( + default=1, + description="Minimum sum of instance weight (hessian) needed in a child. Higher values prevent " + "overfitting. Range: [0,∞]", + ) + + min_data_in_leaf: int = Field( + default=10, + description="Minimum number of data points in a leaf. Higher values prevent overfitting. Range: [1,∞]", + ) + min_data_in_bin: int = Field( + default=10, + description="Minimum number of data points in a bin. Higher values prevent overfitting. Range: [1,∞]", + ) + + # Regularization + reg_alpha: float = Field( + default=0, + description="L1 regularization on leaf weights. Higher values increase regularization. Range: [0,∞]", + ) + reg_lambda: float = Field( + default=1, + description="L2 regularization on leaf weights. Higher values increase regularization. Range: [0,∞]", + ) + + # Tree Structure Control + num_leaves: int = Field( + default=100, # 31 + description="Maximum number of leaves. 0 means no limit. Only relevant when grow_policy='lossguide'.", + ) + + max_bin: int = Field( + default=256, + description="Maximum number of discrete bins for continuous features. Higher values may improve accuracy but " + "increase memory. Only for hist tree_method.", + ) + + # Subsampling Parameters + colsample_bytree: float = Field( + default=1.0, + description="Fraction of features used when constructing each tree. Range: (0,1]", + ) + + @classmethod + def forecaster_class(cls) -> "type[LGBMForecaster]": + """Create a LightGBM forecaster instance from this configuration. + + Returns: + Forecaster class associated with this configuration. + """ + return LGBMForecaster + + +class LGBMForecasterConfig(ForecasterConfig): + """Configuration for LightGBM-based forecaster. + Extends HorizonForecasterConfig with LightGBM-specific hyperparameters + and execution settings. + + Example: + Creating a LightGBM forecaster configuration with custom hyperparameters: + >>> from datetime import timedelta + >>> from openstef_core.types import LeadTime, Quantile + >>> config = LGBMForecasterConfig( + ... quantiles=[Quantile(0.1), Quantile(0.5), Quantile(0.9)], + ... horizons=[LeadTime(timedelta(hours=1))], + ... hyperparams=LGBMHyperParams(n_estimators=100, max_depth=6)) + """ # noqa: D205 + + hyperparams: LGBMHyperParams = LGBMHyperParams() + + # General Parameters + device: str = Field( + default="cpu", + description="Device for LightGBM computation. Options: 'cpu', 'cuda', 'cuda:', 'gpu'", + ) + n_jobs: int = Field( + default=1, + description="Number of parallel threads for tree construction. -1 uses all available cores.", + ) + verbosity: Literal[-1, 0, 1, 2, 3] = Field( + default=-1, description="Verbosity level. 0=silent, 1=warning, 2=info, 3=debug" + ) + + random_state: int | None = Field( + default=None, + alias="seed", + description="Random seed for reproducibility. Controls tree structure randomness.", + ) + + early_stopping_rounds: int | None = Field( + default=None, + description="Training will stop if performance doesn't improve for this many rounds. Requires validation data.", + ) + + def forecaster_from_config(self) -> "LGBMForecaster": + """Create a LGBMForecaster instance from this configuration. + + Returns: + Forecaster instance associated with this configuration. + """ + return LGBMForecaster(config=self) + + +MODEL_CODE_VERSION = 1 + + +class LGBMForecaster(Forecaster, ExplainableForecaster): + """LightGBM-based forecaster for probabilistic energy forecasting. + + Implements gradient boosting trees using LightGBM for multi-quantile forecasting. + Optimized for time series prediction with specialized loss functions and + comprehensive hyperparameter control suitable for production energy forecasting. + + The forecaster uses a multi-output strategy where each quantile is predicted + by separate trees within the same boosting ensemble. This approach provides + well-calibrated uncertainty estimates while maintaining computational efficiency. + + Invariants: + - fit() must be called before predict() to train the model + - Configuration quantiles determine the number of prediction outputs + - Model state is preserved across predict() calls after fitting + - Input features must match training data structure during prediction + + Example: + Basic forecasting workflow: + + >>> from datetime import timedelta + >>> from openstef_core.types import LeadTime, Quantile + >>> config = LGBMForecasterConfig( + ... quantiles=[Quantile(0.1), Quantile(0.5), Quantile(0.9)], + ... horizons=[LeadTime(timedelta(hours=1))], + ... hyperparams=LGBMHyperParams(n_estimators=100, max_depth=6) + ... ) + >>> forecaster = LGBMForecaster(config) + >>> # forecaster.fit(training_data) + >>> # predictions = forecaster.predict(test_data) + + Note: + LightGBM dependency is optional and must be installed separately. + The model automatically handles multi-quantile output and uses + magnitude-weighted pinball loss by default for better forecasting performance. + + See Also: + LGBMHyperParams: Detailed hyperparameter configuration options. + HorizonForecaster: Base interface for all forecasting models. + GBLinearForecaster: Alternative linear model using LightGBM. + """ + + Config = LGBMForecasterConfig + HyperParams = LGBMHyperParams + + _config: LGBMForecasterConfig + + def __init__(self, config: LGBMForecasterConfig) -> None: + """Initialize LightGBM forecaster with configuration. + + Creates an untrained LightGBM regressor with the specified configuration. + The underlying LightGBM model is configured for multi-output quantile + regression using the provided hyperparameters and execution settings. + + Args: + config: Complete configuration including hyperparameters, quantiles, + and execution settings for the LightGBM model. + """ + self._config = config + + lgbm_params = { + "linear_tree": False, + "objective": "quantile", + "random_state": config.random_state, + "early_stopping_rounds": config.early_stopping_rounds, + "verbosity": config.verbosity, + "n_jobs": config.n_jobs, + **config.hyperparams.model_dump(), + } + + self._lgbm_model: MultiQuantileRegressor = MultiQuantileRegressor( + base_learner=LGBMRegressor, # type: ignore + quantile_param="alpha", + hyperparams=lgbm_params, + quantiles=[float(q) for q in config.quantiles], + ) + + @property + @override + def config(self) -> ForecasterConfig: + return self._config + + @property + @override + def hyperparams(self) -> LGBMHyperParams: + return self._config.hyperparams + + @property + @override + def is_fitted(self) -> bool: + return self._lgbm_model.is_fitted + + @staticmethod + def _prepare_fit_input(data: ForecastInputDataset) -> tuple[pd.DataFrame, np.ndarray, pd.Series]: + input_data: pd.DataFrame = data.input_data() + target: np.ndarray = np.asarray(data.target_series.values) + sample_weight: pd.Series = data.sample_weight_series + + return input_data, target, sample_weight + + @override + def fit(self, data: ForecastInputDataset, data_val: ForecastInputDataset | None = None) -> None: + # Prepare training data + input_data, target, sample_weight = self._prepare_fit_input(data) + + # Evaluation sets + eval_set = [(input_data, target)] + sample_weight_eval_set = [sample_weight] + + if data_val is not None: + input_data_val, target_val, sample_weight_val = self._prepare_fit_input(data_val) + eval_set.append((input_data_val, target_val)) + sample_weight_eval_set.append(sample_weight_val) + + self._lgbm_model.fit( + X=input_data, + y=target, + feature_name=input_data.columns.tolist(), + sample_weight=sample_weight, + eval_set=eval_set, + eval_sample_weight=sample_weight_eval_set, + ) + + @override + def predict(self, data: ForecastInputDataset) -> ForecastDataset: + if not self.is_fitted: + raise NotFittedError(self.__class__.__name__) + + input_data: pd.DataFrame = data.input_data(start=data.forecast_start) + prediction: npt.NDArray[np.floating] = self._lgbm_model.predict(X=input_data) + + return ForecastDataset( + data=pd.DataFrame( + data=prediction, + index=input_data.index, + columns=[quantile.format() for quantile in self.config.quantiles], + ), + sample_interval=data.sample_interval, + ) + + def predict_contributions(self, data: ForecastInputDataset, *, scale: bool) -> pd.DataFrame: + """Get feature contributions for each prediction. + + Args: + data: Input dataset for which to compute feature contributions. + scale: If True, scale contributions to sum to 1.0 per quantile. + + Returns: + DataFrame with contributions per feature. + """ + # Get input features for prediction + input_data: pd.DataFrame = data.input_data(start=data.forecast_start) + + contributions: list[pd.DataFrame] = [] + + for i, quantile in enumerate(self.config.quantiles): + # Get model for specific quantile + model: LGBMRegressor = self._lgbm_model.models[i] # type: ignore + + # Generate contributions using LightGBM's built-in method, and remove bias term + contribs_quantile: np.ndarray[float] = model.predict(input_data, pred_contrib=True)[:, :-1] # type: ignore + + if scale: + # Scale contributions so that they sum to 1.0 per quantile + contribs_quantile = np.abs(contribs_quantile) / np.sum(np.abs(contribs_quantile), axis=1, keepdims=True) + + contributions.append( + pd.DataFrame( + data=contribs_quantile, + index=input_data.index, + columns=[f"{feature}_{quantile.format()}" for feature in input_data.columns], + ) + ) + + # Construct DataFrame + return pd.concat(contributions, axis=1) + + @property + @override + def feature_importances(self) -> pd.DataFrame: + models: list[LGBMRegressor] = self._lgbm_model.models # type: ignore + weights_df = pd.DataFrame( + [models[i].feature_importances_ for i in range(len(models))], + index=[quantile.format() for quantile in self.config.quantiles], + columns=self._lgbm_model.model_feature_names if self._lgbm_model.has_feature_names else None, + ).transpose() + + weights_df.index.name = "feature_name" + weights_df.columns.name = "quantiles" + + weights_abs = weights_df.abs() + total = weights_abs.sum(axis=0).replace(to_replace=0, value=1.0) # pyright: ignore[reportUnknownMemberType] + + return weights_abs / total + + +__all__ = ["LGBMForecaster", "LGBMForecasterConfig", "LGBMHyperParams"] diff --git a/packages/openstef-models/src/openstef_models/models/forecasting/lgbmlinear_forecaster.py b/packages/openstef-models/src/openstef_models/models/forecasting/lgbmlinear_forecaster.py new file mode 100644 index 000000000..391bcceca --- /dev/null +++ b/packages/openstef-models/src/openstef_models/models/forecasting/lgbmlinear_forecaster.py @@ -0,0 +1,349 @@ +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# +# SPDX-License-Identifier: MPL-2.0 + +"""LightGBM-based forecasting models for probabilistic energy forecasting. + +Provides gradient boosting tree models using LightGBM for multi-quantile energy +forecasting. Optimized for time series data with specialized loss functions and +comprehensive hyperparameter control for production forecasting workflows. +""" + +from typing import TYPE_CHECKING, Literal, override + +import numpy as np +import pandas as pd +from lightgbm import LGBMRegressor +from pydantic import Field + +from openstef_core.datasets import ForecastDataset, ForecastInputDataset +from openstef_core.exceptions import ( + NotFittedError, +) +from openstef_core.mixins import HyperParams +from openstef_models.explainability.mixins import ExplainableForecaster +from openstef_models.models.forecasting.forecaster import Forecaster, ForecasterConfig +from openstef_models.utils.multi_quantile_regressor import MultiQuantileRegressor + +if TYPE_CHECKING: + import numpy.typing as npt + + +class LGBMLinearHyperParams(HyperParams): + """LgbLinear hyperparameters for gradient boosting tree models. + + Example: + Creating custom hyperparameters for deep trees with regularization: + + >>> hyperparams = LGBMLinearHyperParams( + ... n_estimators=200, + ... max_depth=8, + ... learning_rate=0.1, + ... reg_alpha=0.1, + ... reg_lambda=1.0, + ... ) + + Note: + These parameters are optimized for probabilistic forecasting with + quantile regression. The default objective function is specialized + for magnitude-weighted pinball loss. + """ + + # Core Tree Boosting Parameters + + n_estimators: int = Field( + default=100, + description="Number of boosting rounds/trees to fit. Higher values may improve performance but " + "increase training time and risk overfitting.", + ) + learning_rate: float = Field( + default=0.07, + alias="eta", + description="Step size shrinkage used to prevent overfitting. Range: [0,1]. Lower values require " + "more boosting rounds.", + ) + max_depth: int = Field( + default=6, + description="Maximum depth of trees. Higher values capture more complex patterns but risk " + "overfitting. Range: [1,∞]", + ) + min_child_weight: float = Field( + default=0.06, + description="Minimum sum of instance weight (hessian) needed in a child. Higher values prevent " + "overfitting. Range: [0,∞]", + ) + + min_data_in_leaf: int = Field( + default=500, + description="Minimum number of data points in a leaf. Higher values prevent overfitting. Range: [1,∞]", + ) + min_data_in_bin: int = Field( + default=500, + description="Minimum number of data points in a bin. Higher values prevent overfitting. Range: [1,∞]", + ) + + # Regularization + reg_alpha: float = Field( + default=0, + description="L1 regularization on leaf weights. Higher values increase regularization. Range: [0,∞]", + ) + reg_lambda: float = Field( + default=1, + description="L2 regularization on leaf weights. Higher values increase regularization. Range: [0,∞]", + ) + + # Tree Structure Control + num_leaves: int = Field( + default=30, + description="Maximum number of leaves. 0 means no limit. Only relevant when grow_policy='lossguide'.", + ) + + max_bin: int = Field( + default=256, + description="Maximum number of discrete bins for continuous features. Higher values may improve accuracy but " + "increase memory.", + ) + + # Subsampling Parameters + colsample_bytree: float = Field( + default=1, + description="Fraction of features used when constructing each tree. Range: (0,1]", + ) + + @classmethod + def forecaster_class(cls) -> "type[LGBMLinearForecaster]": + """Get forecaster class for these hyperparams. + + Returns: + Forecaster class associated with this configuration. + """ + return LGBMLinearForecaster + + +class LGBMLinearForecasterConfig(ForecasterConfig): + """Configuration for LgbLinear-based forecaster. + Extends HorizonForecasterConfig with LgbLinear-specific hyperparameters + and execution settings. + + Example: + Creating a LgbLinear forecaster configuration with custom hyperparameters: + >>> from datetime import timedelta + >>> from openstef_core.types import LeadTime, Quantile + >>> config = LGBMLinearForecasterConfig( + ... quantiles=[Quantile(0.1), Quantile(0.5), Quantile(0.9)], + ... horizons=[LeadTime(timedelta(hours=1))], + ... hyperparams=LGBMLinearHyperParams(n_estimators=100, max_depth=6) + ... ) + """ # noqa: D205 + + hyperparams: LGBMLinearHyperParams = LGBMLinearHyperParams() + + # General Parameters + device: str = Field( + default="cpu", + description="Device for LgbLinear computation. Options: 'cpu', 'cuda', 'cuda:', 'gpu'", + ) + n_jobs: int = Field( + default=1, + description="Number of parallel threads for tree construction. -1 uses all available cores.", + ) + verbosity: Literal[-1, 0, 1, 2, 3] = Field( + default=-1, description="Verbosity level. 0=silent, 1=warning, 2=info, 3=debug" + ) + + random_state: int | None = Field( + default=None, + alias="seed", + description="Random seed for reproducibility. Controls tree structure randomness.", + ) + + early_stopping_rounds: int | None = Field( + default=None, + description="Training will stop if performance doesn't improve for this many rounds. Requires validation data.", + ) + + def forecaster_from_config(self) -> "LGBMLinearForecaster": + """Create a LGBMLinearForecaster instance from this configuration. + + Returns: + Forecaster instance associated with this configuration. + """ + return LGBMLinearForecaster(config=self) + + +MODEL_CODE_VERSION = 1 + + +class LGBMLinearForecaster(Forecaster, ExplainableForecaster): + """LgbLinear-based forecaster for probabilistic energy forecasting. + + Implements gradient boosting trees using LgbLinear for multi-quantile forecasting. + Optimized for time series prediction with specialized loss functions and + comprehensive hyperparameter control suitable for production energy forecasting. + + The forecaster uses a multi-output strategy where each quantile is predicted + by separate trees within the same boosting ensemble. This approach provides + well-calibrated uncertainty estimates while maintaining computational efficiency. + + Invariants: + - fit() must be called before predict() to train the model + - Configuration quantiles determine the number of prediction outputs + - Model state is preserved across predict() calls after fitting + - Input features must match training data structure during prediction + + Example: + Basic forecasting workflow: + + >>> from datetime import timedelta + >>> from openstef_core.types import LeadTime, Quantile + >>> config = LGBMLinearForecasterConfig( + ... quantiles=[Quantile(0.1), Quantile(0.5), Quantile(0.9)], + ... horizons=[LeadTime(timedelta(hours=1))], + ... hyperparams=LGBMLinearHyperParams(n_estimators=100, max_depth=6) + ... ) + >>> forecaster = LGBMLinearForecaster(config) + >>> # forecaster.fit(training_data) + >>> # predictions = forecaster.predict(test_data) + + Note: + LgbLinear dependency is optional and must be installed separately. + The model automatically handles multi-quantile output and uses + magnitude-weighted pinball loss by default for better forecasting performance. + + See Also: + LGBMLinearHyperParams: Detailed hyperparameter configuration options. + HorizonForecaster: Base interface for all forecasting models. + GBLinearForecaster: Alternative linear model using LgbLinear. + """ + + Config = LGBMLinearForecasterConfig + HyperParams = LGBMLinearHyperParams + + _config: LGBMLinearForecasterConfig + + def __init__(self, config: LGBMLinearForecasterConfig) -> None: + """Initialize LgbLinear forecaster with configuration. + + Creates an untrained LgbLinear regressor with the specified configuration. + The underlying LgbLinear model is configured for multi-output quantile + regression using the provided hyperparameters and execution settings. + + Args: + config: Complete configuration including hyperparameters, quantiles, + and execution settings for the LgbLinear model. + """ + self._config = config + + lgbmlinear_params = { + "linear_tree": True, + "objective": "quantile", + "random_state": config.random_state, + "early_stopping_rounds": config.early_stopping_rounds, + "verbosity": config.verbosity, + "n_jobs": config.n_jobs, + **config.hyperparams.model_dump(), + } + + self._lgbmlinear_model: MultiQuantileRegressor = MultiQuantileRegressor( + base_learner=LGBMRegressor, # type: ignore + quantile_param="alpha", + hyperparams=lgbmlinear_params, + quantiles=[float(q) for q in config.quantiles], + ) + + @property + @override + def config(self) -> ForecasterConfig: + return self._config + + @property + @override + def hyperparams(self) -> LGBMLinearHyperParams: + return self._config.hyperparams + + @property + @override + def is_fitted(self) -> bool: + return self._lgbmlinear_model.is_fitted + + @staticmethod + def _prepare_fit_input(data: ForecastInputDataset) -> tuple[pd.DataFrame, np.ndarray, pd.Series]: + input_data: pd.DataFrame = data.input_data() + target: np.ndarray = np.asarray(data.target_series.values) + sample_weight: pd.Series = data.sample_weight_series + + return input_data, target, sample_weight + + @override + def fit(self, data: ForecastInputDataset, data_val: ForecastInputDataset | None = None) -> None: + # Prepare training data + input_data, target, sample_weight = self._prepare_fit_input(data) + + # Evaluation sets + eval_set = [(input_data, target)] + sample_weight_eval_set = [sample_weight] + + if data_val is not None: + input_data_val, target_val, sample_weight_val = self._prepare_fit_input(data_val) + eval_set.append((input_data_val, target_val)) + sample_weight_eval_set.append(sample_weight_val) + + self._lgbmlinear_model.fit( + X=input_data, + y=target, + feature_name=input_data.columns.tolist(), + sample_weight=sample_weight, + eval_set=eval_set, + eval_sample_weight=sample_weight_eval_set, + ) + + @override + def predict(self, data: ForecastInputDataset) -> ForecastDataset: + if not self.is_fitted: + raise NotFittedError(self.__class__.__name__) + + input_data: pd.DataFrame = data.input_data(start=data.forecast_start) + prediction: npt.NDArray[np.floating] = self._lgbmlinear_model.predict(X=input_data) + + return ForecastDataset( + data=pd.DataFrame( + data=prediction, + index=input_data.index, + columns=[quantile.format() for quantile in self.config.quantiles], + ), + sample_interval=data.sample_interval, + ) + + @override + def predict_contributions(self, data: ForecastInputDataset, *, scale: bool) -> pd.DataFrame: + """Get feature contributions for each prediction. + + Args: + data: Input dataset for which to compute feature contributions. + scale: If True, scale contributions to sum to 1.0 per quantile. + + Returns: + DataFrame with contributions per feature. + """ + raise NotImplementedError("predict_contributions is not yet implemented for LGBMLinearForecaster") + + @property + @override + def feature_importances(self) -> pd.DataFrame: + models = self._lgbmlinear_model._models # noqa: SLF001 + weights_df = pd.DataFrame( + [models[i].feature_importances_ for i in range(len(models))], # type: ignore + index=[quantile.format() for quantile in self.config.quantiles], + columns=self._lgbmlinear_model.model_feature_names if self._lgbmlinear_model.has_feature_names else None, + ).transpose() + + weights_df.index.name = "feature_name" + weights_df.columns.name = "quantiles" + + weights_abs = weights_df.abs() + total = weights_abs.sum(axis=0).replace(to_replace=0, value=1.0) # pyright: ignore[reportUnknownMemberType] + + return weights_abs / total + + +__all__ = ["LGBMLinearForecaster", "LGBMLinearForecasterConfig", "LGBMLinearHyperParams"] diff --git a/packages/openstef-models/src/openstef_models/models/forecasting/xgboost_forecaster.py b/packages/openstef-models/src/openstef_models/models/forecasting/xgboost_forecaster.py index 7c0576f84..c5415e2d6 100644 --- a/packages/openstef-models/src/openstef_models/models/forecasting/xgboost_forecaster.py +++ b/packages/openstef-models/src/openstef_models/models/forecasting/xgboost_forecaster.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 @@ -169,6 +169,15 @@ class XGBoostHyperParams(HyperParams): description="Whether to apply standard scaling to the target variable before training. Improves convergence.", ) + @classmethod + def forecaster_class(cls) -> "type[XGBoostForecaster]": + """Get the forecaster class for these hyperparams. + + Returns: + Forecaster class associated with this configuration. + """ + return XGBoostForecaster + class XGBoostForecasterConfig(ForecasterConfig): """Configuration for XGBoost-based forecasting models. @@ -205,6 +214,14 @@ class XGBoostForecasterConfig(ForecasterConfig): default=1, description="Verbosity level. 0=silent, 1=warning, 2=info, 3=debug" ) + def forecaster_from_config(self) -> "XGBoostForecaster": + """Create a XGBoost forecaster instance from this configuration. + + Returns: + Forecaster instance associated with this configuration. + """ + return XGBoostForecaster(config=self) + MODEL_CODE_VERSION = 1 @@ -403,6 +420,48 @@ def predict(self, data: ForecastInputDataset) -> ForecastDataset: sample_interval=data.sample_interval, ) + def predict_contributions(self, data: ForecastInputDataset, *, scale: bool) -> pd.DataFrame: + """Get feature contributions for each prediction. + + Args: + data: Input dataset for which to compute feature contributions. + scale: If True, scale contributions to sum to 1.0 per quantile. + + Returns: + DataFrame with contributions per feature. + """ + # Get input features for prediction + input_data: pd.DataFrame = data.input_data(start=data.forecast_start) + xgb_input: xgb.DMatrix = xgb.DMatrix(data=input_data) + + # Generate predictions + booster = self._xgboost_model.get_booster() + predictions_array: np.ndarray = booster.predict(xgb_input, pred_contribs=True, strict_shape=True)[:, :, :-1] + + # Remove last column + contribs = predictions_array / np.sum(predictions_array, axis=-1, keepdims=True) + + # Flatten to 2D array, name columns accordingly + contribs = contribs.reshape(contribs.shape[0], -1) + + df = pd.DataFrame( + data=contribs, + index=input_data.index, + columns=[ + f"{feature}_{quantile.format()}" for feature in input_data.columns for quantile in self.config.quantiles + ], + ) + + if scale: + # Scale contributions so that they sum to 1.0 per quantile and are positive + for q in self.config.quantiles: + quantile_cols = [col for col in df.columns if col.endswith(f"_{q.format()}")] + row_sums = df[quantile_cols].abs().sum(axis=1) + df[quantile_cols] = df[quantile_cols].abs().div(row_sums, axis=0) + + # Construct DataFrame with appropriate quantile columns + return df + @property @override def feature_importances(self) -> pd.DataFrame: diff --git a/packages/openstef-models/src/openstef_models/models/forecasting_model.py b/packages/openstef-models/src/openstef_models/models/forecasting_model.py index 9d9e47498..f2de3c4b3 100644 --- a/packages/openstef-models/src/openstef_models/models/forecasting_model.py +++ b/packages/openstef-models/src/openstef_models/models/forecasting_model.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/src/openstef_models/presets/__init__.py b/packages/openstef-models/src/openstef_models/presets/__init__.py index b0a0929ca..0615a0ea4 100644 --- a/packages/openstef-models/src/openstef_models/presets/__init__.py +++ b/packages/openstef-models/src/openstef_models/presets/__init__.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/src/openstef_models/presets/forecasting_workflow.py b/packages/openstef-models/src/openstef_models/presets/forecasting_workflow.py index ed2a819d4..b5e8efdce 100644 --- a/packages/openstef-models/src/openstef_models/presets/forecasting_workflow.py +++ b/packages/openstef-models/src/openstef_models/presets/forecasting_workflow.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 @@ -25,15 +25,26 @@ from openstef_core.base_model import BaseConfig from openstef_core.mixins import TransformPipeline from openstef_core.types import LeadTime, Q, Quantile, QuantileOrGlobal +from openstef_meta.models.forecasting.residual_forecaster import ResidualForecaster from openstef_models.integrations.mlflow import MLFlowStorage, MLFlowStorageCallback from openstef_models.mixins import ModelIdentifier from openstef_models.models import ForecastingModel from openstef_models.models.forecasting.flatliner_forecaster import FlatlinerForecaster from openstef_models.models.forecasting.gblinear_forecaster import GBLinearForecaster +from openstef_models.models.forecasting.lgbm_forecaster import LGBMForecaster +from openstef_models.models.forecasting.lgbmlinear_forecaster import LGBMLinearForecaster from openstef_models.models.forecasting.xgboost_forecaster import XGBoostForecaster from openstef_models.transforms.energy_domain import WindPowerFeatureAdder -from openstef_models.transforms.general import Clipper, EmptyFeatureRemover, Imputer, NaNDropper, SampleWeighter, Scaler -from openstef_models.transforms.postprocessing import QuantileSorter +from openstef_models.transforms.general import ( + Clipper, + EmptyFeatureRemover, + Imputer, + NaNDropper, + SampleWeighter, + Scaler, + Selector, +) +from openstef_models.transforms.postprocessing import ConfidenceIntervalApplicator, QuantileSorter from openstef_models.transforms.time_domain import ( CyclicFeaturesAdder, DatetimeFeaturesAdder, @@ -50,13 +61,19 @@ ) from openstef_models.utils.data_split import DataSplitter from openstef_models.utils.feature_selection import Exclude, FeatureSelection, Include -from openstef_models.workflows.custom_forecasting_workflow import CustomForecastingWorkflow, ForecastingCallback +from openstef_models.workflows.custom_forecasting_workflow import ( + CustomForecastingWorkflow, + ForecastingCallback, +) class LocationConfig(BaseConfig): """Configuration for location information in forecasting workflows.""" - name: str = Field(default="test_location", description="Name of the forecasting location or workflow.") + name: str = Field( + default="test_location", + description="Name of the forecasting location or workflow.", + ) description: str = Field(default="", description="Description of the forecasting workflow.") coordinate: Coordinate = Field( default=Coordinate( @@ -66,7 +83,8 @@ class LocationConfig(BaseConfig): description="Geographic coordinate of the location.", ) country_code: CountryAlpha2 = Field( - default=CountryAlpha2("NL"), description="Country code for holiday feature generation." + default=CountryAlpha2("NL"), + description="Country code for holiday feature generation.", ) @property @@ -88,45 +106,74 @@ class ForecastingWorkflowConfig(BaseConfig): # PredictionJob """ model_id: ModelIdentifier = Field(description="Unique identifier for the forecasting model.") + run_name: str | None = Field(default=None, description="Optional name for this workflow run.") # Model configuration - model: Literal["xgboost", "gblinear", "flatliner"] = Field( + model: Literal["xgboost", "gblinear", "flatliner", "residual", "lgbm", "lgbmlinear"] = Field( description="Type of forecasting model to use." ) # TODO(#652): Implement median forecaster quantiles: list[Quantile] = Field( - default=[Q(0.5)], description="List of quantiles to predict for probabilistic forecasting." + default=[Q(0.5)], + description="List of quantiles to predict for probabilistic forecasting.", ) sample_interval: timedelta = Field( - default=timedelta(minutes=15), description="Time interval between consecutive data samples." + default=timedelta(minutes=15), + description="Time interval between consecutive data samples.", ) horizons: list[LeadTime] = Field( - default=[LeadTime.from_string("PT48H")], description="List of forecast horizons to predict." + default=[LeadTime.from_string("PT48H")], + description="List of forecast horizons to predict.", ) xgboost_hyperparams: XGBoostForecaster.HyperParams = Field( - default=XGBoostForecaster.HyperParams(), description="Hyperparameters for XGBoost forecaster." + default=XGBoostForecaster.HyperParams(), + description="Hyperparameters for XGBoost forecaster.", ) gblinear_hyperparams: GBLinearForecaster.HyperParams = Field( - default=GBLinearForecaster.HyperParams(), description="Hyperparameters for GBLinear forecaster." + default=GBLinearForecaster.HyperParams(), + description="Hyperparameters for GBLinear forecaster.", + ) + + lgbm_hyperparams: LGBMForecaster.HyperParams = Field( + default=LGBMForecaster.HyperParams(), + description="Hyperparameters for LightGBM forecaster.", + ) + + lgbmlinear_hyperparams: LGBMLinearForecaster.HyperParams = Field( + default=LGBMLinearForecaster.HyperParams(), + description="Hyperparameters for LightGBM forecaster.", + ) + + residual_hyperparams: ResidualForecaster.HyperParams = Field( + default=ResidualForecaster.HyperParams(), + description="Hyperparameters for Residual forecaster.", ) location: LocationConfig = Field( - default=LocationConfig(), description="Location information for the forecasting workflow." + default=LocationConfig(), + description="Location information for the forecasting workflow.", ) # Data properties target_column: str = Field(default="load", description="Name of the target variable column in datasets.") energy_price_column: str = Field( - default="day_ahead_electricity_price", description="Name of the energy price column in datasets." + default="day_ahead_electricity_price", + description="Name of the energy price column in datasets.", ) radiation_column: str = Field(default="radiation", description="Name of the radiation column in datasets.") wind_speed_column: str = Field(default="windspeed", description="Name of the wind speed column in datasets.") pressure_column: str = Field(default="pressure", description="Name of the pressure column in datasets.") temperature_column: str = Field(default="temperature", description="Name of the temperature column in datasets.") relative_humidity_column: str = Field( - default="relative_humidity", description="Name of the relative humidity column in datasets." + default="relative_humidity", + description="Name of the relative humidity column in datasets.", ) + selected_features: FeatureSelection = Field( + default=FeatureSelection.ALL, + description="Feature selection for which features to include/exclude.", + ) + predict_history: timedelta = Field( default=timedelta(days=14), description="Amount of historical data available at prediction time.", @@ -143,7 +190,8 @@ class ForecastingWorkflowConfig(BaseConfig): # PredictionJob # Feature engineering and validation completeness_threshold: float = Field( - default=0.5, description="Minimum fraction of data that should be available for making a regular forecast." + default=0.5, + description="Minimum fraction of data that should be available for making a regular forecast.", ) flatliner_threshold: timedelta = Field( default=timedelta(hours=24), @@ -153,6 +201,12 @@ class ForecastingWorkflowConfig(BaseConfig): # PredictionJob default=False, description="If True, flatliners are also detected on non-zero values (median of the load).", ) + predict_nonzero_flatliner: bool = Field( + default=False, + description="If True, predict the median of load measurements instead of zero (only for flatliner model).", + ) + + # Feature engineering rolling_aggregate_features: list[AggregationFunction] = Field( default=[], description="If not None, rolling aggregate(s) of load will be used as features in the model.", @@ -167,7 +221,9 @@ class ForecastingWorkflowConfig(BaseConfig): # PredictionJob "Values are normalized relative to this percentile before weighting.", ) sample_weight_exponent: float = Field( - default_factory=lambda data: 1.0 if data.get("model") == "gblinear" else 0.0, + default_factory=lambda data: 1.0 + if data.get("model") in {"gblinear", "lgbmlinear", "lgbm", "learned_weights", "stacking", "residual", "xgboost"} + else 0.0, description="Exponent applied to scale the sample weights. " "0=uniform weights, 1=linear scaling, >1=stronger emphasis on high values. " "Note: Defaults to 1.0 for gblinear congestion models.", @@ -197,16 +253,22 @@ class ForecastingWorkflowConfig(BaseConfig): # PredictionJob # Callbacks mlflow_storage: MLFlowStorage | None = Field( - default_factory=MLFlowStorage, description="Configuration for MLflow experiment tracking and model storage." + default_factory=MLFlowStorage, + description="Configuration for MLflow experiment tracking and model storage.", ) - model_reuse_enable: bool = Field(default=True, description="Whether to enable reuse of previously trained models.") + model_reuse_enable: bool = Field( + default=True, + description="Whether to enable reuse of previously trained models.", + ) model_reuse_max_age: timedelta = Field( - default=timedelta(days=7), description="Maximum age of a model to be considered for reuse." + default=timedelta(days=7), + description="Maximum age of a model to be considered for reuse.", ) model_selection_enable: bool = Field( - default=True, description="Whether to enable automatic model selection based on performance." + default=True, + description="Whether to enable automatic model selection based on performance.", ) model_selection_metric: tuple[QuantileOrGlobal, str, MetricDirection] = Field( default=(Q(0.5), "R2", "higher_is_better"), @@ -218,17 +280,23 @@ class ForecastingWorkflowConfig(BaseConfig): # PredictionJob ) verbosity: Literal[0, 1, 2, 3, True] = Field( - default=1, description="Verbosity level. 0=silent, 1=warning, 2=info, 3=debug" + default=0, description="Verbosity level. 0=silent, 1=warning, 2=info, 3=debug" ) # Metadata tags: dict[str, str] = Field( default_factory=dict, - description="Optional metadata tags for the model.", + description="Optional metadata tags for the model run.", + ) + experiment_tags: dict[str, str] = Field( + default_factory=dict, + description="Optional metadata tags for experiment tracking.", ) -def create_forecasting_workflow(config: ForecastingWorkflowConfig) -> CustomForecastingWorkflow: +def create_forecasting_workflow( + config: ForecastingWorkflowConfig, +) -> CustomForecastingWorkflow: """Create a forecasting workflow from configuration. Builds a complete forecasting pipeline including preprocessing, forecaster, and postprocessing @@ -244,12 +312,13 @@ def create_forecasting_workflow(config: ForecastingWorkflowConfig) -> CustomFore ValueError: If an unsupported model type is specified. """ checks = [ + Selector(selection=config.selected_features), InputConsistencyChecker(), FlatlineChecker( load_column=config.target_column, flatliner_threshold=config.flatliner_threshold, detect_non_zero_flatliner=config.detect_non_zero_flatliner, - error_on_flatliner=True, + error_on_flatliner=False, ), CompletenessChecker(completeness_threshold=config.completeness_threshold), ] @@ -257,9 +326,12 @@ def create_forecasting_workflow(config: ForecastingWorkflowConfig) -> CustomFore LagsAdder( history_available=config.predict_history, horizons=config.horizons, - add_trivial_lags=config.model != "gblinear", # GBLinear uses only 7day lag. + add_trivial_lags=config.model + not in {"gblinear", "residual", "stacking", "learned_weights"}, # GBLinear uses only 7day lag. target_column=config.target_column, - custom_lags=[timedelta(days=7)] if config.model == "gblinear" else [], + custom_lags=[timedelta(days=7)] + if config.model in {"gblinear", "residual", "stacking", "learned_weights"} + else [], ), WindPowerFeatureAdder( windspeed_reference_column=config.wind_speed_column, @@ -311,8 +383,45 @@ def create_forecasting_workflow(config: ForecastingWorkflowConfig) -> CustomFore verbosity=config.verbosity, ) ) + postprocessing = [ + QuantileSorter(), + ConfidenceIntervalApplicator( + quantiles=config.quantiles, + add_quantiles_from_std=False, + ), + ] + elif config.model == "lgbmlinear": + preprocessing = [ + *checks, + *feature_adders, + HolidayFeatureAdder(country_code=config.location.country_code), + DatetimeFeaturesAdder(onehot_encode=False), + *feature_standardizers, + ] + forecaster = LGBMLinearForecaster( + config=LGBMLinearForecaster.Config( + quantiles=config.quantiles, + horizons=config.horizons, + hyperparams=config.lgbmlinear_hyperparams, + ) + ) + postprocessing = [QuantileSorter()] + elif config.model == "lgbm": + preprocessing = [ + *checks, + *feature_adders, + HolidayFeatureAdder(country_code=config.location.country_code), + DatetimeFeaturesAdder(onehot_encode=False), + *feature_standardizers, + ] + forecaster = LGBMForecaster( + config=LGBMForecaster.Config( + quantiles=config.quantiles, + horizons=config.horizons, + hyperparams=config.lgbm_hyperparams, + ) + ) postprocessing = [QuantileSorter()] - elif config.model == "gblinear": preprocessing = [ *checks, @@ -335,16 +444,49 @@ def create_forecasting_workflow(config: ForecastingWorkflowConfig) -> CustomFore verbosity=config.verbosity, ), ) - postprocessing = [] + postprocessing = [ + QuantileSorter(), + ConfidenceIntervalApplicator( + quantiles=config.quantiles, + add_quantiles_from_std=False, + ), + ] elif config.model == "flatliner": preprocessing = [] forecaster = FlatlinerForecaster( config=FlatlinerForecaster.Config( quantiles=[Q(0.5)], horizons=config.horizons, + predict_median=config.predict_nonzero_flatliner, ) ) - postprocessing = [] + postprocessing = [ + QuantileSorter(), + ConfidenceIntervalApplicator(quantiles=config.quantiles), + ] + + elif config.model == "residual": + preprocessing = [ + *checks, + *feature_adders, + *feature_standardizers, + Imputer( + selection=Exclude(config.target_column), + imputation_strategy="mean", + fill_future_values=Include(config.energy_price_column), + ), + NaNDropper( + selection=Exclude(config.target_column), + ), + ] + forecaster = ResidualForecaster( + config=ResidualForecaster.Config( + quantiles=config.quantiles, + horizons=config.horizons, + hyperparams=config.residual_hyperparams, + ) + ) + postprocessing = [QuantileSorter()] else: msg = f"Unsupported model type: {config.model}" raise ValueError(msg) @@ -382,5 +524,7 @@ def create_forecasting_workflow(config: ForecastingWorkflowConfig) -> CustomFore tags=tags, ), model_id=config.model_id, + run_name=config.run_name, callbacks=callbacks, + experiment_tags=config.experiment_tags, ) diff --git a/packages/openstef-models/src/openstef_models/transforms/__init__.py b/packages/openstef-models/src/openstef_models/transforms/__init__.py index 427ca37d6..fde29d25e 100644 --- a/packages/openstef-models/src/openstef_models/transforms/__init__.py +++ b/packages/openstef-models/src/openstef_models/transforms/__init__.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/src/openstef_models/transforms/energy_domain/__init__.py b/packages/openstef-models/src/openstef_models/transforms/energy_domain/__init__.py index 428f2ec02..333f524f3 100644 --- a/packages/openstef-models/src/openstef_models/transforms/energy_domain/__init__.py +++ b/packages/openstef-models/src/openstef_models/transforms/energy_domain/__init__.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/src/openstef_models/transforms/energy_domain/wind_power_feature_adder.py b/packages/openstef-models/src/openstef_models/transforms/energy_domain/wind_power_feature_adder.py index d89f0050c..4d9e6175a 100644 --- a/packages/openstef-models/src/openstef_models/transforms/energy_domain/wind_power_feature_adder.py +++ b/packages/openstef-models/src/openstef_models/transforms/energy_domain/wind_power_feature_adder.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/src/openstef_models/transforms/general/__init__.py b/packages/openstef-models/src/openstef_models/transforms/general/__init__.py index 79e59f58b..e601043c1 100644 --- a/packages/openstef-models/src/openstef_models/transforms/general/__init__.py +++ b/packages/openstef-models/src/openstef_models/transforms/general/__init__.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 """General feature transforms for time series data. @@ -13,17 +13,21 @@ from openstef_models.transforms.general.empty_feature_remover import ( EmptyFeatureRemover, ) +from openstef_models.transforms.general.flagger import Flagger from openstef_models.transforms.general.imputer import Imputer from openstef_models.transforms.general.nan_dropper import NaNDropper from openstef_models.transforms.general.sample_weighter import SampleWeighter from openstef_models.transforms.general.scaler import Scaler +from openstef_models.transforms.general.selector import Selector __all__ = [ "Clipper", "DimensionalityReducer", "EmptyFeatureRemover", + "Flagger", "Imputer", "NaNDropper", "SampleWeighter", "Scaler", + "Selector", ] diff --git a/packages/openstef-models/src/openstef_models/transforms/general/clipper.py b/packages/openstef-models/src/openstef_models/transforms/general/clipper.py index eb3a21446..a148d5c58 100644 --- a/packages/openstef-models/src/openstef_models/transforms/general/clipper.py +++ b/packages/openstef-models/src/openstef_models/transforms/general/clipper.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/src/openstef_models/transforms/general/dimensionality_reducer.py b/packages/openstef-models/src/openstef_models/transforms/general/dimensionality_reducer.py index 32764151d..2bca878b2 100644 --- a/packages/openstef-models/src/openstef_models/transforms/general/dimensionality_reducer.py +++ b/packages/openstef-models/src/openstef_models/transforms/general/dimensionality_reducer.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/src/openstef_models/transforms/general/empty_feature_remover.py b/packages/openstef-models/src/openstef_models/transforms/general/empty_feature_remover.py index 3147427ff..c0914e9e1 100644 --- a/packages/openstef-models/src/openstef_models/transforms/general/empty_feature_remover.py +++ b/packages/openstef-models/src/openstef_models/transforms/general/empty_feature_remover.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/src/openstef_models/transforms/general/flagger.py b/packages/openstef-models/src/openstef_models/transforms/general/flagger.py new file mode 100644 index 000000000..5c3675148 --- /dev/null +++ b/packages/openstef-models/src/openstef_models/transforms/general/flagger.py @@ -0,0 +1,97 @@ +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# +# SPDX-License-Identifier: MPL-2.0 + +"""Transform for flagging feature values inside or outside observed training ranges. + +This module provides functionality to clip feature values to their observed +minimum and maximum ranges during training. It is useful to flag data drift and +can be used to inform forecast combiners which models might perform better. +""" + +from typing import override + +import pandas as pd +from pydantic import Field, PrivateAttr + +from openstef_core.base_model import BaseConfig +from openstef_core.datasets import TimeSeriesDataset +from openstef_core.exceptions import NotFittedError +from openstef_core.transforms import TimeSeriesTransform +from openstef_models.utils.feature_selection import FeatureSelection + + +class Flagger(BaseConfig, TimeSeriesTransform): + """Transform that flags specified features to their observed min and max values. + + This transform flags the peaks for the metalearner to know when to expect outliers and + extrapolate from its training set. + + + Example: + >>> import pandas as pd + >>> from datetime import timedelta + >>> from openstef_core.datasets import TimeSeriesDataset + >>> from openstef_models.transforms.general import Flagger + >>> from openstef_models.utils.feature_selection import FeatureSelection + >>> # Create sample training dataset + >>> training_data = pd.DataFrame({ + ... 'load': [100, 90, 110], + ... 'temperature': [19, 20, 21] + ... }, index=pd.date_range('2025-01-01', periods=3, freq='1h')) + >>> training_dataset = TimeSeriesDataset(training_data, timedelta(hours=1)) + >>> test_data = pd.DataFrame({ + ... 'load': [90, 140, 100], + ... 'temperature': [18, 20, 22] + ... }, index=pd.date_range('2025-01-06', periods=3, + ... freq='1h')) + >>> test_dataset = TimeSeriesDataset(test_data, timedelta(hours=1)) + >>> # Initialize and apply transform + >>> flagger = Flagger(selection=FeatureSelection(include=['load', 'temperature'])) + >>> flagger.fit(training_dataset) + >>> transformed_dataset = flagger.transform(test_dataset) + >>> transformed_dataset.data['load'].tolist() + [0, 0, 1] + >>> transformed_dataset.data['temperature'].tolist() + [0, 1, 0] + + """ + + selection: FeatureSelection = Field(default=FeatureSelection.ALL, description="Features to flag.") + + _feature_mins: pd.Series = PrivateAttr(default_factory=pd.Series) + _feature_maxs: pd.Series = PrivateAttr(default_factory=pd.Series) + _is_fitted: bool = PrivateAttr(default=False) + + @property + @override + def is_fitted(self) -> bool: + return self._is_fitted + + @override + def fit(self, data: TimeSeriesDataset) -> None: + features = self.selection.resolve(data.feature_names) + self._feature_mins = data.data.reindex(features, axis=1).min() + self._feature_maxs = data.data.reindex(features, axis=1).max() + self._is_fitted = True + + @override + def transform(self, data: TimeSeriesDataset) -> TimeSeriesDataset: + if not self._is_fitted: + raise NotFittedError(self.__class__.__name__) + + features = self.selection.resolve(data.feature_names) + transformed_data = data.data.copy(deep=False).loc[:, features] + + # compute min & max of the features + min_aligned = self._feature_mins.reindex(features) + max_aligned = self._feature_maxs.reindex(features) + + outside = (transformed_data[features] <= min_aligned) | (transformed_data[features] >= max_aligned) + transformed_data = (~outside).astype(int) + + return TimeSeriesDataset(data=transformed_data, sample_interval=data.sample_interval) + + @override + def features_added(self) -> list[str]: + return [] diff --git a/packages/openstef-models/src/openstef_models/transforms/general/imputer.py b/packages/openstef-models/src/openstef_models/transforms/general/imputer.py index 2a08dcf05..addc8df54 100644 --- a/packages/openstef-models/src/openstef_models/transforms/general/imputer.py +++ b/packages/openstef-models/src/openstef_models/transforms/general/imputer.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/src/openstef_models/transforms/general/nan_dropper.py b/packages/openstef-models/src/openstef_models/transforms/general/nan_dropper.py index 0d8cef10c..e9144bba9 100644 --- a/packages/openstef-models/src/openstef_models/transforms/general/nan_dropper.py +++ b/packages/openstef-models/src/openstef_models/transforms/general/nan_dropper.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/src/openstef_models/transforms/general/sample_weighter.py b/packages/openstef-models/src/openstef_models/transforms/general/sample_weighter.py index 4dbfe1ea2..f820008d3 100644 --- a/packages/openstef-models/src/openstef_models/transforms/general/sample_weighter.py +++ b/packages/openstef-models/src/openstef_models/transforms/general/sample_weighter.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/src/openstef_models/transforms/general/scaler.py b/packages/openstef-models/src/openstef_models/transforms/general/scaler.py index a474ed763..fbbb4c215 100644 --- a/packages/openstef-models/src/openstef_models/transforms/general/scaler.py +++ b/packages/openstef-models/src/openstef_models/transforms/general/scaler.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/src/openstef_models/transforms/general/selector.py b/packages/openstef-models/src/openstef_models/transforms/general/selector.py new file mode 100644 index 000000000..00afbd68a --- /dev/null +++ b/packages/openstef-models/src/openstef_models/transforms/general/selector.py @@ -0,0 +1,85 @@ +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# +# SPDX-License-Identifier: MPL-2.0 + +"""Transform for dropping for dropping features from dataset based on FeatureSelection. + +This transform allows selecting a subset of features from a TimeSeriesDataset based on a specified +FeatureSelection strategy. It can be used to exclude certain features before model training +or inference. +""" + +from typing import override + +from pydantic import Field, PrivateAttr + +from openstef_core.base_model import BaseConfig +from openstef_core.datasets import TimeSeriesDataset +from openstef_core.datasets.validated_datasets import ForecastInputDataset +from openstef_core.transforms import TimeSeriesTransform +from openstef_models.utils.feature_selection import FeatureSelection + + +class Selector(BaseConfig, TimeSeriesTransform): + """Selects features based on FeatureSelection. + + Example: + >>> import pandas as pd + >>> from datetime import timedelta + >>> from openstef_core.datasets import TimeSeriesDataset + >>> from openstef_models.transforms.general import Selector + >>> from openstef_models.utils.feature_selection import FeatureSelection + >>> + >>> # Create sample dataset + >>> data = pd.DataFrame( + ... { + ... "load": [100.0, 110.0, 120.0], + ... "temperature": [20.0, 22.0, 23.0], + ... "humidity": [60.0, 65.0, 70.0], + ... }, + ... index=pd.date_range("2025-01-01", periods=3, freq="1h"), + ... ) + >>> dataset = TimeSeriesDataset(data, timedelta(hours=1)) + >>> + >>> # Select specific features + >>> selector = Selector(selection=FeatureSelection(include={'load', 'temperature'})) + >>> transformed = selector.transform(dataset) + >>> transformed.feature_names + ['load', 'temperature'] + """ + + selection: FeatureSelection = Field( + default=FeatureSelection.ALL, + description="Feature selection for efficient model specific preprocessing.", + ) + _is_fitted: bool = PrivateAttr(default=False) + + @property + @override + def is_fitted(self) -> bool: + return self._is_fitted + + @override + def fit(self, data: TimeSeriesDataset) -> None: + if ( + isinstance(data, ForecastInputDataset) + and self.selection.include is not None + and (data.target_column not in self.selection.include) + ): + self.selection.include.add(data.target_column) + + self._is_fitted = True + + @override + def transform(self, data: TimeSeriesDataset) -> TimeSeriesDataset: + features = self.selection.resolve(data.feature_names) + + transformed_data = data.data.drop( + columns=[col for col in data.feature_names if col not in features] + ) + + return data.copy_with(data=transformed_data, is_sorted=True) + + @override + def features_added(self) -> list[str]: + return [] diff --git a/packages/openstef-models/src/openstef_models/transforms/postprocessing/__init__.py b/packages/openstef-models/src/openstef_models/transforms/postprocessing/__init__.py index 72e77049f..6d0e75c54 100644 --- a/packages/openstef-models/src/openstef_models/transforms/postprocessing/__init__.py +++ b/packages/openstef-models/src/openstef_models/transforms/postprocessing/__init__.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/src/openstef_models/transforms/postprocessing/confidence_interval_applicator.py b/packages/openstef-models/src/openstef_models/transforms/postprocessing/confidence_interval_applicator.py index 857c448f0..09748428b 100644 --- a/packages/openstef-models/src/openstef_models/transforms/postprocessing/confidence_interval_applicator.py +++ b/packages/openstef-models/src/openstef_models/transforms/postprocessing/confidence_interval_applicator.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 @@ -72,6 +72,11 @@ class ConfidenceIntervalApplicator(BaseModel, Transform[ForecastDataset, Forecas """ quantiles: list[Quantile] | None = Field(default=None) + add_quantiles_from_std: bool = Field( + default=True, + description="If True, adds quantiles based on computed standard deviation. " + "If False, only computes standard deviation without adding quantiles.", + ) _standard_deviation: pd.DataFrame = PrivateAttr(default_factory=pd.DataFrame) _is_fitted: bool = PrivateAttr(default=False) @@ -149,8 +154,15 @@ def transform(self, data: ForecastDataset) -> ForecastDataset: # Compute standard deviation series stdev_series = self._compute_stdev_series(data) + # Add standard deviation column + stdev_column = data.standard_deviation_column + data = data.pipe_pandas(lambda df: df.assign(**{stdev_column: stdev_series})) + # Add quantiles based on standard deviation - return self._add_quantiles_from_stdev(forecast=data, stdev_series=stdev_series, quantiles=self.quantiles) + if self.add_quantiles_from_std: + return self._add_quantiles_from_stdev(forecast=data, stdev_series=stdev_series, quantiles=self.quantiles) + + return data def _calculate_hourly_std(errors: pd.Series) -> pd.Series: diff --git a/packages/openstef-models/src/openstef_models/transforms/postprocessing/quantile_sorter.py b/packages/openstef-models/src/openstef_models/transforms/postprocessing/quantile_sorter.py index 81fac5c30..14f44e2ec 100644 --- a/packages/openstef-models/src/openstef_models/transforms/postprocessing/quantile_sorter.py +++ b/packages/openstef-models/src/openstef_models/transforms/postprocessing/quantile_sorter.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/src/openstef_models/transforms/time_domain/__init__.py b/packages/openstef-models/src/openstef_models/transforms/time_domain/__init__.py index 75f8e1e00..f587914ba 100644 --- a/packages/openstef-models/src/openstef_models/transforms/time_domain/__init__.py +++ b/packages/openstef-models/src/openstef_models/transforms/time_domain/__init__.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/src/openstef_models/transforms/time_domain/cyclic_features_adder.py b/packages/openstef-models/src/openstef_models/transforms/time_domain/cyclic_features_adder.py index 690ba118c..5d3b0b948 100644 --- a/packages/openstef-models/src/openstef_models/transforms/time_domain/cyclic_features_adder.py +++ b/packages/openstef-models/src/openstef_models/transforms/time_domain/cyclic_features_adder.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/src/openstef_models/transforms/time_domain/datetime_features_adder.py b/packages/openstef-models/src/openstef_models/transforms/time_domain/datetime_features_adder.py index e269a6b7d..bc0520cb9 100644 --- a/packages/openstef-models/src/openstef_models/transforms/time_domain/datetime_features_adder.py +++ b/packages/openstef-models/src/openstef_models/transforms/time_domain/datetime_features_adder.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/src/openstef_models/transforms/time_domain/holiday_features_adder.py b/packages/openstef-models/src/openstef_models/transforms/time_domain/holiday_features_adder.py index 7f300de97..59e39f2cc 100644 --- a/packages/openstef-models/src/openstef_models/transforms/time_domain/holiday_features_adder.py +++ b/packages/openstef-models/src/openstef_models/transforms/time_domain/holiday_features_adder.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/src/openstef_models/transforms/time_domain/lags_adder.py b/packages/openstef-models/src/openstef_models/transforms/time_domain/lags_adder.py index 53d2a707f..33c881280 100644 --- a/packages/openstef-models/src/openstef_models/transforms/time_domain/lags_adder.py +++ b/packages/openstef-models/src/openstef_models/transforms/time_domain/lags_adder.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/src/openstef_models/transforms/time_domain/rolling_aggregates_adder.py b/packages/openstef-models/src/openstef_models/transforms/time_domain/rolling_aggregates_adder.py index a4e95a1d8..5c5dc4fde 100644 --- a/packages/openstef-models/src/openstef_models/transforms/time_domain/rolling_aggregates_adder.py +++ b/packages/openstef-models/src/openstef_models/transforms/time_domain/rolling_aggregates_adder.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/src/openstef_models/transforms/time_domain/versioned_lags_adder.py b/packages/openstef-models/src/openstef_models/transforms/time_domain/versioned_lags_adder.py index 7833945df..934f536ad 100644 --- a/packages/openstef-models/src/openstef_models/transforms/time_domain/versioned_lags_adder.py +++ b/packages/openstef-models/src/openstef_models/transforms/time_domain/versioned_lags_adder.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/src/openstef_models/transforms/validation/__init__.py b/packages/openstef-models/src/openstef_models/transforms/validation/__init__.py index c6dfb5151..32b14f592 100644 --- a/packages/openstef-models/src/openstef_models/transforms/validation/__init__.py +++ b/packages/openstef-models/src/openstef_models/transforms/validation/__init__.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/src/openstef_models/transforms/validation/completeness_checker.py b/packages/openstef-models/src/openstef_models/transforms/validation/completeness_checker.py index ff563452f..79ae2b3e4 100644 --- a/packages/openstef-models/src/openstef_models/transforms/validation/completeness_checker.py +++ b/packages/openstef-models/src/openstef_models/transforms/validation/completeness_checker.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/src/openstef_models/transforms/validation/flatline_checker.py b/packages/openstef-models/src/openstef_models/transforms/validation/flatline_checker.py index c3269603b..aba3b0148 100644 --- a/packages/openstef-models/src/openstef_models/transforms/validation/flatline_checker.py +++ b/packages/openstef-models/src/openstef_models/transforms/validation/flatline_checker.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/src/openstef_models/transforms/validation/input_consistency_checker.py b/packages/openstef-models/src/openstef_models/transforms/validation/input_consistency_checker.py index ad4fd07f9..2f16d720b 100644 --- a/packages/openstef-models/src/openstef_models/transforms/validation/input_consistency_checker.py +++ b/packages/openstef-models/src/openstef_models/transforms/validation/input_consistency_checker.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/src/openstef_models/transforms/weather_domain/__init__.py b/packages/openstef-models/src/openstef_models/transforms/weather_domain/__init__.py index f4f1e8fc7..33dd06560 100644 --- a/packages/openstef-models/src/openstef_models/transforms/weather_domain/__init__.py +++ b/packages/openstef-models/src/openstef_models/transforms/weather_domain/__init__.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/src/openstef_models/transforms/weather_domain/atmosphere_derived_features_adder.py b/packages/openstef-models/src/openstef_models/transforms/weather_domain/atmosphere_derived_features_adder.py index c6a03ed53..9b313477d 100644 --- a/packages/openstef-models/src/openstef_models/transforms/weather_domain/atmosphere_derived_features_adder.py +++ b/packages/openstef-models/src/openstef_models/transforms/weather_domain/atmosphere_derived_features_adder.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/src/openstef_models/transforms/weather_domain/daylight_feature_adder.py b/packages/openstef-models/src/openstef_models/transforms/weather_domain/daylight_feature_adder.py index c70ebdac0..af6a79c2b 100644 --- a/packages/openstef-models/src/openstef_models/transforms/weather_domain/daylight_feature_adder.py +++ b/packages/openstef-models/src/openstef_models/transforms/weather_domain/daylight_feature_adder.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/src/openstef_models/transforms/weather_domain/radiation_derived_features_adder.py b/packages/openstef-models/src/openstef_models/transforms/weather_domain/radiation_derived_features_adder.py index 299079ed6..3e242cd70 100644 --- a/packages/openstef-models/src/openstef_models/transforms/weather_domain/radiation_derived_features_adder.py +++ b/packages/openstef-models/src/openstef_models/transforms/weather_domain/radiation_derived_features_adder.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 @@ -26,15 +26,14 @@ class RadiationDerivedFeaturesAdder(BaseConfig, TimeSeriesTransform): """Transform that adds radiation derived features to time series data. - Computes features that are derived from radiation data (in J/m²) based on geographical coordinates + Computes features that are derived from radiation data (in W/m²) based on geographical coordinates (latitude and longitude) and solar position. The features added can include: - - dni: Direct Normal Irradiance (DNI) in kWh/m². - - gti: Global Tilted Irradiance (GTI) in kWh/m² on a tilted surface. + - dni: Direct Normal Irradiance (DNI) in W/m². + - gti: Global Tilted Irradiance (GTI) in W/m² on a tilted surface. Note: - The input radiation data must be in J/m² units. The transform will automatically - convert this to kWh/m² for internal calculations. + The input radiation data must be in W/m² units. Example: >>> import pandas as pd @@ -45,9 +44,9 @@ class RadiationDerivedFeaturesAdder(BaseConfig, TimeSeriesTransform): ... ) >>> from pydantic_extra_types.coordinate import Coordinate, Latitude, Longitude >>> - >>> # Create sample dataset with radiation data in J/m² + >>> # Create sample dataset with radiation data in W/m² >>> data = pd.DataFrame({ - ... 'radiation': [3600000, 7200000, 5400000] # Corresponds to 1, 2, and 1.5 kWh/m² + ... 'radiation': [1000, 2000, 1500] ... }, index=pd.date_range('2025-06-01', periods=3, freq='D', tz='Europe/Amsterdam')) >>> dataset = TimeSeriesDataset(data, sample_interval=timedelta(minutes=15)) >>> @@ -92,7 +91,7 @@ class RadiationDerivedFeaturesAdder(BaseConfig, TimeSeriesTransform): ) radiation_column: str = Field( default="radiation", - description="Name of the column in the dataset containing radiation data in J/m².", + description="Name of the column in the dataset containing radiation data in W/m².", ) _logger: logging.Logger = PrivateAttr(default=logging.getLogger(__name__)) @@ -115,8 +114,8 @@ def transform(self, data: TimeSeriesDataset) -> TimeSeriesDataset: ) return data - # Convert radiation from J/m² to kWh/m² and rename to 'ghi' - ghi = (data.data[self.radiation_column] / 3600).rename("ghi") + # Rename radiation column to 'ghi' + ghi = data.data[self.radiation_column].rename("ghi") location = pvlib.location.Location( latitude=self.coordinate.latitude, diff --git a/packages/openstef-models/src/openstef_models/utils/__init__.py b/packages/openstef-models/src/openstef_models/utils/__init__.py index 9ca0c6544..fe29ec223 100644 --- a/packages/openstef-models/src/openstef_models/utils/__init__.py +++ b/packages/openstef-models/src/openstef_models/utils/__init__.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/src/openstef_models/utils/data_split.py b/packages/openstef-models/src/openstef_models/utils/data_split.py index 908203fda..f27ca83e5 100644 --- a/packages/openstef-models/src/openstef_models/utils/data_split.py +++ b/packages/openstef-models/src/openstef_models/utils/data_split.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/src/openstef_models/utils/evaluation_functions.py b/packages/openstef-models/src/openstef_models/utils/evaluation_functions.py index 7d568af13..d7753ebad 100644 --- a/packages/openstef-models/src/openstef_models/utils/evaluation_functions.py +++ b/packages/openstef-models/src/openstef_models/utils/evaluation_functions.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 """Utility functions for evaluation metrics in forecasting models.""" diff --git a/packages/openstef-models/src/openstef_models/utils/feature_selection.py b/packages/openstef-models/src/openstef_models/utils/feature_selection.py index ae260fa87..c9405822b 100644 --- a/packages/openstef-models/src/openstef_models/utils/feature_selection.py +++ b/packages/openstef-models/src/openstef_models/utils/feature_selection.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 """Feature selection utilities for transforms. @@ -7,6 +7,7 @@ Transforms use this to consistently specify which features to operate on. """ +import re from typing import ClassVar, Self from pydantic import Field @@ -17,29 +18,114 @@ class FeatureSelection(BaseConfig): """Standardized feature selection with include/exclude patterns. - Defines which features a transform should operate on. Features can be - specified by inclusion (whitelist) or exclusion (blacklist), or both. + Supports both exact matching and regex pattern matching for feature selection. + Features can be specified by inclusion (whitelist) or exclusion (blacklist), or both. When both are specified, inclusion is applied first, then exclusion. Use `FeatureSelection.ALL` to select all available features. + + Example: + >>> from openstef_models.utils.feature_selection import ( + ... FeatureSelection, + ... Include, + ... Exclude, + ... ) + >>> + >>> # Select all features + >>> all_features = FeatureSelection.ALL + >>> all_features.resolve(['a', 'b', 'c']) + ['a', 'b', 'c'] + >>> + >>> # Include only specific features (exact match) + >>> include_only = Include('a', 'b') + >>> include_only.resolve(['a', 'b', 'c', 'd']) + ['a', 'b'] + >>> + >>> # Exclude specific features (exact match) + >>> exclude_some = Exclude('b', 'd') + >>> exclude_some.resolve(['a', 'b', 'c', 'd']) + ['a', 'c'] + >>> + >>> # Regex matching + >>> regex_sel = FeatureSelection(include_regex={r'^b_.*'}) + >>> regex_sel.resolve(['b_1', 'b_2', 'c_1']) + ['b_1', 'b_2'] + >>> + >>> # Combine exact and regex + >>> combined = FeatureSelection(include={'a'}, include_regex={r'^b.*'}) + >>> combined.resolve(['a', 'b1', 'b2', 'c']) + ['a', 'b1', 'b2'] """ include: set[str] | None = Field( default=None, - description=("List of feature names to include. Use None to include all features from the input dataset."), + description="Set of exact feature names to include. Use None to include all features.", + frozen=True, + ) + include_regex: set[str] | None = Field( + default=None, + description="Set of regex patterns to include features. Use None to include all features.", frozen=True, ) exclude: set[str] | None = Field( default=None, - description="List of feature names to exclude. Use None to exclude no features.", + description="Set of exact feature names to exclude. Use None to exclude no features.", + frozen=True, + ) + exclude_regex: set[str] | None = Field( + default=None, + description="Set of regex patterns to exclude features. Use None to exclude no features.", frozen=True, ) ALL: ClassVar[Self] NONE: ClassVar[Self] + @staticmethod + def _matches_regex(feature: str, patterns: set[str]) -> bool: + """Check if a feature matches any regex pattern in the set. + + Args: + feature: Feature name to check. + patterns: Set of regex patterns to match against. + + Returns: + True if feature matches any regex pattern. + """ + return any(re.match(pattern, feature) for pattern in patterns) + + def _should_include_feature(self, feature: str) -> bool: + """Check if a feature should be included based on include filters. + + Args: + feature: Feature name to check. + + Returns: + True if feature should be included. + """ + if self.include is None and self.include_regex is None: + return True + exact_match = self.include is not None and feature in self.include + regex_match = self.include_regex is not None and self._matches_regex(feature, self.include_regex) + return exact_match or regex_match + + def _should_exclude_feature(self, feature: str) -> bool: + """Check if a feature should be excluded based on exclude filters. + + Args: + feature: Feature name to check. + + Returns: + True if feature should be excluded. + """ + if self.exclude is None and self.exclude_regex is None: + return False + exact_match = self.exclude is not None and feature in self.exclude + regex_match = self.exclude_regex is not None and self._matches_regex(feature, self.exclude_regex) + return exact_match or regex_match + def resolve(self, features: list[str]) -> list[str]: - """Resolve the final list of features based on include and exclude lists. + """Resolve the final list of features based on include and exclude filters. Args: features: List of all available feature names. @@ -50,8 +136,7 @@ def resolve(self, features: list[str]) -> list[str]: return [ feature for feature in features - if (self.include is None or feature in self.include) - and (self.exclude is None or feature not in self.exclude) + if self._should_include_feature(feature) and not self._should_exclude_feature(feature) ] def combine(self, other: Self | None) -> Self: @@ -66,14 +151,19 @@ def combine(self, other: Self | None) -> Self: if other is None: return self + def _union(a: set[str] | None, b: set[str] | None) -> set[str] | None: + return None if a is None and b is None else (a or set()) | (b or set()) + return self.__class__( - include=((self.include or set()) | (other.include or set())), - exclude=((self.exclude or set()) | (other.exclude or set())), + include=_union(self.include, other.include), + include_regex=_union(self.include_regex, other.include_regex), + exclude=_union(self.exclude, other.exclude), + exclude_regex=_union(self.exclude_regex, other.exclude_regex), ) -FeatureSelection.ALL = FeatureSelection(include=None, exclude=None) -FeatureSelection.NONE = FeatureSelection(include=set(), exclude=None) +FeatureSelection.ALL = FeatureSelection(include=None, include_regex=None, exclude=None, exclude_regex=None) +FeatureSelection.NONE = FeatureSelection(include=set(), include_regex=set(), exclude=None, exclude_regex=None) def Include(*features: str) -> FeatureSelection: # noqa: N802 @@ -85,7 +175,7 @@ def Include(*features: str) -> FeatureSelection: # noqa: N802 Returns: FeatureSelection instance with specified features included. """ - return FeatureSelection(include={*features}, exclude=None) + return FeatureSelection(include={*features}, include_regex=None, exclude=None, exclude_regex=None) def Exclude(*features: str) -> FeatureSelection: # noqa: N802 @@ -97,4 +187,28 @@ def Exclude(*features: str) -> FeatureSelection: # noqa: N802 Returns: FeatureSelection instance with specified features excluded. """ - return FeatureSelection(include=None, exclude={*features}) + return FeatureSelection(include=None, include_regex=None, exclude={*features}, exclude_regex=None) + + +def IncludeRegex(*patterns: str) -> FeatureSelection: # noqa: N802 + """Helper to create a FeatureSelection that includes features matching regex patterns. + + Args: + *patterns: Regex patterns to include. + + Returns: + FeatureSelection instance with specified patterns included. + """ + return FeatureSelection(include=None, include_regex={*patterns}, exclude=None, exclude_regex=None) + + +def ExcludeRegex(*patterns: str) -> FeatureSelection: # noqa: N802 + """Helper to create a FeatureSelection that excludes features matching regex patterns. + + Args: + *patterns: Regex patterns to exclude. + + Returns: + FeatureSelection instance with specified patterns excluded. + """ + return FeatureSelection(include=None, include_regex=None, exclude=None, exclude_regex={*patterns}) diff --git a/packages/openstef-models/src/openstef_models/utils/loss_functions.py b/packages/openstef-models/src/openstef_models/utils/loss_functions.py index 60b8dddfe..294e6694c 100644 --- a/packages/openstef-models/src/openstef_models/utils/loss_functions.py +++ b/packages/openstef-models/src/openstef_models/utils/loss_functions.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/src/openstef_models/utils/multi_quantile_regressor.py b/packages/openstef-models/src/openstef_models/utils/multi_quantile_regressor.py new file mode 100644 index 000000000..b95fbc28c --- /dev/null +++ b/packages/openstef-models/src/openstef_models/utils/multi_quantile_regressor.py @@ -0,0 +1,157 @@ +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# +# SPDX-License-Identifier: MPL-2.0 +"""Adaptor for multi-quantile regression using a base quantile regressor. + +Designed to work with scikit-learn compatible regressors that support quantile regression. +""" + +import logging + +import numpy as np +import numpy.typing as npt +import pandas as pd +from sklearn.base import BaseEstimator, RegressorMixin + +logger = logging.getLogger(__name__) + +ParamType = float | int | str | bool | None + + +class MultiQuantileRegressor(BaseEstimator, RegressorMixin): + """Adaptor for multi-quantile regression using a base quantile regressor. + + This class creates separate instances of a given quantile regressor for each quantile + and manages their training and prediction. + """ + + def __init__( + self, + base_learner: type[BaseEstimator], + quantile_param: str, + quantiles: list[float], + hyperparams: dict[str, ParamType], + ): + """Initialize MultiQuantileRegressor. + + This is an adaptor that allows any quantile-capable regressor to predict multiple quantiles + by instantiating separate models for each quantile. + + Args: + base_learner: A scikit-learn compatible regressor class that supports quantile regression. + quantile_param: The name of the parameter in base_learner that sets the quantile level. + quantiles: List of quantiles to predict (e.g., [0.1, 0.5, 0.9]). + hyperparams: Dictionary of hyperparameters to pass to each estimator instance. + """ + self.quantiles = quantiles + self.hyperparams = hyperparams + self.quantile_param = quantile_param + self.base_learner = base_learner + self.is_fitted = False + self._models = [self._init_model(q) for q in quantiles] + + def _init_model(self, q: float) -> BaseEstimator: + params = self.hyperparams.copy() + params[self.quantile_param] = q + base_learner = self.base_learner(**params) + + if self.quantile_param not in base_learner.get_params(): # type: ignore + msg = f"The base estimator does not support the quantile parameter '{self.quantile_param}'." + raise ValueError(msg) + + return base_learner + + def fit( + self, + X: npt.NDArray[np.floating] | pd.DataFrame, + y: npt.NDArray[np.floating] | pd.Series, + sample_weight: npt.NDArray[np.floating] | pd.Series | None = None, + feature_name: list[str] | None = None, + eval_set: list[tuple[pd.DataFrame, npt.NDArray[np.floating]]] | None = None, + eval_sample_weight: list[npt.NDArray[np.floating]] | list[pd.Series] | None = None, + ) -> None: + """Fit the multi-quantile regressor. + + Args: + X: Input features as a DataFrame. + y: Target values as a 2D array where each column corresponds to a quantile. + sample_weight: Sample weights for training data. + feature_name: List of feature names. + eval_set: Evaluation set for early stopping. + eval_sample_weight: Sample weights for evaluation data. + """ + # Pass model-specific eval arguments + kwargs = {} + for model in self._models: + # Check if early stopping is supported + # Check that eval_set is supported + if eval_set is None and "early_stopping_rounds" in self.hyperparams: + model.set_params(early_stopping_rounds=None) # type: ignore + + if eval_set is not None and self.learner_eval_sample_weight_param is not None: # type: ignore + kwargs[self.learner_eval_sample_weight_param] = eval_sample_weight + + if "early_stopping_rounds" in self.hyperparams and self.learner_eval_sample_weight_param is not None: + model.set_params(early_stopping_rounds=self.hyperparams["early_stopping_rounds"]) # type: ignore + + if feature_name: + self.model_feature_names = feature_name + else: + self.model_feature_names = [] + + if eval_sample_weight is not None and self.learner_eval_sample_weight_param: + kwargs[self.learner_eval_sample_weight_param] = eval_sample_weight + + model.fit( # type: ignore + X=np.asarray(X), + y=y, + sample_weight=sample_weight, + **kwargs, + ) + + self.is_fitted = True + + @property + def learner_eval_sample_weight_param(self) -> str | None: + """Get the name of the sample weight parameter for evaluation sets. + + Returns: + The name of the sample weight parameter if supported, else None. + """ + learner_name: str = self.base_learner.__name__ + params: dict[str, str | None] = { + "QuantileRegressor": None, + "LGBMRegressor": "eval_sample_weight", + "XGBRegressor": "sample_weight_eval_set", + } + return params.get(learner_name) + + def predict(self, X: npt.NDArray[np.floating] | pd.DataFrame) -> npt.NDArray[np.floating]: + """Predict quantiles for the input features. + + Args: + X: Input features as a DataFrame. + + Returns: + + A 2D array where each column corresponds to predicted quantiles. + """ # noqa: D412 + return np.column_stack([model.predict(X=X) for model in self._models]) # type: ignore + + @property + def models(self) -> list[BaseEstimator]: + """Get the list of underlying quantile models. + + Returns: + List of BaseEstimator instances for each quantile. + """ + return self._models + + @property + def has_feature_names(self) -> bool: + """Check if the base estimators have feature names. + + Returns: + True if the base estimators have feature names, False otherwise. + """ + return len(self.model_feature_names) > 0 diff --git a/packages/openstef-models/src/openstef_models/workflows/__init__.py b/packages/openstef-models/src/openstef_models/workflows/__init__.py index 3d66390fe..5ac3d10b2 100644 --- a/packages/openstef-models/src/openstef_models/workflows/__init__.py +++ b/packages/openstef-models/src/openstef_models/workflows/__init__.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/src/openstef_models/workflows/custom_component_split_workflow.py b/packages/openstef-models/src/openstef_models/workflows/custom_component_split_workflow.py index 5c535793b..2e1cf24ef 100644 --- a/packages/openstef-models/src/openstef_models/workflows/custom_component_split_workflow.py +++ b/packages/openstef-models/src/openstef_models/workflows/custom_component_split_workflow.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/src/openstef_models/workflows/custom_forecasting_workflow.py b/packages/openstef-models/src/openstef_models/workflows/custom_forecasting_workflow.py index a740ac7c0..5fbeac8a0 100644 --- a/packages/openstef-models/src/openstef_models/workflows/custom_forecasting_workflow.py +++ b/packages/openstef-models/src/openstef_models/workflows/custom_forecasting_workflow.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 @@ -18,6 +18,7 @@ from openstef_core.datasets import TimeSeriesDataset, VersionedTimeSeriesDataset from openstef_core.datasets.validated_datasets import ForecastDataset from openstef_core.exceptions import NotFittedError, SkipFitting +from openstef_meta.models.ensemble_forecasting_model import EnsembleForecastingModel, EnsembleModelFitResult from openstef_models.mixins import ModelIdentifier, PredictorCallback from openstef_models.mixins.callbacks import WorkflowContext from openstef_models.models.forecasting_model import ForecastingModel, ModelFitResult @@ -117,11 +118,16 @@ class CustomForecastingWorkflow(BaseModel): ... ) # doctest: +SKIP """ - model: ForecastingModel = Field(description="The forecasting model to use.") + model: ForecastingModel | EnsembleForecastingModel = Field(description="The forecasting model to use.") callbacks: list[ForecastingCallback] = Field( default_factory=list[ForecastingCallback], description="List of callbacks to execute during workflow events." ) model_id: ModelIdentifier = Field(...) + run_name: str | None = Field(default=None, description="Optional name for this workflow run.") + experiment_tags: dict[str, str] = Field( + default_factory=dict, + description="Optional metadata tags for experiment tracking.", + ) _logger: logging.Logger = PrivateAttr(default_factory=lambda: logging.getLogger(__name__)) @@ -130,7 +136,7 @@ def fit( data: TimeSeriesDataset, data_val: TimeSeriesDataset | None = None, data_test: TimeSeriesDataset | None = None, - ) -> ModelFitResult | None: + ) -> ModelFitResult | EnsembleModelFitResult | None: """Train the forecasting model with callback execution. Executes the complete training workflow including pre-fit callbacks, @@ -153,6 +159,10 @@ def fit( result = self.model.fit(data=data, data_val=data_val, data_test=data_test) + if isinstance(result, EnsembleModelFitResult): + self._logger.debug("Discarding EnsembleModelFitResult for compatibility.") + result = result.combiner_fit_result + for callback in self.callbacks: callback.on_fit_end(context=context, result=result) except SkipFitting as e: diff --git a/packages/openstef-models/tests/__init__.py b/packages/openstef-models/tests/__init__.py index 81747127d..72baaab86 100644 --- a/packages/openstef-models/tests/__init__.py +++ b/packages/openstef-models/tests/__init__.py @@ -1,3 +1,3 @@ -# SPDX-FileCopyrightText: 2017-2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2017-2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/tests/integration/test_integration.py b/packages/openstef-models/tests/integration/test_integration.py index 89c46bce1..c2cbe61f0 100644 --- a/packages/openstef-models/tests/integration/test_integration.py +++ b/packages/openstef-models/tests/integration/test_integration.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/tests/unit/integrations/__init__.py b/packages/openstef-models/tests/unit/integrations/__init__.py index 63d543f53..1c11ceb54 100644 --- a/packages/openstef-models/tests/unit/integrations/__init__.py +++ b/packages/openstef-models/tests/unit/integrations/__init__.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/tests/unit/integrations/joblib/__init__.py b/packages/openstef-models/tests/unit/integrations/joblib/__init__.py index 63d543f53..1c11ceb54 100644 --- a/packages/openstef-models/tests/unit/integrations/joblib/__init__.py +++ b/packages/openstef-models/tests/unit/integrations/joblib/__init__.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/tests/unit/integrations/joblib/test_joblib_model_serializer.py b/packages/openstef-models/tests/unit/integrations/joblib/test_joblib_model_serializer.py index 861eb244d..0bd2649da 100644 --- a/packages/openstef-models/tests/unit/integrations/joblib/test_joblib_model_serializer.py +++ b/packages/openstef-models/tests/unit/integrations/joblib/test_joblib_model_serializer.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/tests/unit/integrations/mlflow/__init__.py b/packages/openstef-models/tests/unit/integrations/mlflow/__init__.py index 60a258f81..7b9e0469f 100644 --- a/packages/openstef-models/tests/unit/integrations/mlflow/__init__.py +++ b/packages/openstef-models/tests/unit/integrations/mlflow/__init__.py @@ -1,3 +1,3 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/tests/unit/integrations/mlflow/test_mlflow_storage.py b/packages/openstef-models/tests/unit/integrations/mlflow/test_mlflow_storage.py index 25e0d8a90..079341b3b 100644 --- a/packages/openstef-models/tests/unit/integrations/mlflow/test_mlflow_storage.py +++ b/packages/openstef-models/tests/unit/integrations/mlflow/test_mlflow_storage.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 @@ -107,7 +107,7 @@ def test_model_roundtrip(storage: MLFlowStorage, model_id: str): # Act storage.save_run_model(model_id=model_id, run_id=run_id, model=original_model) storage.finalize_run(model_id=model_id, run_id=run_id) - loaded_model = storage.load_run_model(run_id=run_id) + loaded_model = storage.load_run_model(model_id=model_id, run_id=run_id) # Assert assert isinstance(loaded_model, SimpleStatefulModel) @@ -145,3 +145,18 @@ def test_search_latest_runs__no_experiment(storage: MLFlowStorage): # Assert assert latest_runs == [] + + +def test_search_run__returns_matching_run(storage: MLFlowStorage, model_id: str): + """Test that search_run finds a run by its name.""" + # Arrange + run_name = "my_training_run" + created_run = storage.create_run(model_id=model_id, run_name=run_name) + created_run_id = cast(str, created_run.info.run_id) + + # Act + found_run = storage.search_run(model_id=model_id, run_name=run_name) + + # Assert + assert found_run is not None + assert cast(str, found_run.info.run_id) == created_run_id diff --git a/packages/openstef-models/tests/unit/integrations/mlflow/test_mlflow_storage_callback.py b/packages/openstef-models/tests/unit/integrations/mlflow/test_mlflow_storage_callback.py index e28725ee3..9561f4d03 100644 --- a/packages/openstef-models/tests/unit/integrations/mlflow/test_mlflow_storage_callback.py +++ b/packages/openstef-models/tests/unit/integrations/mlflow/test_mlflow_storage_callback.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 @@ -128,7 +128,7 @@ def test_mlflow_storage_callback__on_fit_end__stores_model_and_metrics( # Assert - Model can be loaded from the run run_id = cast(str, runs[0].info.run_id) - loaded_model = callback.storage.load_run_model(run_id=run_id) + loaded_model = callback.storage.load_run_model(model_id=workflow.model_id, run_id=run_id) assert isinstance(loaded_model, ForecastingModel) assert loaded_model.is_fitted @@ -255,3 +255,38 @@ def test_mlflow_storage_callback__model_selection__keeps_better_model( # Act & Assert - Should raise SkipFitting because new model is worse with pytest.raises(SkipFitting, match="New model did not improve"): callback.on_fit_end(context=worse_context, result=worse_result) + + +def test_mlflow_storage_callback__model_selection__skips_on_tag_change( + storage: MLFlowStorage, + workflow: CustomForecastingWorkflow, + fit_result: ModelFitResult, + sample_dataset: TimeSeriesDataset, +): + """Test that model selection keeps the better performing model.""" + # Arrange - Create callback with R2 metric (capital letters) + callback = MLFlowStorageCallback( + storage=storage, + model_selection_metric=(Q(0.5), "R2", "higher_is_better"), + ) + + # Store an initial model + context = WorkflowContext(workflow=workflow) + callback.on_fit_end(context=context, result=fit_result) + + # Create a new result by fitting with a model with a different tag + new_model = ForecastingModel( + forecaster=SimpleTestForecaster( + config=ForecasterConfig(horizons=[LeadTime(timedelta(hours=6))], quantiles=[Q(0.5)]) + ), + tags={"version": "2.0"}, + ) + new_workflow = CustomForecastingWorkflow(model_id="test_model", model=new_model) + new_result = new_model.fit(sample_dataset) + + # Act + result = callback._run_model_selection(workflow=new_workflow, result=new_result) + + # Assert - Should not raise SkipFitting because model changed + assert result is None + assert new_workflow.model == new_model diff --git a/packages/openstef-models/tests/unit/integrations/skops/__init__.py b/packages/openstef-models/tests/unit/integrations/skops/__init__.py new file mode 100644 index 000000000..63d543f53 --- /dev/null +++ b/packages/openstef-models/tests/unit/integrations/skops/__init__.py @@ -0,0 +1,5 @@ +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# +# SPDX-License-Identifier: MPL-2.0 + +__all__ = [] diff --git a/packages/openstef-models/tests/unit/integrations/skops/test_skops_model_serializer.py b/packages/openstef-models/tests/unit/integrations/skops/test_skops_model_serializer.py new file mode 100644 index 000000000..8d4bb9eb7 --- /dev/null +++ b/packages/openstef-models/tests/unit/integrations/skops/test_skops_model_serializer.py @@ -0,0 +1,72 @@ +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +from io import BytesIO +from typing import TYPE_CHECKING + +import pytest + +from openstef_core.mixins import Stateful +from openstef_core.types import LeadTime, Q +from openstef_models.integrations.skops.skops_model_serializer import SkopsModelSerializer +from openstef_models.models.forecasting.forecaster import ForecasterConfig +from openstef_models.models.forecasting.lgbm_forecaster import LGBMForecaster +from openstef_models.models.forecasting.lgbmlinear_forecaster import LGBMLinearForecaster +from openstef_models.models.forecasting.xgboost_forecaster import XGBoostForecaster + +if TYPE_CHECKING: + from openstef_models.models.forecasting.forecaster import Forecaster + + +class SimpleSerializableModel(Stateful): + """A simple model class that can be pickled for testing.""" + + def __init__(self) -> None: + self.target_column = "load" + self.is_fitted = True + + +def test_skops_model_serializer__roundtrip__preserves_model_integrity(): + """Test complete serialize/deserialize roundtrip preserves model state.""" + # Arrange + buffer = BytesIO() + serializer = SkopsModelSerializer() + model = SimpleSerializableModel() + + # Act - Serialize then deserialize + serializer.serialize(model, buffer) + buffer.seek(0) + restored_model = serializer.deserialize(buffer) + + # Assert - Model state should be identical + assert isinstance(restored_model, SimpleSerializableModel) + assert restored_model.target_column == model.target_column + assert restored_model.is_fitted == model.is_fitted + + +@pytest.mark.parametrize( + "forecaster_class", + [ + XGBoostForecaster, + LGBMForecaster, + LGBMLinearForecaster, + ], +) +def test_skops_works_with_different_forecasters(forecaster_class: type[Forecaster]): + buffer = BytesIO() + serializer = SkopsModelSerializer() + + config: ForecasterConfig = forecaster_class.Config(horizons=[LeadTime.from_string("PT12H")], quantiles=[Q(0.5)]) # type: ignore + assert isinstance(config, ForecasterConfig) + forecaster = forecaster_class(config=config) + + # Act - Serialize then deserialize + serializer.serialize(forecaster, buffer) + buffer.seek(0) + restored_model = serializer.deserialize(buffer) + + # Assert - Model state should be identical + assert isinstance(restored_model, forecaster.__class__) diff --git a/packages/openstef-models/tests/unit/models/component_splitting/__init__.py b/packages/openstef-models/tests/unit/models/component_splitting/__init__.py index 60a258f81..7b9e0469f 100644 --- a/packages/openstef-models/tests/unit/models/component_splitting/__init__.py +++ b/packages/openstef-models/tests/unit/models/component_splitting/__init__.py @@ -1,3 +1,3 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/tests/unit/models/component_splitting/test_constant_component_splitter.py b/packages/openstef-models/tests/unit/models/component_splitting/test_constant_component_splitter.py index e18c3a679..66dfd2423 100644 --- a/packages/openstef-models/tests/unit/models/component_splitting/test_constant_component_splitter.py +++ b/packages/openstef-models/tests/unit/models/component_splitting/test_constant_component_splitter.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/tests/unit/models/component_splitting/test_linear_component_splitter.py b/packages/openstef-models/tests/unit/models/component_splitting/test_linear_component_splitter.py index 19df47b67..a9e0197fb 100644 --- a/packages/openstef-models/tests/unit/models/component_splitting/test_linear_component_splitter.py +++ b/packages/openstef-models/tests/unit/models/component_splitting/test_linear_component_splitter.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 @@ -24,8 +24,8 @@ def sample_timeseries_dataset() -> TimeSeriesDataset: """ data = pd.DataFrame( { - "load": [100.0, 150.0, 200.0, 250.0, 300.0], - "radiation": [0.0, 0.0, 10.0, 50.0, 100.0], + "load": [10.0, 15.0, 20.0, 25.0, 30.0], + "radiation": [0.0, 0.0, 1000.0, 5000.0, 1000.0], "windspeed_100m": [10.0, 5.0, 0.0, 3.0, 7.0], }, index=pd.date_range(datetime.fromisoformat("2025-01-01T00:00:00"), periods=5, freq="1h"), @@ -63,12 +63,12 @@ def test_linear_component_splitter__predict_returns_correct_components( # Check that result has same index as input pd.testing.assert_index_equal(result.data.index, sample_timeseries_dataset.data.index) - # Check that components are non-negative - assert (result.data[EnergyComponentType.SOLAR] >= 0).all() - assert (result.data[EnergyComponentType.WIND] >= 0).all() + # Check that components are negative + assert (result.data[EnergyComponentType.SOLAR] <= 0).all() + assert (result.data[EnergyComponentType.WIND] <= 0).all() # Check that not all components are zero - assert (result.data[EnergyComponentType.SOLAR] > 0).any() or (result.data[EnergyComponentType.WIND] > 0).any() + assert (result.data[EnergyComponentType.SOLAR] < 0).any() or (result.data[EnergyComponentType.WIND] < 0).any() def test_linear_component_splitter__create_input_features( diff --git a/packages/openstef-models/tests/unit/models/forecasting/conftest.py b/packages/openstef-models/tests/unit/models/forecasting/conftest.py index 968e68d8c..0823f4ebc 100644 --- a/packages/openstef-models/tests/unit/models/forecasting/conftest.py +++ b/packages/openstef-models/tests/unit/models/forecasting/conftest.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/tests/unit/models/forecasting/test_base_case_forecaster.py b/packages/openstef-models/tests/unit/models/forecasting/test_base_case_forecaster.py index d5ab45e7d..a9898cb5c 100644 --- a/packages/openstef-models/tests/unit/models/forecasting/test_base_case_forecaster.py +++ b/packages/openstef-models/tests/unit/models/forecasting/test_base_case_forecaster.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/tests/unit/models/forecasting/test_constant_median_forecaster.py b/packages/openstef-models/tests/unit/models/forecasting/test_constant_median_forecaster.py index 68557cd45..d4d037ca8 100644 --- a/packages/openstef-models/tests/unit/models/forecasting/test_constant_median_forecaster.py +++ b/packages/openstef-models/tests/unit/models/forecasting/test_constant_median_forecaster.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/tests/unit/models/forecasting/test_flatliner_forecaster.py b/packages/openstef-models/tests/unit/models/forecasting/test_flatliner_forecaster.py index 6d0dc30ba..b657061e1 100644 --- a/packages/openstef-models/tests/unit/models/forecasting/test_flatliner_forecaster.py +++ b/packages/openstef-models/tests/unit/models/forecasting/test_flatliner_forecaster.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 @@ -30,3 +30,25 @@ def test_predict_returns_zeros(config: FlatlinerForecasterConfig, sample_forecas def test_is_fitted_always_true(config: FlatlinerForecasterConfig): forecaster = FlatlinerForecaster(config) assert forecaster.is_fitted + + +def test_predict_returns_median_when_predict_median_is_true(sample_forecast_input_dataset: ForecastInputDataset): + """Test that the forecaster predicts the median of load measurements when predict_median is True.""" + # Arrange + config = FlatlinerForecasterConfig( + quantiles=[Quantile(0.5), Quantile(0.9)], + horizons=[LeadTime(timedelta(hours=1))], + predict_median=True, + ) + forecaster = FlatlinerForecaster(config) + + # Act + forecaster.fit(sample_forecast_input_dataset) + result = forecaster.predict(sample_forecast_input_dataset) + + # Assert + expected_median = sample_forecast_input_dataset.target_series.median() + assert forecaster.is_fitted + assert isinstance(result.data, pd.DataFrame) + assert (result.data == expected_median).all().all() + assert set(result.data.columns) == {q.format() for q in config.quantiles} diff --git a/packages/openstef-models/tests/unit/models/forecasting/test_gblinear_forecaster.py b/packages/openstef-models/tests/unit/models/forecasting/test_gblinear_forecaster.py index 1eba577f5..24d3b8e01 100644 --- a/packages/openstef-models/tests/unit/models/forecasting/test_gblinear_forecaster.py +++ b/packages/openstef-models/tests/unit/models/forecasting/test_gblinear_forecaster.py @@ -1,9 +1,10 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 from datetime import timedelta +import numpy as np import pandas as pd import pytest @@ -132,3 +133,32 @@ def test_gblinear_forecaster__feature_importances( col_sums = feature_importances.sum(axis=0) pd.testing.assert_series_equal(col_sums, pd.Series(1.0, index=expected_columns), atol=1e-10) assert (feature_importances >= 0).all().all() + + +def test_gblinear_forecaster_predict_contributions( + sample_forecast_input_dataset: ForecastInputDataset, + base_config: GBLinearForecasterConfig, +): + """Test basic fit and predict workflow with output validation.""" + # Arrange + expected_quantiles = base_config.quantiles + forecaster = GBLinearForecaster(config=base_config) + + # Act + forecaster.fit(sample_forecast_input_dataset) + result = forecaster.predict_contributions(sample_forecast_input_dataset, scale=True) + + # Assert + # Basic functionality + assert forecaster.is_fitted, "Model should be fitted after calling fit()" + + # Check that necessary quantiles are present + input_features = sample_forecast_input_dataset.input_data().columns + expected_columns = [f"{col}_{q.format()}" for col in input_features for q in expected_quantiles] + assert list(result.columns) == expected_columns, f"Expected columns {expected_columns}, got {list(result.columns)}" + + # Contributions should sum to 1.0 per quantile + for q in expected_quantiles: + quantile_cols = [col for col in result.columns if col.endswith(f"_{q.format()}")] + col_sums = result[quantile_cols].sum(axis=1) + pd.testing.assert_series_equal(col_sums, pd.Series(1.0, index=result.index, dtype=np.float32), atol=1e-10) diff --git a/packages/openstef-models/tests/unit/models/forecasting/test_lgbm_forecaster.py b/packages/openstef-models/tests/unit/models/forecasting/test_lgbm_forecaster.py new file mode 100644 index 000000000..886da0ce6 --- /dev/null +++ b/packages/openstef-models/tests/unit/models/forecasting/test_lgbm_forecaster.py @@ -0,0 +1,180 @@ +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# +# SPDX-License-Identifier: MPL-2.0 +from datetime import timedelta + +import pandas as pd +import pytest + +from openstef_core.datasets import ForecastInputDataset +from openstef_core.exceptions import NotFittedError +from openstef_core.types import LeadTime, Q +from openstef_models.models.forecasting.lgbm_forecaster import ( + LGBMForecaster, + LGBMForecasterConfig, + LGBMHyperParams, +) + + +@pytest.fixture +def base_config() -> LGBMForecasterConfig: + """Base configuration for LightGBM forecaster tests.""" + + return LGBMForecasterConfig( + quantiles=[Q(0.1), Q(0.5), Q(0.9)], + horizons=[LeadTime(timedelta(days=1))], + hyperparams=LGBMHyperParams(n_estimators=100, max_depth=3, min_data_in_leaf=1, min_data_in_bin=1), + device="cpu", + n_jobs=1, + verbosity=0, + ) + + +@pytest.fixture +def forecaster(base_config: LGBMForecasterConfig) -> LGBMForecaster: + return LGBMForecaster(base_config) + + +def test_initialization(forecaster: LGBMForecaster): + assert isinstance(forecaster, LGBMForecaster) + assert forecaster.config.hyperparams.n_estimators == 100 # type: ignore + + +def test_quantile_lgbm_forecaster__fit_predict( + sample_forecast_input_dataset: ForecastInputDataset, + base_config: LGBMForecasterConfig, +): + """Test basic fit and predict workflow with comprehensive output validation.""" + # Arrange + expected_quantiles = base_config.quantiles + forecaster = LGBMForecaster(config=base_config) + + # Act + forecaster.fit(sample_forecast_input_dataset) + result = forecaster.predict(sample_forecast_input_dataset) + + # Assert + # Basic functionality + assert forecaster.is_fitted, "Model should be fitted after calling fit()" + + # Check that necessary quantiles are present + expected_columns = [q.format() for q in expected_quantiles] + assert list(result.data.columns) == expected_columns, ( + f"Expected columns {expected_columns}, got {list(result.data.columns)}" + ) + + # Forecast data quality + assert not result.data.isna().any().any(), "Forecast should not contain NaN or None values" + + # Since forecast is deterministic with fixed random seed, check value spread (vectorized) + # All quantiles should have some variation (not all identical values) + stds = result.data.std() + assert (stds > 0).all(), f"All columns should have variation, got stds: {dict(stds)}" + + +def test_lgbm_forecaster__not_fitted_error( + sample_forecast_input_dataset: ForecastInputDataset, + base_config: LGBMForecasterConfig, +): + """Test that NotFittedError is raised when predicting before fitting.""" + # Arrange + forecaster = LGBMForecaster(config=base_config) + + # Act & Assert + with pytest.raises(NotFittedError): + forecaster.predict(sample_forecast_input_dataset) + + +def test_lgbm_forecaster__with_sample_weights( + sample_dataset_with_weights: ForecastInputDataset, + base_config: LGBMForecasterConfig, +): + """Test that forecaster works with sample weights and produces different results.""" + # Arrange + forecaster_with_weights = LGBMForecaster(config=base_config) + + # Create dataset without weights for comparison + data_without_weights = ForecastInputDataset( + data=sample_dataset_with_weights.data.drop(columns=["sample_weight"]), + sample_interval=sample_dataset_with_weights.sample_interval, + target_column=sample_dataset_with_weights.target_column, + forecast_start=sample_dataset_with_weights.forecast_start, + ) + forecaster_without_weights = LGBMForecaster(config=base_config) + + # Act + forecaster_with_weights.fit(sample_dataset_with_weights) + forecaster_without_weights.fit(data_without_weights) + + # Predict using data without sample_weight column (since that's used for training, not prediction) + result_with_weights = forecaster_with_weights.predict(data_without_weights) + result_without_weights = forecaster_without_weights.predict(data_without_weights) + + # Assert + # Both should produce valid forecasts + assert not result_with_weights.data.isna().any().any(), "Weighted forecast should not contain NaN values" + assert not result_without_weights.data.isna().any().any(), "Unweighted forecast should not contain NaN values" + + # Sample weights should affect the model, so results should be different + # (This is a statistical test - with different weights, predictions should differ) + differences = (result_with_weights.data - result_without_weights.data).abs() + assert differences.sum().sum() > 0, "Sample weights should affect model predictions" + + +def test_lgbm_forecaster__feature_importances( + sample_forecast_input_dataset: ForecastInputDataset, + base_config: LGBMForecasterConfig, +): + """Test that feature_importances returns correct normalized importance scores.""" + # Arrange + forecaster = LGBMForecaster(config=base_config) + forecaster.fit(sample_forecast_input_dataset) + + # Act + feature_importances = forecaster.feature_importances + + # Assert + assert len(feature_importances.index) > 0 + + # Columns should match expected quantile formats + expected_columns = pd.Index([q.format() for q in base_config.quantiles], name="quantiles") + pd.testing.assert_index_equal(feature_importances.columns, expected_columns) + + # Values should be normalized (sum to 1.0 per quantile column) and non-negative + col_sums = feature_importances.sum(axis=0) + pd.testing.assert_series_equal(col_sums, pd.Series(1.0, index=expected_columns), atol=1e-10) + assert (feature_importances >= 0).all().all() + + +def test_lgbm_forecaster_predict_contributions( + sample_forecast_input_dataset: ForecastInputDataset, + base_config: LGBMForecasterConfig, +): + """Test basic fit and predict workflow with output validation.""" + # Arrange + expected_quantiles = base_config.quantiles + forecaster = LGBMForecaster(config=base_config) + + # Act + forecaster.fit(sample_forecast_input_dataset) + result = forecaster.predict_contributions(sample_forecast_input_dataset, scale=True) + + # Assert + # Basic functionality + assert forecaster.is_fitted, "Model should be fitted after calling fit()" + + # Check that necessary quantiles are present + input_features = sample_forecast_input_dataset.input_data().columns + expected_columns = [f"{col}_{q.format()}" for col in input_features for q in expected_quantiles] + assert sorted(result.columns) == sorted(expected_columns), ( + f"Expected columns {expected_columns}, got {list(result.columns)}" + ) + + # Contributions should sum to 1.0 per quantile + for q in expected_quantiles: + quantile_cols = [col for col in result.columns if col.endswith(f"_{q.format()}")] + col_sums = result[quantile_cols].sum(axis=1) + pd.testing.assert_series_equal(col_sums, pd.Series(1.0, index=result.index), atol=1e-10) + + +# TODO(@MvLieshout): Add tests on different loss functions # noqa: TD003 diff --git a/packages/openstef-models/tests/unit/models/forecasting/test_lgbmlinear_forecaster.py b/packages/openstef-models/tests/unit/models/forecasting/test_lgbmlinear_forecaster.py new file mode 100644 index 000000000..cc4b4701e --- /dev/null +++ b/packages/openstef-models/tests/unit/models/forecasting/test_lgbmlinear_forecaster.py @@ -0,0 +1,149 @@ +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# +# SPDX-License-Identifier: MPL-2.0 +from datetime import timedelta + +import pandas as pd +import pytest + +from openstef_core.datasets import ForecastInputDataset +from openstef_core.exceptions import NotFittedError +from openstef_core.types import LeadTime, Q +from openstef_models.models.forecasting.lgbmlinear_forecaster import ( + LGBMLinearForecaster, + LGBMLinearForecasterConfig, + LGBMLinearHyperParams, +) + + +@pytest.fixture +def base_config() -> LGBMLinearForecasterConfig: + """Base configuration for LgbLinear forecaster tests.""" + + return LGBMLinearForecasterConfig( + quantiles=[Q(0.1), Q(0.5), Q(0.9)], + horizons=[LeadTime(timedelta(days=1))], + hyperparams=LGBMLinearHyperParams(n_estimators=100, max_depth=3, min_data_in_leaf=1, min_data_in_bin=1), + device="cpu", + n_jobs=1, + verbosity=0, + ) + + +@pytest.fixture +def forecaster(base_config: LGBMLinearForecasterConfig) -> LGBMLinearForecaster: + return LGBMLinearForecaster(base_config) + + +def test_initialization(forecaster: LGBMLinearForecaster): + assert isinstance(forecaster, LGBMLinearForecaster) + assert forecaster.config.hyperparams.n_estimators == 100 # type: ignore + + +def test_quantile_lgbmlinear_forecaster__fit_predict( + sample_forecast_input_dataset: ForecastInputDataset, + base_config: LGBMLinearForecasterConfig, +): + """Test basic fit and predict workflow with comprehensive output validation.""" + # Arrange + expected_quantiles = base_config.quantiles + forecaster = LGBMLinearForecaster(config=base_config) + + # Act + forecaster.fit(sample_forecast_input_dataset) + result = forecaster.predict(sample_forecast_input_dataset) + + # Assert + # Basic functionality + assert forecaster.is_fitted, "Model should be fitted after calling fit()" + + # Check that necessary quantiles are present + expected_columns = [q.format() for q in expected_quantiles] + assert list(result.data.columns) == expected_columns, ( + f"Expected columns {expected_columns}, got {list(result.data.columns)}" + ) + + # Forecast data quality + assert not result.data.isna().any().any(), "Forecast should not contain NaN or None values" + + # Since forecast is deterministic with fixed random seed, check value spread (vectorized) + # All quantiles should have some variation (not all identical values) + stds = result.data.std() + assert (stds > 0).all(), f"All columns should have variation, got stds: {dict(stds)}" + + +def test_lgbmlinear_forecaster__not_fitted_error( + sample_forecast_input_dataset: ForecastInputDataset, + base_config: LGBMLinearForecasterConfig, +): + """Test that NotFittedError is raised when predicting before fitting.""" + # Arrange + forecaster = LGBMLinearForecaster(config=base_config) + + # Act & Assert + with pytest.raises(NotFittedError): + forecaster.predict(sample_forecast_input_dataset) + + +def test_lgbmlinear_forecaster__with_sample_weights( + sample_dataset_with_weights: ForecastInputDataset, + base_config: LGBMLinearForecasterConfig, +): + """Test that forecaster works with sample weights and produces different results.""" + # Arrange + forecaster_with_weights = LGBMLinearForecaster(config=base_config) + + # Create dataset without weights for comparison + data_without_weights = ForecastInputDataset( + data=sample_dataset_with_weights.data.drop(columns=["sample_weight"]), + sample_interval=sample_dataset_with_weights.sample_interval, + target_column=sample_dataset_with_weights.target_column, + forecast_start=sample_dataset_with_weights.forecast_start, + ) + forecaster_without_weights = LGBMLinearForecaster(config=base_config) + + # Act + forecaster_with_weights.fit(sample_dataset_with_weights) + forecaster_without_weights.fit(data_without_weights) + + # Predict using data without sample_weight column (since that's used for training, not prediction) + result_with_weights = forecaster_with_weights.predict(data_without_weights) + result_without_weights = forecaster_without_weights.predict(data_without_weights) + + # Assert + # Both should produce valid forecasts + assert not result_with_weights.data.isna().any().any(), "Weighted forecast should not contain NaN values" + assert not result_without_weights.data.isna().any().any(), "Unweighted forecast should not contain NaN values" + + # Sample weights should affect the model, so results should be different + # (This is a statistical test - with different weights, predictions should differ) + differences = (result_with_weights.data - result_without_weights.data).abs() + assert differences.sum().sum() > 0, "Sample weights should affect model predictions" + + +def test_lgbmlinear_forecaster__feature_importances( + sample_forecast_input_dataset: ForecastInputDataset, + base_config: LGBMLinearForecasterConfig, +): + """Test that feature_importances returns correct normalized importance scores.""" + # Arrange + forecaster = LGBMLinearForecaster(config=base_config) + forecaster.fit(sample_forecast_input_dataset) + + # Act + feature_importances = forecaster.feature_importances + + # Assert + assert len(feature_importances.index) > 0 + + # Columns should match expected quantile formats + expected_columns = pd.Index([q.format() for q in base_config.quantiles], name="quantiles") + pd.testing.assert_index_equal(feature_importances.columns, expected_columns) + + # Values should be normalized (sum to 1.0 per quantile column) and non-negative + col_sums = feature_importances.sum(axis=0) + pd.testing.assert_series_equal(col_sums, pd.Series(1.0, index=expected_columns), atol=1e-10) + assert (feature_importances >= 0).all().all() + + +# TODO(@MvLieshout): Add tests on different loss functions # noqa: TD003 diff --git a/packages/openstef-models/tests/unit/models/forecasting/test_xgboost_forecaster.py b/packages/openstef-models/tests/unit/models/forecasting/test_xgboost_forecaster.py index dd0e80058..91fddda99 100644 --- a/packages/openstef-models/tests/unit/models/forecasting/test_xgboost_forecaster.py +++ b/packages/openstef-models/tests/unit/models/forecasting/test_xgboost_forecaster.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 @@ -22,7 +22,7 @@ def base_config() -> XGBoostForecasterConfig: """Base configuration for XGBoost forecaster tests.""" return XGBoostForecasterConfig( horizons=[LeadTime(timedelta(days=1))], - quantiles=[Q(0.1), Q(0.5), Q(0.9)], + quantiles=[Q(0.1), Q(0.3), Q(0.5), Q(0.7), Q(0.9)], hyperparams=XGBoostHyperParams( n_estimators=10, # Small for fast tests ), @@ -167,3 +167,32 @@ def test_xgboost_forecaster__feature_importances( col_sums = feature_importances.sum(axis=0) pd.testing.assert_series_equal(col_sums, pd.Series(1.0, index=expected_columns), atol=1e-10) assert (feature_importances >= 0).all().all() + + +def test_xgboost_forecaster_predict_contributions( + sample_forecast_input_dataset: ForecastInputDataset, + base_config: XGBoostForecasterConfig, +): + """Test basic fit and predict workflow with output validation.""" + # Arrange + expected_quantiles = base_config.quantiles + forecaster = XGBoostForecaster(config=base_config) + + # Act + forecaster.fit(sample_forecast_input_dataset) + result = forecaster.predict_contributions(sample_forecast_input_dataset, scale=True) + + # Assert + # Basic functionality + assert forecaster.is_fitted, "Model should be fitted after calling fit()" + + # Check that necessary quantiles are present + input_features = sample_forecast_input_dataset.input_data().columns + expected_columns = [f"{col}_{q.format()}" for col in input_features for q in expected_quantiles] + assert list(result.columns) == expected_columns, f"Expected columns {expected_columns}, got {list(result.columns)}" + + # Contributions should sum to 1.0 per quantile + for q in expected_quantiles: + quantile_cols = [col for col in result.columns if col.endswith(f"_{q.format()}")] + col_sums = result[quantile_cols].sum(axis=1) + pd.testing.assert_series_equal(col_sums, pd.Series(1.0, index=result.index), atol=1e-10, check_dtype=False) diff --git a/packages/openstef-models/tests/unit/models/test_forecasting_model.py b/packages/openstef-models/tests/unit/models/test_forecasting_model.py index b99a01c9b..9e2d43f30 100644 --- a/packages/openstef-models/tests/unit/models/test_forecasting_model.py +++ b/packages/openstef-models/tests/unit/models/test_forecasting_model.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/tests/unit/test_example.py b/packages/openstef-models/tests/unit/test_example.py index f410538a5..97c6f72a4 100644 --- a/packages/openstef-models/tests/unit/test_example.py +++ b/packages/openstef-models/tests/unit/test_example.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/tests/unit/transforms/energy_domain/test_wind_power_feature_adder.py b/packages/openstef-models/tests/unit/transforms/energy_domain/test_wind_power_feature_adder.py index ff6b8efb3..55a014003 100644 --- a/packages/openstef-models/tests/unit/transforms/energy_domain/test_wind_power_feature_adder.py +++ b/packages/openstef-models/tests/unit/transforms/energy_domain/test_wind_power_feature_adder.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/tests/unit/transforms/general/test_clipper.py b/packages/openstef-models/tests/unit/transforms/general/test_clipper.py index c2aaf1b02..a45769083 100644 --- a/packages/openstef-models/tests/unit/transforms/general/test_clipper.py +++ b/packages/openstef-models/tests/unit/transforms/general/test_clipper.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/tests/unit/transforms/general/test_dimensionality_reducer.py b/packages/openstef-models/tests/unit/transforms/general/test_dimensionality_reducer.py index 7dcc45396..400c2bf3c 100644 --- a/packages/openstef-models/tests/unit/transforms/general/test_dimensionality_reducer.py +++ b/packages/openstef-models/tests/unit/transforms/general/test_dimensionality_reducer.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/tests/unit/transforms/general/test_empty_feature_remover.py b/packages/openstef-models/tests/unit/transforms/general/test_empty_feature_remover.py index 8c89b27d8..a1fb1fe51 100644 --- a/packages/openstef-models/tests/unit/transforms/general/test_empty_feature_remover.py +++ b/packages/openstef-models/tests/unit/transforms/general/test_empty_feature_remover.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/tests/unit/transforms/general/test_flagger.py b/packages/openstef-models/tests/unit/transforms/general/test_flagger.py new file mode 100644 index 000000000..b250099f4 --- /dev/null +++ b/packages/openstef-models/tests/unit/transforms/general/test_flagger.py @@ -0,0 +1,62 @@ +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# +# SPDX-License-Identifier: MPL-2.0 + +from datetime import timedelta + +import pandas as pd +import pytest + +from openstef_core.datasets import TimeSeriesDataset +from openstef_models.transforms.general import Flagger +from openstef_models.utils.feature_selection import FeatureSelection + + +@pytest.fixture +def train_dataset() -> TimeSeriesDataset: + """Training dataset with three features A, B, C.""" + return TimeSeriesDataset( + data=pd.DataFrame( + {"A": [1.0, 2.0, 3.0], "B": [1.0, 2.0, 3.0], "C": [1.0, 2.0, 3.0]}, + index=pd.date_range("2025-01-01", periods=3, freq="1h"), + ), + sample_interval=timedelta(hours=1), + ) + + +@pytest.fixture +def test_dataset() -> TimeSeriesDataset: + """Test dataset with values outside training ranges.""" + return TimeSeriesDataset( + data=pd.DataFrame( + {"A": [2, 2], "B": [0.0, 2.0], "C": [1, 4]}, + index=pd.date_range("2025-01-06", periods=2, freq="1h"), + ), + sample_interval=timedelta(hours=1), + ) + + +def test_flagger__fit_transform( + train_dataset: TimeSeriesDataset, + test_dataset: TimeSeriesDataset, +): + """Test fit and transform flags correctly leaves other columns unchanged.""" + # Arrange + flagger = Flagger(selection=FeatureSelection(include={"A", "B", "C"})) + + # Act + flagger.fit(train_dataset) + transformed_dataset = flagger.transform(test_dataset) + + # Assert + # Column C should remain unchanged + expected_df = pd.DataFrame( + { + "A": [1, 1], + "B": [0, 1], + "C": [0, 0], # Unchanged + }, + index=test_dataset.index, + ) + pd.testing.assert_frame_equal(transformed_dataset.data, expected_df) + assert transformed_dataset.sample_interval == test_dataset.sample_interval diff --git a/packages/openstef-models/tests/unit/transforms/general/test_imputer.py b/packages/openstef-models/tests/unit/transforms/general/test_imputer.py index 049fbde48..d72d4f3e0 100644 --- a/packages/openstef-models/tests/unit/transforms/general/test_imputer.py +++ b/packages/openstef-models/tests/unit/transforms/general/test_imputer.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/tests/unit/transforms/general/test_nan_dropper.py b/packages/openstef-models/tests/unit/transforms/general/test_nan_dropper.py index 7c6c1d5c3..5a35e6e8b 100644 --- a/packages/openstef-models/tests/unit/transforms/general/test_nan_dropper.py +++ b/packages/openstef-models/tests/unit/transforms/general/test_nan_dropper.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/tests/unit/transforms/general/test_sample_weighter.py b/packages/openstef-models/tests/unit/transforms/general/test_sample_weighter.py index 3417085e1..c1bb81f6e 100644 --- a/packages/openstef-models/tests/unit/transforms/general/test_sample_weighter.py +++ b/packages/openstef-models/tests/unit/transforms/general/test_sample_weighter.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/tests/unit/transforms/general/test_scaler.py b/packages/openstef-models/tests/unit/transforms/general/test_scaler.py index 08cc124ae..cad9fdc67 100644 --- a/packages/openstef-models/tests/unit/transforms/general/test_scaler.py +++ b/packages/openstef-models/tests/unit/transforms/general/test_scaler.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/tests/unit/transforms/general/test_selector.py b/packages/openstef-models/tests/unit/transforms/general/test_selector.py new file mode 100644 index 000000000..c6fd78081 --- /dev/null +++ b/packages/openstef-models/tests/unit/transforms/general/test_selector.py @@ -0,0 +1,82 @@ +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# +# SPDX-License-Identifier: MPL-2.0 + +from datetime import timedelta + +import pandas as pd +import pytest + +from openstef_core.datasets import TimeSeriesDataset +from openstef_core.datasets.validated_datasets import ForecastInputDataset +from openstef_models.transforms.general import Selector +from openstef_models.utils.feature_selection import FeatureSelection + + +@pytest.mark.parametrize( + ("timeseries_type", "feature_selection", "expected_features"), + [ + pytest.param( + TimeSeriesDataset, + FeatureSelection(include={"temperature"}), + {"temperature"}, + id="include_subset", + ), + pytest.param( + TimeSeriesDataset, + FeatureSelection(exclude={"humidity"}), + {"load", "temperature"}, + id="exclude_subset", + ), + pytest.param( + TimeSeriesDataset, + FeatureSelection.ALL, + {"load", "temperature", "humidity"}, + id="all_features", + ), + pytest.param( + TimeSeriesDataset, + FeatureSelection.NONE, + set(), + id="no_features", + ), + pytest.param( + ForecastInputDataset, + FeatureSelection.NONE, + {"load"}, + id="forecast_input_no_features_keep_target", + ), + pytest.param( + ForecastInputDataset, + FeatureSelection(include={"humidity"}), + {"load", "humidity"}, + id="forecast_input_include_subset_keep_target", + ), + ], +) +def test_selector__selects_specified_features( + timeseries_type: type[TimeSeriesDataset], + feature_selection: FeatureSelection, + expected_features: set[str], +) -> None: + """Test that Selector selects only the specified features.""" + # Arrange + data = pd.DataFrame( + { + "load": [100.0, 110.0, 120.0], + "temperature": [20.0, 22.0, 23.0], + "humidity": [60.0, 65.0, 70.0], + }, + index=pd.date_range("2025-01-01", periods=3, freq="1h"), + ) + dataset = timeseries_type(data, timedelta(hours=1)) + + selector = Selector(selection=feature_selection) + + # Act + transformed = selector.fit_transform(dataset) + + # Assert + assert set(transformed.feature_names) == expected_features + for feature in expected_features: + pd.testing.assert_series_equal(transformed.data[feature], dataset.data[feature]) diff --git a/packages/openstef-models/tests/unit/transforms/postprocessing/__init__.py b/packages/openstef-models/tests/unit/transforms/postprocessing/__init__.py index 2a7b1e830..6e0512288 100644 --- a/packages/openstef-models/tests/unit/transforms/postprocessing/__init__.py +++ b/packages/openstef-models/tests/unit/transforms/postprocessing/__init__.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/tests/unit/transforms/postprocessing/test_confidence_interval_applicator.py b/packages/openstef-models/tests/unit/transforms/postprocessing/test_confidence_interval_applicator.py index 7111d93db..c07399345 100644 --- a/packages/openstef-models/tests/unit/transforms/postprocessing/test_confidence_interval_applicator.py +++ b/packages/openstef-models/tests/unit/transforms/postprocessing/test_confidence_interval_applicator.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 @@ -89,6 +89,47 @@ def test_single_horizon_workflow( assert not result.data["quantile_P10"].isna().any() assert not result.data["quantile_P50"].isna().any() assert not result.data["quantile_P90"].isna().any() + assert not result.data["stdev"].isna().any() + + +def test_no_add_quantiles_from_std( + validation_predictions: ForecastDataset, + predictions: ForecastDataset, +): + """Test complete single-horizon workflow with quantile generation.""" + # Arrange + quantiles = [Quantile(0.1), Quantile(0.5), Quantile(0.9)] + applicator = ConfidenceIntervalApplicator(quantiles=quantiles, add_quantiles_from_std=False) + + # Act + applicator.fit(validation_predictions) + result = applicator.transform(predictions) + + # Assert + assert "quantile_P10" not in result.data.columns + assert "quantile_P90" not in result.data.columns + assert "quantile_P50" in result.data.columns + assert "stdev" in result.data.columns + assert not result.data["quantile_P50"].isna().any() + assert not result.data["stdev"].isna().any() + + +@pytest.mark.parametrize("add_quantiles_from_std", [True, False]) +def test_quantiles_none( + add_quantiles_from_std: bool, + validation_predictions: ForecastDataset, + predictions: ForecastDataset, +): + """Test complete single-horizon workflow with quantile generation.""" + # Arrange + applicator = ConfidenceIntervalApplicator(quantiles=None, add_quantiles_from_std=add_quantiles_from_std) + + # Act + applicator.fit(validation_predictions) + result = applicator.transform(predictions) + + # Assert + pd.testing.assert_frame_equal(result.data, predictions.data) def test_multi_horizon_workflow( @@ -113,6 +154,7 @@ def test_multi_horizon_workflow( assert "quantile_P10" in result.data.columns assert "quantile_P50" in result.data.columns assert "quantile_P90" in result.data.columns + assert "stdev" in result.data.columns # Quantiles should follow normal distribution properties assert (result.data["quantile_P10"] <= result.data["quantile_P50"]).all() diff --git a/packages/openstef-models/tests/unit/transforms/postprocessing/test_quantile_sorter.py b/packages/openstef-models/tests/unit/transforms/postprocessing/test_quantile_sorter.py index 843f21e3e..cece8aaaf 100644 --- a/packages/openstef-models/tests/unit/transforms/postprocessing/test_quantile_sorter.py +++ b/packages/openstef-models/tests/unit/transforms/postprocessing/test_quantile_sorter.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/tests/unit/transforms/time_domain/test_cyclic_features_adder.py b/packages/openstef-models/tests/unit/transforms/time_domain/test_cyclic_features_adder.py index 568aef7ef..5e376f713 100644 --- a/packages/openstef-models/tests/unit/transforms/time_domain/test_cyclic_features_adder.py +++ b/packages/openstef-models/tests/unit/transforms/time_domain/test_cyclic_features_adder.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/tests/unit/transforms/time_domain/test_datetime_features_adder.py b/packages/openstef-models/tests/unit/transforms/time_domain/test_datetime_features_adder.py index 5fe9a2552..79b52f2e4 100644 --- a/packages/openstef-models/tests/unit/transforms/time_domain/test_datetime_features_adder.py +++ b/packages/openstef-models/tests/unit/transforms/time_domain/test_datetime_features_adder.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/tests/unit/transforms/time_domain/test_holiday_features_adder.py b/packages/openstef-models/tests/unit/transforms/time_domain/test_holiday_features_adder.py index 83866b492..8b391f635 100644 --- a/packages/openstef-models/tests/unit/transforms/time_domain/test_holiday_features_adder.py +++ b/packages/openstef-models/tests/unit/transforms/time_domain/test_holiday_features_adder.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/tests/unit/transforms/time_domain/test_lags_adder.py b/packages/openstef-models/tests/unit/transforms/time_domain/test_lags_adder.py index bcdaf7d23..430f7b396 100644 --- a/packages/openstef-models/tests/unit/transforms/time_domain/test_lags_adder.py +++ b/packages/openstef-models/tests/unit/transforms/time_domain/test_lags_adder.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/tests/unit/transforms/time_domain/test_rolling_aggregates_adder.py b/packages/openstef-models/tests/unit/transforms/time_domain/test_rolling_aggregates_adder.py index 5bda95ea5..b74a42317 100644 --- a/packages/openstef-models/tests/unit/transforms/time_domain/test_rolling_aggregates_adder.py +++ b/packages/openstef-models/tests/unit/transforms/time_domain/test_rolling_aggregates_adder.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/tests/unit/transforms/time_domain/test_versioned_lags_adder.py b/packages/openstef-models/tests/unit/transforms/time_domain/test_versioned_lags_adder.py index d7c5ba6b9..a93f949c9 100644 --- a/packages/openstef-models/tests/unit/transforms/time_domain/test_versioned_lags_adder.py +++ b/packages/openstef-models/tests/unit/transforms/time_domain/test_versioned_lags_adder.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/tests/unit/transforms/validation/test_completeness_checker.py b/packages/openstef-models/tests/unit/transforms/validation/test_completeness_checker.py index 2ea46f059..d0bbe7b99 100644 --- a/packages/openstef-models/tests/unit/transforms/validation/test_completeness_checker.py +++ b/packages/openstef-models/tests/unit/transforms/validation/test_completeness_checker.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/tests/unit/transforms/validation/test_flatline_checker.py b/packages/openstef-models/tests/unit/transforms/validation/test_flatline_checker.py index 71e7596c3..4b9ff23cf 100644 --- a/packages/openstef-models/tests/unit/transforms/validation/test_flatline_checker.py +++ b/packages/openstef-models/tests/unit/transforms/validation/test_flatline_checker.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/tests/unit/transforms/validation/test_input_consistency_checker.py b/packages/openstef-models/tests/unit/transforms/validation/test_input_consistency_checker.py index 549b6c4f9..41e49a57c 100644 --- a/packages/openstef-models/tests/unit/transforms/validation/test_input_consistency_checker.py +++ b/packages/openstef-models/tests/unit/transforms/validation/test_input_consistency_checker.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/tests/unit/transforms/weather_domain/test_atmosphere_derived_features_adder.py b/packages/openstef-models/tests/unit/transforms/weather_domain/test_atmosphere_derived_features_adder.py index c5a9ffe0b..d250b4fbc 100644 --- a/packages/openstef-models/tests/unit/transforms/weather_domain/test_atmosphere_derived_features_adder.py +++ b/packages/openstef-models/tests/unit/transforms/weather_domain/test_atmosphere_derived_features_adder.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/tests/unit/transforms/weather_domain/test_daylight_feature_adder.py b/packages/openstef-models/tests/unit/transforms/weather_domain/test_daylight_feature_adder.py index 3e0dbfffe..84b31ca38 100644 --- a/packages/openstef-models/tests/unit/transforms/weather_domain/test_daylight_feature_adder.py +++ b/packages/openstef-models/tests/unit/transforms/weather_domain/test_daylight_feature_adder.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/tests/unit/transforms/weather_domain/test_radiation_derived_featuers_adder.py b/packages/openstef-models/tests/unit/transforms/weather_domain/test_radiation_derived_featuers_adder.py index 088844f12..5720d9936 100644 --- a/packages/openstef-models/tests/unit/transforms/weather_domain/test_radiation_derived_featuers_adder.py +++ b/packages/openstef-models/tests/unit/transforms/weather_domain/test_radiation_derived_featuers_adder.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 @@ -24,7 +24,7 @@ def sample_dataset() -> TimeSeriesDataset: """Create a sample TimeSeriesDataset with radiation data for testing.""" data = pd.DataFrame( - {"radiation": [3600000, 7200000, 5400000, 1800000, 0]}, # J/m² values + {"radiation": [1000, 2000, 3000, 500, 0]}, # W/m² values index=pd.date_range("2025-06-01 08:00", periods=5, freq="h", tz="Europe/Amsterdam"), ) return TimeSeriesDataset(data, timedelta(hours=1)) @@ -33,9 +33,7 @@ def sample_dataset() -> TimeSeriesDataset: @pytest.fixture def sample_dataset_no_tz() -> TimeSeriesDataset: """Create a sample TimeSeriesDataset without timezone for testing error cases.""" - data = pd.DataFrame( - {"radiation": [3600000, 7200000, 5400000]}, index=pd.date_range("2025-06-01", periods=3, freq="h") - ) + data = pd.DataFrame({"radiation": [1000, 2000, 3000]}, index=pd.date_range("2025-06-01", periods=3, freq="h")) return TimeSeriesDataset(data, timedelta(hours=1)) @@ -147,27 +145,6 @@ def test_transform_preserves_original_data_and_metadata( pd.testing.assert_series_equal(result.data[feature], sample_dataset.data[feature]) -def test_transform_radiation_unit_conversion(): - """Test that radiation is correctly converted from J/m² to kWh/m² for pvlib.""" - # Arrange - data = pd.DataFrame( - {"radiation": [3600000, 7200000]}, # 1000 and 2000 kWh/m² when divided by 3.6e6 - index=pd.date_range("2025-06-01 12:00", periods=2, freq="1h", tz="Europe/Amsterdam"), - ) - dataset = TimeSeriesDataset(data, timedelta(hours=1)) - transform = RadiationDerivedFeaturesAdder(coordinate=Coordinate(latitude=Latitude(52.0), longitude=Longitude(5.0))) - - # Act - result = transform.transform(dataset) - - # Assert - # The exact values depend on solar calculations, but we can verify the result is reasonable - assert "dni" in result.data.columns - assert "gti" in result.data.columns - assert (result.data["dni"] >= 0).all() - assert (result.data["gti"] >= 0).all() - - def test_transform_with_empty_dataset(): """Test handling of empty dataset.""" # Arrange @@ -222,7 +199,7 @@ def test_transform_custom_radiation_column(): """Test transform with custom radiation column name.""" # Arrange data = pd.DataFrame( - {"solar_irradiance": [3600000, 7200000]}, + {"solar_irradiance": [1000, 2000]}, index=pd.date_range("2025-06-01 12:00", periods=2, freq="1h", tz="Europe/Amsterdam"), ) dataset = TimeSeriesDataset(data, timedelta(hours=1)) @@ -243,7 +220,7 @@ def test_transform_handles_missing_columns(): # Arrange dataset = TimeSeriesDataset( data=pd.DataFrame( - {"radiation": [3600000, 7200000]}, + {"radiation": [1000, 2000]}, index=pd.date_range("2025-06-01 12:00", periods=2, freq="1h", tz="Europe/Amsterdam"), ), sample_interval=timedelta(hours=1), @@ -271,7 +248,7 @@ def test_pvlib_integration_different_locations(latitude: float, longitude: float """Test RadiationDerivedFeaturesAdder with real pvlib calls across different locations.""" # Arrange data = pd.DataFrame( - {"radiation": [7200000, 5400000]}, # J/m² values + {"radiation": [2000, 3000]}, index=pd.date_range("2025-06-01 12:00", periods=2, freq="1h", tz=timezone), ) dataset = TimeSeriesDataset(data, timedelta(hours=1)) @@ -302,7 +279,7 @@ def test_pvlib_integration_summer_midday(): """Test that solar calculations produce reasonable results during summer midday.""" # Arrange - Use summer midday for better solar radiation data = pd.DataFrame( - {"radiation": [7200000, 10800000, 14400000]}, # High radiation values for summer + {"radiation": [3000, 4000, 5000]}, # High radiation values for summer index=pd.date_range("2025-06-21 11:00", periods=3, freq="1h", tz="Europe/Amsterdam"), ) dataset = TimeSeriesDataset(data, timedelta(hours=1)) @@ -332,7 +309,7 @@ def test_pvlib_integration_surface_orientations(): """Test different surface orientations with real pvlib calculations.""" # Arrange data = pd.DataFrame( - {"radiation": [7200000, 7200000]}, + {"radiation": [2000, 2000]}, index=pd.date_range("2025-06-01 12:00", periods=2, freq="1h", tz="Europe/Amsterdam"), ) dataset = TimeSeriesDataset(data, timedelta(hours=1)) diff --git a/packages/openstef-models/tests/unit/utils/__init__.py b/packages/openstef-models/tests/unit/utils/__init__.py index 60a258f81..7b9e0469f 100644 --- a/packages/openstef-models/tests/unit/utils/__init__.py +++ b/packages/openstef-models/tests/unit/utils/__init__.py @@ -1,3 +1,3 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/tests/unit/utils/test_data_split.py b/packages/openstef-models/tests/unit/utils/test_data_split.py index a59aa8c79..1ca673d19 100644 --- a/packages/openstef-models/tests/unit/utils/test_data_split.py +++ b/packages/openstef-models/tests/unit/utils/test_data_split.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/tests/unit/utils/test_feature_selection.py b/packages/openstef-models/tests/unit/utils/test_feature_selection.py new file mode 100644 index 000000000..9d2ef2d0a --- /dev/null +++ b/packages/openstef-models/tests/unit/utils/test_feature_selection.py @@ -0,0 +1,125 @@ +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# +# SPDX-License-Identifier: MPL-2.0 + +"""Unit tests for the FeatureSelection utility.""" + +from openstef_models.utils.feature_selection import ( + Exclude, + ExcludeRegex, + FeatureSelection, + Include, + IncludeRegex, +) + + +def test_feature_selection_all(): + """Test FeatureSelection.ALL selects all features.""" + features = ["a", "b", "c"] + assert FeatureSelection.ALL.resolve(features) == features + + +def test_feature_selection_none(): + """Test FeatureSelection.NONE selects no features.""" + features = ["a", "b", "c"] + assert FeatureSelection.NONE.resolve(features) == [] + + +def test_feature_selection_include(): + """Test including specific features.""" + selection = Include("a", "c") + assert selection.resolve(["a", "b", "c", "d"]) == ["a", "c"] + + +def test_feature_selection_exclude(): + """Test excluding specific features.""" + selection = Exclude("b", "d") + assert selection.resolve(["a", "b", "c", "d"]) == ["a", "c"] + + +def test_feature_selection_include_and_exclude(): + """Test combination of include and exclude.""" + selection = FeatureSelection(include={"a", "b", "c"}, exclude={"b"}) + assert selection.resolve(["a", "b", "c", "d"]) == ["a", "c"] + + +def test_feature_selection_combine_both_none(): + """Test combining two ALL selections preserves None.""" + combined = FeatureSelection.ALL.combine(FeatureSelection.ALL) + assert combined.include is None + assert combined.exclude is None + assert combined.resolve(["a", "b", "c"]) == ["a", "b", "c"] + + +def test_feature_selection_combine_include_sets(): + """Test combining include sets.""" + sel1 = Include("a", "b") + sel2 = Include("c", "d") + combined = sel1.combine(sel2) + assert set(combined.resolve(["a", "b", "c", "d", "e"])) == {"a", "b", "c", "d"} + + +def test_feature_selection_combine_mixed(): + """Test combining selections with different patterns.""" + sel1 = FeatureSelection(include={"a", "b"}, exclude={"b"}) + sel2 = FeatureSelection(include={"c"}, exclude={"a"}) + combined = sel1.combine(sel2) + assert combined.include == {"a", "b", "c"} + assert combined.exclude == {"a", "b"} + assert combined.resolve(["a", "b", "c", "d"]) == ["c"] # exclusion applied last + + +def test_regex_include_pattern(): + """Test including features by regex pattern.""" + selection = IncludeRegex(r"^temp_.*") + features = ["temp_sensor", "temp_valve", "pressure_sensor", "humidity"] + assert selection.resolve(features) == ["temp_sensor", "temp_valve"] + + +def test_regex_exclude_pattern(): + """Test excluding features by regex pattern.""" + selection = ExcludeRegex(r".*_old$") + features = ["temp_new", "pressure_old", "humidity_current", "wind_old"] + assert selection.resolve(features) == ["temp_new", "humidity_current"] + + +def test_regex_include_and_exclude(): + """Test combination of include and exclude regex patterns.""" + selection = FeatureSelection(include_regex={r"^temp_.*", r"^pressure_.*"}, exclude_regex={r".*_old$"}) + features = ["temp_sensor", "temp_old", "pressure_valve", "humidity_sensor", "pressure_old"] + assert selection.resolve(features) == ["temp_sensor", "pressure_valve"] + + +def test_exact_and_regex_include(): + """Test combining exact and regex include patterns.""" + selection = FeatureSelection(include={"a"}, include_regex={r"^b.*"}) + features = ["a", "b1", "b2", "c"] + assert set(selection.resolve(features)) == {"a", "b1", "b2"} + + +def test_exact_and_regex_exclude(): + """Test combining exact and regex exclude patterns.""" + selection = FeatureSelection(exclude={"a"}, exclude_regex={r"^b.*"}) + features = ["a", "b1", "b2", "c"] + assert selection.resolve(features) == ["c"] + + +def test_combine_exact_and_regex(): + """Test combining selections with exact and regex patterns.""" + sel1 = Include("a", "b") + sel2 = IncludeRegex(r"^c.*") + combined = sel1.combine(sel2) + features = ["a", "b", "c1", "c2", "d"] + assert set(combined.resolve(features)) == {"a", "b", "c1", "c2"} + + +def test_combine_all_types(): + """Test combining all types of patterns.""" + sel1 = FeatureSelection(include={"a"}, exclude_regex={r".*_old$"}) + sel2 = FeatureSelection(include_regex={r"^temp_.*"}, exclude={"a"}) + combined = sel1.combine(sel2) + features = ["a", "temp_sensor", "temp_old", "pressure"] + # include: {a} + regex temp_.* + # exclude: {a} + regex .*_old$ + # So: a excluded, temp_sensor included, temp_old excluded + assert combined.resolve(features) == ["temp_sensor"] diff --git a/packages/openstef-models/tests/unit/utils/test_loss_functions.py b/packages/openstef-models/tests/unit/utils/test_loss_functions.py index c5b2c6367..587360413 100644 --- a/packages/openstef-models/tests/unit/utils/test_loss_functions.py +++ b/packages/openstef-models/tests/unit/utils/test_loss_functions.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/packages/openstef-models/tests/unit/utils/test_multi_quantile_regressor.py b/packages/openstef-models/tests/unit/utils/test_multi_quantile_regressor.py new file mode 100644 index 000000000..d2e8ad7be --- /dev/null +++ b/packages/openstef-models/tests/unit/utils/test_multi_quantile_regressor.py @@ -0,0 +1,107 @@ +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# +# SPDX-License-Identifier: MPL-2.0 +import pandas as pd +import pytest +from lightgbm import LGBMRegressor +from numpy.random import default_rng +from pydantic import BaseModel +from sklearn.base import BaseEstimator +from sklearn.linear_model import QuantileRegressor +from xgboost import XGBRegressor + +from openstef_models.utils.multi_quantile_regressor import MultiQuantileRegressor, ParamType + +ParamDict = dict[str, ParamType] +BaseLearner = BaseEstimator + + +class BaseLearnerConfig(BaseModel): + base_learner: type[BaseLearner] + quantile_param: str + hyperparams: ParamDict + + +@pytest.fixture +def dataset() -> tuple[pd.DataFrame, pd.Series]: + n_samples = 100 + n_features = 5 + rng = default_rng() + X = pd.DataFrame(rng.random((n_samples, n_features))) + y = pd.Series(rng.random(n_samples)) + return X, y + + +@pytest.fixture(params=["sklearn_quantile", "lgbm", "xgboost"]) +def baselearner_config(request: pytest.FixtureRequest) -> BaseLearnerConfig: # type : ignore + model: str = request.param + if model == "sklearn_quantile": + return BaseLearnerConfig( + base_learner=QuantileRegressor, + quantile_param="quantile", + hyperparams={"alpha": 0.1, "solver": "highs", "fit_intercept": True}, + ) + if model == "lgbm": + return BaseLearnerConfig( + base_learner=LGBMRegressor, # type: ignore + quantile_param="alpha", + hyperparams={ + "objective": "quantile", + "n_estimators": 10, + "learning_rate": 0.1, + "max_depth": -1, + }, + ) + return BaseLearnerConfig( + base_learner=XGBRegressor, + quantile_param="quantile_alpha", + hyperparams={ + "objective": "reg:quantileerror", + "n_estimators": 10, + "learning_rate": 0.1, + "max_depth": 3, + }, + ) + + +def test_init_sets_quantiles_and_models(baselearner_config: BaseLearnerConfig): + quantiles = [0.1, 0.5, 0.9] + + model = MultiQuantileRegressor( + base_learner=baselearner_config.base_learner, + quantile_param=baselearner_config.quantile_param, + quantiles=quantiles, + hyperparams=baselearner_config.hyperparams, + ) + + assert model.quantiles == quantiles + assert len(model._models) == len(quantiles) + + +def test_fit_and_predict_shape(dataset: tuple[pd.DataFrame, pd.Series], baselearner_config: BaseLearnerConfig): + quantiles = [0.1, 0.5, 0.9] + + X, y = dataset[0], dataset[1] + model = MultiQuantileRegressor( + base_learner=baselearner_config.base_learner, + quantile_param=baselearner_config.quantile_param, + quantiles=quantiles, + hyperparams=baselearner_config.hyperparams, + ) + + model.fit(X, y) + preds = model.predict(X) + assert preds.shape == (X.shape[0], len(quantiles)) + + +def test_is_fitted_true_after_fit(dataset: tuple[pd.DataFrame, pd.Series], baselearner_config: BaseLearnerConfig): + quantiles = [0.1, 0.5, 0.9] + X, y = dataset[0], dataset[1] + model = MultiQuantileRegressor( + base_learner=baselearner_config.base_learner, + quantile_param=baselearner_config.quantile_param, + quantiles=quantiles, + hyperparams=baselearner_config.hyperparams, + ) + model.fit(X, y) + assert model.is_fitted diff --git a/pyproject.toml b/pyproject.toml index 6c9333afa..eb92e37fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 @@ -15,7 +15,7 @@ readme = "README.md" keywords = [ "energy", "forecasting", "machinelearning" ] license = "MPL-2.0" authors = [ - { name = "Alliander N.V", email = "short.term.energy.forecasts@alliander.com" }, + { name = "Alliander N.V", email = "openstef@lfenergy.org" }, ] requires-python = ">=3.12,<4.0" classifiers = [ @@ -41,6 +41,7 @@ optional-dependencies.beam = [ "openstef-beam", ] optional-dependencies.models = [ + "openstef-meta", "openstef-models[xgb-cpu]", ] urls.Documentation = "https://openstef.github.io/openstef/index.html" @@ -77,14 +78,17 @@ openstef-beam = { workspace = true } openstef-models = { workspace = true } openstef-docs = { workspace = true } openstef-core = { workspace = true } +openstef-meta = { workspace = true } microsoft-python-type-stubs = { git = "git+https://github.com/microsoft/python-type-stubs.git" } [tool.uv.workspace] members = [ - "packages/openstef-models", - "packages/openstef-beam", "docs", + "examples", + "packages/openstef-beam", "packages/openstef-core", + "packages/openstef-meta", + "packages/openstef-models", ] [tool.ruff] @@ -145,6 +149,7 @@ lint.isort.known-first-party = [ "tests", "examples", ] # Useful if ruff does not run from the actual root of the project and to import form tests +lint.pep8-naming.ignore-names = [ "X" ] # Allow X for SKLearn-like feature matrices lint.pydocstyle.convention = "google" lint.pylint.allow-dunder-method-names = [ "__get_pydantic_core_schema__", @@ -189,6 +194,7 @@ source = [ "packages/openstef-beam/src", "packages/openstef-models/src", "packages/openstef-core/src", + "packages/openstef-meta/src", ] omit = [ "tests/*", @@ -264,7 +270,7 @@ help = "Check REUSE compliance (with optional fix)" args = [ { name = "fix", type = "boolean", help = "Automatically fix REUSE compliance issues before lint check" } ] control.expr = "fix" switch = [ - { case = "True", cmd = "uv run tools/reuse-fix.py --license \"MPL-2.0\" --copyright \"Contributors to the OpenSTEF project \"" }, + { case = "True", cmd = "uv run tools/reuse-fix.py --license \"MPL-2.0\" --copyright \"Contributors to the OpenSTEF project \"" }, { cmd = "reuse lint" }, ] diff --git a/tools/reuse-fix.py b/tools/reuse-fix.py index 4fc5b8f72..50785c03e 100755 --- a/tools/reuse-fix.py +++ b/tools/reuse-fix.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project # # SPDX-License-Identifier: MPL-2.0 diff --git a/uv.lock b/uv.lock index aa29c527d..dde23531c 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,10 @@ version = 1 revision = 3 requires-python = ">=3.12, <4.0" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version < '3.14'", +] [manifest] members = [ @@ -8,6 +12,8 @@ members = [ "openstef-beam", "openstef-core", "openstef-docs", + "openstef-examples", + "openstef-meta", "openstef-models", ] @@ -216,6 +222,71 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/00/2344469e2084fb287c2e0b57b72910309874c3245463acd6cf5e3db69324/appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128", size = 9566, upload-time = "2020-05-11T07:59:49.499Z" }, ] +[[package]] +name = "appnope" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/5d/752690df9ef5b76e169e68d6a129fa6d08a7100ca7f754c89495db3c6019/appnope-0.1.4.tar.gz", hash = "sha256:1de3860566df9caf38f01f86f65e0e13e379af54f9e4bee1e66b48f2efffd1ee", size = 4170, upload-time = "2024-02-06T09:43:11.258Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/29/5ecc3a15d5a33e31b26c11426c45c501e439cb865d0bff96315d86443b78/appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c", size = 4321, upload-time = "2024-02-06T09:43:09.663Z" }, +] + +[[package]] +name = "argon2-cffi" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argon2-cffi-bindings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" }, +] + +[[package]] +name = "argon2-cffi-bindings" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/2d/db8af0df73c1cf454f71b2bbe5e356b8c1f8041c979f505b3d3186e520a9/argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d", size = 1783441, upload-time = "2025-07-30T10:02:05.147Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/97/3c0a35f46e52108d4707c44b95cfe2afcafc50800b5450c197454569b776/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f", size = 54393, upload-time = "2025-07-30T10:01:40.97Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/98bbd6ee89febd4f212696f13c03ca302b8552e7dbf9c8efa11ea4a388c3/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b", size = 29328, upload-time = "2025-07-30T10:01:41.916Z" }, + { url = "https://files.pythonhosted.org/packages/43/24/90a01c0ef12ac91a6be05969f29944643bc1e5e461155ae6559befa8f00b/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a", size = 31269, upload-time = "2025-07-30T10:01:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/d4/d3/942aa10782b2697eee7af5e12eeff5ebb325ccfb86dd8abda54174e377e4/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44", size = 86558, upload-time = "2025-07-30T10:01:43.943Z" }, + { url = "https://files.pythonhosted.org/packages/0d/82/b484f702fec5536e71836fc2dbc8c5267b3f6e78d2d539b4eaa6f0db8bf8/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb", size = 92364, upload-time = "2025-07-30T10:01:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c1/a606ff83b3f1735f3759ad0f2cd9e038a0ad11a3de3b6c673aa41c24bb7b/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92", size = 85637, upload-time = "2025-07-30T10:01:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/44/b4/678503f12aceb0262f84fa201f6027ed77d71c5019ae03b399b97caa2f19/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85", size = 91934, upload-time = "2025-07-30T10:01:47.203Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c7/f36bd08ef9bd9f0a9cff9428406651f5937ce27b6c5b07b92d41f91ae541/argon2_cffi_bindings-25.1.0-cp314-cp314t-win32.whl", hash = "sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f", size = 28158, upload-time = "2025-07-30T10:01:48.341Z" }, + { url = "https://files.pythonhosted.org/packages/b3/80/0106a7448abb24a2c467bf7d527fe5413b7fdfa4ad6d6a96a43a62ef3988/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6", size = 32597, upload-time = "2025-07-30T10:01:49.112Z" }, + { url = "https://files.pythonhosted.org/packages/05/b8/d663c9caea07e9180b2cb662772865230715cbd573ba3b5e81793d580316/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623", size = 28231, upload-time = "2025-07-30T10:01:49.92Z" }, + { url = "https://files.pythonhosted.org/packages/1d/57/96b8b9f93166147826da5f90376e784a10582dd39a393c99bb62cfcf52f0/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500", size = 54121, upload-time = "2025-07-30T10:01:50.815Z" }, + { url = "https://files.pythonhosted.org/packages/0a/08/a9bebdb2e0e602dde230bdde8021b29f71f7841bd54801bcfd514acb5dcf/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44", size = 29177, upload-time = "2025-07-30T10:01:51.681Z" }, + { url = "https://files.pythonhosted.org/packages/b6/02/d297943bcacf05e4f2a94ab6f462831dc20158614e5d067c35d4e63b9acb/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0", size = 31090, upload-time = "2025-07-30T10:01:53.184Z" }, + { url = "https://files.pythonhosted.org/packages/c1/93/44365f3d75053e53893ec6d733e4a5e3147502663554b4d864587c7828a7/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6", size = 81246, upload-time = "2025-07-30T10:01:54.145Z" }, + { url = "https://files.pythonhosted.org/packages/09/52/94108adfdd6e2ddf58be64f959a0b9c7d4ef2fa71086c38356d22dc501ea/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a", size = 87126, upload-time = "2025-07-30T10:01:55.074Z" }, + { url = "https://files.pythonhosted.org/packages/72/70/7a2993a12b0ffa2a9271259b79cc616e2389ed1a4d93842fac5a1f923ffd/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d", size = 80343, upload-time = "2025-07-30T10:01:56.007Z" }, + { url = "https://files.pythonhosted.org/packages/78/9a/4e5157d893ffc712b74dbd868c7f62365618266982b64accab26bab01edc/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99", size = 86777, upload-time = "2025-07-30T10:01:56.943Z" }, + { url = "https://files.pythonhosted.org/packages/74/cd/15777dfde1c29d96de7f18edf4cc94c385646852e7c7b0320aa91ccca583/argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2", size = 27180, upload-time = "2025-07-30T10:01:57.759Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c6/a759ece8f1829d1f162261226fbfd2c6832b3ff7657384045286d2afa384/argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98", size = 31715, upload-time = "2025-07-30T10:01:58.56Z" }, + { url = "https://files.pythonhosted.org/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94", size = 27149, upload-time = "2025-07-30T10:01:59.329Z" }, +] + +[[package]] +name = "arrow" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/33/032cdc44182491aa708d06a68b62434140d8c50820a087fac7af37703357/arrow-1.4.0.tar.gz", hash = "sha256:ed0cc050e98001b8779e84d461b0098c4ac597e88704a655582b21d116e526d7", size = 152931, upload-time = "2025-10-18T17:46:46.761Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/c9/d7977eaacb9df673210491da99e6a247e93df98c715fc43fd136ce1d3d33/arrow-1.4.0-py3-none-any.whl", hash = "sha256:749f0769958ebdc79c173ff0b0670d59051a535fa26e8eba02953dc19eb43205", size = 68797, upload-time = "2025-10-18T17:46:45.663Z" }, +] + [[package]] name = "asttokens" version = "3.0.1" @@ -225,6 +296,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" }, ] +[[package]] +name = "async-lru" +version = "2.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/4d/71ec4d3939dc755264f680f6c2b4906423a304c3d18e96853f0a595dfe97/async_lru-2.0.5.tar.gz", hash = "sha256:481d52ccdd27275f42c43a928b4a50c3bfb2d67af4e78b170e3e0bb39c66e5bb", size = 10380, upload-time = "2025-03-16T17:25:36.919Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/49/d10027df9fce941cb8184e78a02857af36360d33e1721df81c5ed2179a1a/async_lru-2.0.5-py3-none-any.whl", hash = "sha256:ab95404d8d2605310d345932697371a5f40def0487c03d6d0ad9138de52c9943", size = 6069, upload-time = "2025-03-16T17:25:35.422Z" }, +] + [[package]] name = "attrs" version = "23.2.0" @@ -284,6 +364,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/fe/3aed5d0be4d404d12d36ab97e2f1791424d9ca39c2f754a6285d59a3b01d/beautifulsoup4-4.14.2-py3-none-any.whl", hash = "sha256:5ef6fa3a8cbece8488d66985560f97ed091e22bbc4e9c2338508a9d5de6d4515", size = 106392, upload-time = "2025-09-29T10:05:43.771Z" }, ] +[[package]] +name = "bleach" +version = "6.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/18/3c8523962314be6bf4c8989c79ad9531c825210dd13a8669f6b84336e8bd/bleach-6.3.0.tar.gz", hash = "sha256:6f3b91b1c0a02bb9a78b5a454c92506aa0fdf197e1d5e114d2e00c6f64306d22", size = 203533, upload-time = "2025-10-27T17:57:39.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/3a/577b549de0cc09d95f11087ee63c739bba856cd3952697eec4c4bb91350a/bleach-6.3.0-py3-none-any.whl", hash = "sha256:fe10ec77c93ddf3d13a73b035abaac7a9f5e436513864ccdad516693213c65d6", size = 164437, upload-time = "2025-10-27T17:57:37.538Z" }, +] + +[package.optional-dependencies] +css = [ + { name = "tinycss2" }, +] + [[package]] name = "blinker" version = "1.9.0" @@ -492,6 +589,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, ] +[[package]] +name = "choreographer" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "logistro" }, + { name = "simplejson" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/47/64a035c6f764450ea9f902cbeba14c8c70316c2641125510066d8f912bfa/choreographer-1.2.1.tar.gz", hash = "sha256:022afd72b1e9b0bcb950420b134e70055a294c791b6f36cfb47d89745b701b5f", size = 43399, upload-time = "2025-11-09T23:04:44.749Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/9f/d73dfb85d7a5b1a56a99adc50f2074029468168c970ff5daeade4ad819e4/choreographer-1.2.1-py3-none-any.whl", hash = "sha256:9af5385effa3c204dbc337abf7ac74fd8908ced326a15645dc31dde75718c77e", size = 49338, upload-time = "2025-11-09T23:04:43.154Z" }, +] + [[package]] name = "click" version = "8.3.1" @@ -522,6 +632,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "comm" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/13/7d740c5849255756bc17888787313b61fd38a0a8304fc4f073dfc46122aa/comm-0.2.3.tar.gz", hash = "sha256:2dc8048c10962d55d7ad693be1e7045d891b7ce8d999c97963a5e3e99c055971", size = 6319, upload-time = "2025-07-25T14:02:04.452Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417", size = 7294, upload-time = "2025-07-25T14:02:02.896Z" }, +] + [[package]] name = "contourpy" version = "1.3.3" @@ -741,6 +860,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/27/b822b474aaefb684d11df358d52e012699a2a8af231f9b47c54b73f280cb/databricks_sdk-0.73.0-py3-none-any.whl", hash = "sha256:a4d3cfd19357a2b459d2dc3101454d7f0d1b62865ce099c35d0c342b66ac64ff", size = 753896, upload-time = "2025-11-05T06:52:56.451Z" }, ] +[[package]] +name = "debugpy" +version = "1.8.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/62/1a/7cb5531840d7ba5d9329644109e62adee41f2f0083d9f8a4039f01de58cf/debugpy-1.8.18.tar.gz", hash = "sha256:02551b1b84a91faadd2db9bc4948873f2398190c95b3cc6f97dc706f43e8c433", size = 1644467, upload-time = "2025-12-10T19:48:07.236Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/01/439626e3572a33ac543f25bc1dac1e80bc01c7ce83f3c24dc4441302ca13/debugpy-1.8.18-cp312-cp312-macosx_15_0_universal2.whl", hash = "sha256:530c38114725505a7e4ea95328dbc24aabb9be708c6570623c8163412e6d1d6b", size = 2549961, upload-time = "2025-12-10T19:48:21.73Z" }, + { url = "https://files.pythonhosted.org/packages/cd/73/1eeaa15c20a2b627be57a65bc1ebf2edd8d896950eac323588b127d776f2/debugpy-1.8.18-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:a114865099283cbed4c9330cb0c9cb7a04cfa92e803577843657302d526141ec", size = 4309855, upload-time = "2025-12-10T19:48:23.41Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6f/2da8ded21ae55df7067e57bd7f67ffed7e08b634f29bdba30c03d3f19918/debugpy-1.8.18-cp312-cp312-win32.whl", hash = "sha256:4d26736dfabf404e9f3032015ec7b0189e7396d0664e29e5bdbe7ac453043c95", size = 5280577, upload-time = "2025-12-10T19:48:25.386Z" }, + { url = "https://files.pythonhosted.org/packages/f5/8e/ebe887218c5b84f9421de7eb7bb7cdf196e84535c3f504a562219297d755/debugpy-1.8.18-cp312-cp312-win_amd64.whl", hash = "sha256:7e68ba950acbcf95ee862210133681f408cbb78d1c9badbb515230ec55ed6487", size = 5322458, upload-time = "2025-12-10T19:48:28.049Z" }, + { url = "https://files.pythonhosted.org/packages/fe/3f/45af037e91e308274a092eb6a86282865fb1f11148cdb7616e811aae33d7/debugpy-1.8.18-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:75d14dd04b617ee38e46786394ec0dd5e1ac5e3d10ffb034fd6c7b72111174c2", size = 2538826, upload-time = "2025-12-10T19:48:29.434Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f4/2de6bf624de05134d1bbe0a8750d484363cd212c3ade3d04f5c77d47d0ce/debugpy-1.8.18-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:1b224887af5121fa702f9f542968170d104e3f9cac827d85fdefe89702dc235c", size = 4292542, upload-time = "2025-12-10T19:48:30.836Z" }, + { url = "https://files.pythonhosted.org/packages/93/54/89de7ef84d5ac39fc64a773feaedd902536cc5295814cd22d19c6d9dea35/debugpy-1.8.18-cp313-cp313-win32.whl", hash = "sha256:636a5445a3336e4aba323a3545ca2bb373b04b0bc14084a4eb20c989db44429f", size = 5280460, upload-time = "2025-12-10T19:48:32.696Z" }, + { url = "https://files.pythonhosted.org/packages/4f/59/651329e618406229edbef6508a5aa05e43cd027f042740c5b27e46854b23/debugpy-1.8.18-cp313-cp313-win_amd64.whl", hash = "sha256:6da217ac8c1152d698b9809484d50c75bef9cc02fd6886a893a6df81ec952ff8", size = 5322399, upload-time = "2025-12-10T19:48:35.057Z" }, + { url = "https://files.pythonhosted.org/packages/36/59/5e8bf46a66ca9dfcd0ce4f35c07085aeb60d99bf5c52135973a4e197ed41/debugpy-1.8.18-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:be7f622d250fe3429571e84572eb771023f1da22c754f28d2c60a10d74a4cc1b", size = 2537336, upload-time = "2025-12-10T19:48:36.463Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5a/3b37cc266a69da83a4febaa4267bb2062d4bec5287036e2f23d9a30a788c/debugpy-1.8.18-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:df8bf7cd78019d5d155213bf5a1818b36403d0c3758d669e76827d4db026b840", size = 4268696, upload-time = "2025-12-10T19:48:37.855Z" }, + { url = "https://files.pythonhosted.org/packages/de/4b/1e13586444440e5754b70055449b70afa187aaa167fa4c20c0c05d9c3b80/debugpy-1.8.18-cp314-cp314-win32.whl", hash = "sha256:32dd56d50fe15c47d0f930a7f0b9d3e5eb8ed04770bc6c313fba6d226f87e1e8", size = 5280624, upload-time = "2025-12-10T19:48:39.28Z" }, + { url = "https://files.pythonhosted.org/packages/7a/21/f8c12baa16212859269dc4c3e4b413778ec1154d332896d3c4cca96ac660/debugpy-1.8.18-cp314-cp314-win_amd64.whl", hash = "sha256:714b61d753cfe3ed5e7bf0aad131506d750e271726ac86e3e265fd7eeebbe765", size = 5321982, upload-time = "2025-12-10T19:48:41.086Z" }, + { url = "https://files.pythonhosted.org/packages/dc/0d/bf7ac329c132436c57124202b5b5ccd6366e5d8e75eeb184cf078c826e8d/debugpy-1.8.18-py2.py3-none-any.whl", hash = "sha256:ab8cf0abe0fe2dfe1f7e65abc04b1db8740f9be80c1274acb625855c5c3ece6e", size = 5286576, upload-time = "2025-12-10T19:48:56.071Z" }, +] + [[package]] name = "decorator" version = "5.2.1" @@ -750,6 +890,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, ] +[[package]] +name = "defusedxml" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, +] + [[package]] name = "docker" version = "7.1.0" @@ -831,6 +980,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/eb/23/dfb161e91db7c92727db505dc72a384ee79681fe0603f706f9f9f52c2901/fastapi-0.121.2-py3-none-any.whl", hash = "sha256:f2d80b49a86a846b70cc3a03eb5ea6ad2939298bf6a7fe377aa9cd3dd079d358", size = 109201, upload-time = "2025-11-13T17:05:52.718Z" }, ] +[[package]] +name = "fastjsonschema" +version = "2.21.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/b5/23b216d9d985a956623b6bd12d4086b60f0059b27799f23016af04a74ea1/fastjsonschema-2.21.2.tar.gz", hash = "sha256:b1eb43748041c880796cd077f1a07c3d94e93ae84bba5ed36800a33554ae05de", size = 374130, upload-time = "2025-08-14T18:49:36.666Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/a8/20d0723294217e47de6d9e2e40fd4a9d2f7c4b6ef974babd482a59743694/fastjsonschema-2.21.2-py3-none-any.whl", hash = "sha256:1c797122d0a86c5cace2e54bf4e819c36223b552017172f32c5c024a6b77e463", size = 24024, upload-time = "2025-08-14T18:49:34.776Z" }, +] + [[package]] name = "fhconfparser" version = "2024.1" @@ -924,6 +1082,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/93/0dd45cd283c32dea1545151d8c3637b4b8c53cdb3a625aeb2885b184d74d/fonttools-4.60.1-py3-none-any.whl", hash = "sha256:906306ac7afe2156fcf0042173d6ebbb05416af70f6b370967b47f8f00103bbb", size = 1143175, upload-time = "2025-09-29T21:13:24.134Z" }, ] +[[package]] +name = "fqdn" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/3e/a80a8c077fd798951169626cde3e239adeba7dab75deb3555716415bd9b0/fqdn-1.5.1.tar.gz", hash = "sha256:105ed3677e767fb5ca086a0c1f4bb66ebc3c100be518f0e0d755d9eae164d89f", size = 6015, upload-time = "2021-03-11T07:16:29.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/58/8acf1b3e91c58313ce5cb67df61001fc9dcd21be4fadb76c1a2d540e09ed/fqdn-1.5.1-py3-none-any.whl", hash = "sha256:3a179af3761e4df6eb2e026ff9e1a3033d3587bf980a0b1b2e1e5d08d7358014", size = 9121, upload-time = "2021-03-11T07:16:28.351Z" }, +] + [[package]] name = "frozenlist" version = "1.8.0" @@ -1184,7 +1351,7 @@ wheels = [ [[package]] name = "huggingface-hub" -version = "1.1.4" +version = "1.2.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock" }, @@ -1198,9 +1365,9 @@ dependencies = [ { name = "typer-slim" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/44/8a/3cba668d9cd1b4e3eb6c1c3ff7bf0f74a7809bdbb5c327bcdbdbac802d23/huggingface_hub-1.1.4.tar.gz", hash = "sha256:a7424a766fffa1a11e4c1ac2040a1557e2101f86050fdf06627e7b74cc9d2ad6", size = 606842, upload-time = "2025-11-13T10:51:57.602Z" } +sdist = { url = "https://files.pythonhosted.org/packages/67/51/6db95c854e5eb3af8e0edfbfad7588983f63be39662054a49d5e116fb65d/huggingface_hub-1.2.2.tar.gz", hash = "sha256:b5b97bd37f4fe5b898a467373044649c94ee32006c032ce8fb835abe9d92ea28", size = 614598, upload-time = "2025-12-10T14:51:50.208Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/33/3f/969137c9d9428ed8bf171d27604243dd950a47cac82414826e2aebbc0a4c/huggingface_hub-1.1.4-py3-none-any.whl", hash = "sha256:867799fbd2ef338b7f8b03d038d9c0e09415dfe45bb2893b48a510d1d746daa5", size = 515580, upload-time = "2025-11-13T10:51:55.742Z" }, + { url = "https://files.pythonhosted.org/packages/71/40/eb2f3a2c09bebf2fc989ba8bf701ce1f56b2f054b51e1a0fcb3e5d23f13a/huggingface_hub-1.2.2-py3-none-any.whl", hash = "sha256:0f55d7d22058fbf8b29d8095aeee80a7b695aa764f906a21e886c1f87223718f", size = 520964, upload-time = "2025-12-10T14:51:48.206Z" }, ] [[package]] @@ -1242,6 +1409,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "ipykernel" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "appnope", marker = "sys_platform == 'darwin'" }, + { name = "comm" }, + { name = "debugpy" }, + { name = "ipython" }, + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "matplotlib-inline" }, + { name = "nest-asyncio" }, + { name = "packaging" }, + { name = "psutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/a4/4948be6eb88628505b83a1f2f40d90254cab66abf2043b3c40fa07dfce0f/ipykernel-7.1.0.tar.gz", hash = "sha256:58a3fc88533d5930c3546dc7eac66c6d288acde4f801e2001e65edc5dc9cf0db", size = 174579, upload-time = "2025-10-27T09:46:39.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/17/20c2552266728ceba271967b87919664ecc0e33efca29c3efc6baf88c5f9/ipykernel-7.1.0-py3-none-any.whl", hash = "sha256:763b5ec6c5b7776f6a8d7ce09b267693b4e5ce75cb50ae696aaefb3c85e1ea4c", size = 117968, upload-time = "2025-10-27T09:46:37.805Z" }, +] + [[package]] name = "ipython" version = "9.7.0" @@ -1275,6 +1466,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" }, ] +[[package]] +name = "ipywidgets" +version = "8.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "comm" }, + { name = "ipython" }, + { name = "jupyterlab-widgets" }, + { name = "traitlets" }, + { name = "widgetsnbextension" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4c/ae/c5ce1edc1afe042eadb445e95b0671b03cee61895264357956e61c0d2ac0/ipywidgets-8.1.8.tar.gz", hash = "sha256:61f969306b95f85fba6b6986b7fe45d73124d1d9e3023a8068710d47a22ea668", size = 116739, upload-time = "2025-11-01T21:18:12.393Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/6d/0d9848617b9f753b87f214f1c682592f7ca42de085f564352f10f0843026/ipywidgets-8.1.8-py3-none-any.whl", hash = "sha256:ecaca67aed704a338f88f67b1181b58f821ab5dc89c1f0f5ef99db43c1c2921e", size = 139808, upload-time = "2025-11-01T21:18:10.956Z" }, +] + +[[package]] +name = "isoduration" +version = "20.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "arrow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7c/1a/3c8edc664e06e6bd06cce40c6b22da5f1429aa4224d0c590f3be21c91ead/isoduration-20.11.0.tar.gz", hash = "sha256:ac2f9015137935279eac671f94f89eb00584f940f5dc49462a0c4ee692ba1bd9", size = 11649, upload-time = "2020-11-01T11:00:00.312Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/55/e5326141505c5d5e34c5e0935d2908a74e4561eca44108fbfb9c13d2911a/isoduration-20.11.0-py3-none-any.whl", hash = "sha256:b2904c2a4228c3d44f409c8ae8e2370eb21a26f7ac2ec5446df141dde3452042", size = 11321, upload-time = "2020-11-01T10:59:58.02Z" }, +] + [[package]] name = "itsdangerous" version = "2.2.0" @@ -1338,6 +1557,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/ee/5134fa786f6c4090ac5daec7d18656ca825f7a7754139e38aaad95e544a2/joserfc-1.4.2-py3-none-any.whl", hash = "sha256:b15a5ea3a464c37e8006105665c159a288892fa73856fa40be60266dbc20b49d", size = 66435, upload-time = "2025-11-17T09:03:14.46Z" }, ] +[[package]] +name = "json5" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/12/ae/929aee9619e9eba9015207a9d2c1c54db18311da7eb4dcf6d41ad6f0eb67/json5-0.12.1.tar.gz", hash = "sha256:b2743e77b3242f8d03c143dd975a6ec7c52e2f2afe76ed934e53503dd4ad4990", size = 52191, upload-time = "2025-08-12T19:47:42.583Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/e2/05328bd2621be49a6fed9e3030b1e51a2d04537d3f816d211b9cc53c5262/json5-0.12.1-py3-none-any.whl", hash = "sha256:d9c9b3bc34a5f54d43c35e11ef7cb87d8bdd098c6ace87117a7b7e83e705c1d5", size = 36119, upload-time = "2025-08-12T19:47:41.131Z" }, +] + [[package]] name = "jsonpatch" version = "1.33" @@ -1386,6 +1614,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63", size = 90040, upload-time = "2025-08-18T17:03:48.373Z" }, ] +[package.optional-dependencies] +format-nongpl = [ + { name = "fqdn" }, + { name = "idna" }, + { name = "isoduration" }, + { name = "jsonpointer" }, + { name = "rfc3339-validator" }, + { name = "rfc3986-validator" }, + { name = "rfc3987-syntax" }, + { name = "uri-template" }, + { name = "webcolors" }, +] + [[package]] name = "jsonschema-path" version = "0.3.4" @@ -1413,6 +1654,220 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] +[[package]] +name = "jupyter" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ipykernel" }, + { name = "ipywidgets" }, + { name = "jupyter-console" }, + { name = "jupyterlab" }, + { name = "nbconvert" }, + { name = "notebook" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/f3/af28ea964ab8bc1e472dba2e82627d36d470c51f5cd38c37502eeffaa25e/jupyter-1.1.1.tar.gz", hash = "sha256:d55467bceabdea49d7e3624af7e33d59c37fff53ed3a350e1ac957bed731de7a", size = 5714959, upload-time = "2024-08-30T07:15:48.299Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/64/285f20a31679bf547b75602702f7800e74dbabae36ef324f716c02804753/jupyter-1.1.1-py2.py3-none-any.whl", hash = "sha256:7a59533c22af65439b24bbe60373a4e95af8f16ac65a6c00820ad378e3f7cc83", size = 2657, upload-time = "2024-08-30T07:15:47.045Z" }, +] + +[[package]] +name = "jupyter-client" +version = "8.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-core" }, + { name = "python-dateutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/27/d10de45e8ad4ce872372c4a3a37b7b35b6b064f6f023a5c14ffcced4d59d/jupyter_client-8.7.0.tar.gz", hash = "sha256:3357212d9cbe01209e59190f67a3a7e1f387a4f4e88d1e0433ad84d7b262531d", size = 344691, upload-time = "2025-12-09T18:37:01.953Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/f5/fddaec430367be9d62a7ed125530e133bfd4a1c0350fe221149ee0f2b526/jupyter_client-8.7.0-py3-none-any.whl", hash = "sha256:3671a94fd25e62f5f2f554f5e95389c2294d89822378a5f2dd24353e1494a9e0", size = 106215, upload-time = "2025-12-09T18:37:00.024Z" }, +] + +[[package]] +name = "jupyter-console" +version = "6.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ipykernel" }, + { name = "ipython" }, + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "prompt-toolkit" }, + { name = "pygments" }, + { name = "pyzmq" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/2d/e2fd31e2fc41c14e2bcb6c976ab732597e907523f6b2420305f9fc7fdbdb/jupyter_console-6.6.3.tar.gz", hash = "sha256:566a4bf31c87adbfadf22cdf846e3069b59a71ed5da71d6ba4d8aaad14a53539", size = 34363, upload-time = "2023-03-06T14:13:31.02Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/77/71d78d58f15c22db16328a476426f7ac4a60d3a5a7ba3b9627ee2f7903d4/jupyter_console-6.6.3-py3-none-any.whl", hash = "sha256:309d33409fcc92ffdad25f0bcdf9a4a9daa61b6f341177570fdac03de5352485", size = 24510, upload-time = "2023-03-06T14:13:28.229Z" }, +] + +[[package]] +name = "jupyter-core" +version = "5.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "platformdirs" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/49/9d1284d0dc65e2c757b74c6687b6d319b02f822ad039e5c512df9194d9dd/jupyter_core-5.9.1.tar.gz", hash = "sha256:4d09aaff303b9566c3ce657f580bd089ff5c91f5f89cf7d8846c3cdf465b5508", size = 89814, upload-time = "2025-10-16T19:19:18.444Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407", size = 29032, upload-time = "2025-10-16T19:19:16.783Z" }, +] + +[[package]] +name = "jupyter-events" +version = "0.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonschema", extra = ["format-nongpl"] }, + { name = "packaging" }, + { name = "python-json-logger" }, + { name = "pyyaml" }, + { name = "referencing" }, + { name = "rfc3339-validator" }, + { name = "rfc3986-validator" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/c3/306d090461e4cf3cd91eceaff84bede12a8e52cd821c2d20c9a4fd728385/jupyter_events-0.12.0.tar.gz", hash = "sha256:fc3fce98865f6784c9cd0a56a20644fc6098f21c8c33834a8d9fe383c17e554b", size = 62196, upload-time = "2025-02-03T17:23:41.485Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/48/577993f1f99c552f18a0428731a755e06171f9902fa118c379eb7c04ea22/jupyter_events-0.12.0-py3-none-any.whl", hash = "sha256:6464b2fa5ad10451c3d35fabc75eab39556ae1e2853ad0c0cc31b656731a97fb", size = 19430, upload-time = "2025-02-03T17:23:38.643Z" }, +] + +[[package]] +name = "jupyter-lsp" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-server" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/5a/9066c9f8e94ee517133cd98dba393459a16cd48bba71a82f16a65415206c/jupyter_lsp-2.3.0.tar.gz", hash = "sha256:458aa59339dc868fb784d73364f17dbce8836e906cd75fd471a325cba02e0245", size = 54823, upload-time = "2025-08-27T17:47:34.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/60/1f6cee0c46263de1173894f0fafcb3475ded276c472c14d25e0280c18d6d/jupyter_lsp-2.3.0-py3-none-any.whl", hash = "sha256:e914a3cb2addf48b1c7710914771aaf1819d46b2e5a79b0f917b5478ec93f34f", size = 76687, upload-time = "2025-08-27T17:47:33.15Z" }, +] + +[[package]] +name = "jupyter-server" +version = "2.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "argon2-cffi" }, + { name = "jinja2" }, + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "jupyter-events" }, + { name = "jupyter-server-terminals" }, + { name = "nbconvert" }, + { name = "nbformat" }, + { name = "packaging" }, + { name = "prometheus-client" }, + { name = "pywinpty", marker = "os_name == 'nt'" }, + { name = "pyzmq" }, + { name = "send2trash" }, + { name = "terminado" }, + { name = "tornado" }, + { name = "traitlets" }, + { name = "websocket-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/ac/e040ec363d7b6b1f11304cc9f209dac4517ece5d5e01821366b924a64a50/jupyter_server-2.17.0.tar.gz", hash = "sha256:c38ea898566964c888b4772ae1ed58eca84592e88251d2cfc4d171f81f7e99d5", size = 731949, upload-time = "2025-08-21T14:42:54.042Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/80/a24767e6ca280f5a49525d987bf3e4d7552bf67c8be07e8ccf20271f8568/jupyter_server-2.17.0-py3-none-any.whl", hash = "sha256:e8cb9c7db4251f51ed307e329b81b72ccf2056ff82d50524debde1ee1870e13f", size = 388221, upload-time = "2025-08-21T14:42:52.034Z" }, +] + +[[package]] +name = "jupyter-server-terminals" +version = "0.5.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywinpty", marker = "os_name == 'nt'" }, + { name = "terminado" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/d5/562469734f476159e99a55426d697cbf8e7eb5efe89fb0e0b4f83a3d3459/jupyter_server_terminals-0.5.3.tar.gz", hash = "sha256:5ae0295167220e9ace0edcfdb212afd2b01ee8d179fe6f23c899590e9b8a5269", size = 31430, upload-time = "2024-03-12T14:37:03.049Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/2d/2b32cdbe8d2a602f697a649798554e4f072115438e92249624e532e8aca6/jupyter_server_terminals-0.5.3-py3-none-any.whl", hash = "sha256:41ee0d7dc0ebf2809c668e0fc726dfaf258fcd3e769568996ca731b6194ae9aa", size = 13656, upload-time = "2024-03-12T14:37:00.708Z" }, +] + +[[package]] +name = "jupyterlab" +version = "4.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "async-lru" }, + { name = "httpx" }, + { name = "ipykernel" }, + { name = "jinja2" }, + { name = "jupyter-core" }, + { name = "jupyter-lsp" }, + { name = "jupyter-server" }, + { name = "jupyterlab-server" }, + { name = "notebook-shim" }, + { name = "packaging" }, + { name = "setuptools" }, + { name = "tornado" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/e5/4fa382a796a6d8e2cd867816b64f1ff27f906e43a7a83ad9eb389e448cd8/jupyterlab-4.5.0.tar.gz", hash = "sha256:aec33d6d8f1225b495ee2cf20f0514f45e6df8e360bdd7ac9bace0b7ac5177ea", size = 23989880, upload-time = "2025-11-18T13:19:00.365Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/1e/5a4d5498eba382fee667ed797cf64ae5d1b13b04356df62f067f48bb0f61/jupyterlab-4.5.0-py3-none-any.whl", hash = "sha256:88e157c75c1afff64c7dc4b801ec471450b922a4eae4305211ddd40da8201c8a", size = 12380641, upload-time = "2025-11-18T13:18:56.252Z" }, +] + +[[package]] +name = "jupyterlab-pygments" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/51/9187be60d989df97f5f0aba133fa54e7300f17616e065d1ada7d7646b6d6/jupyterlab_pygments-0.3.0.tar.gz", hash = "sha256:721aca4d9029252b11cfa9d185e5b5af4d54772bb8072f9b7036f4170054d35d", size = 512900, upload-time = "2023-11-23T09:26:37.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/dd/ead9d8ea85bf202d90cc513b533f9c363121c7792674f78e0d8a854b63b4/jupyterlab_pygments-0.3.0-py3-none-any.whl", hash = "sha256:841a89020971da1d8693f1a99997aefc5dc424bb1b251fd6322462a1b8842780", size = 15884, upload-time = "2023-11-23T09:26:34.325Z" }, +] + +[[package]] +name = "jupyterlab-server" +version = "2.28.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "jinja2" }, + { name = "json5" }, + { name = "jsonschema" }, + { name = "jupyter-server" }, + { name = "packaging" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d6/2c/90153f189e421e93c4bb4f9e3f59802a1f01abd2ac5cf40b152d7f735232/jupyterlab_server-2.28.0.tar.gz", hash = "sha256:35baa81898b15f93573e2deca50d11ac0ae407ebb688299d3a5213265033712c", size = 76996, upload-time = "2025-10-22T13:59:18.37Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/07/a000fe835f76b7e1143242ab1122e6362ef1c03f23f83a045c38859c2ae0/jupyterlab_server-2.28.0-py3-none-any.whl", hash = "sha256:e4355b148fdcf34d312bbbc80f22467d6d20460e8b8736bf235577dd18506968", size = 59830, upload-time = "2025-10-22T13:59:16.767Z" }, +] + +[[package]] +name = "jupyterlab-widgets" +version = "3.0.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/2d/ef58fed122b268c69c0aa099da20bc67657cdfb2e222688d5731bd5b971d/jupyterlab_widgets-3.0.16.tar.gz", hash = "sha256:423da05071d55cf27a9e602216d35a3a65a3e41cdf9c5d3b643b814ce38c19e0", size = 897423, upload-time = "2025-11-01T21:11:29.724Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/b5/36c712098e6191d1b4e349304ef73a8d06aed77e56ceaac8c0a306c7bda1/jupyterlab_widgets-3.0.16-py3-none-any.whl", hash = "sha256:45fa36d9c6422cf2559198e4db481aa243c7a32d9926b500781c830c80f7ecf8", size = 914926, upload-time = "2025-11-01T21:11:28.008Z" }, +] + +[[package]] +name = "kaleido" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "choreographer" }, + { name = "logistro" }, + { name = "orjson" }, + { name = "packaging" }, + { name = "pytest-timeout" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/ad/76eec859b71eda803a88ea50ed3f270281254656bb23d19eb0a39aa706a0/kaleido-1.2.0.tar.gz", hash = "sha256:fa621a14423e8effa2895a2526be00af0cf21655be1b74b7e382c171d12e71ef", size = 64160, upload-time = "2025-11-04T21:24:23.833Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/97/f6de8d4af54d6401d6581a686cce3e3e2371a79ba459a449104e026c08bc/kaleido-1.2.0-py3-none-any.whl", hash = "sha256:c27ed82b51df6b923d0e656feac221343a0dbcd2fb9bc7e6b1db97f61e9a1513", size = 68997, upload-time = "2025-11-04T21:24:21.704Z" }, +] + [[package]] name = "kiwisolver" version = "1.4.9" @@ -1485,6 +1940,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/80/be/3578e8afd18c88cdf9cb4cffde75a96d2be38c5a903f1ed0ceec061bd09e/kiwisolver-1.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:4a48a2ce79d65d363597ef7b567ce3d14d68783d2b2263d98db3d9477805ba32", size = 70260, upload-time = "2025-08-10T21:27:36.606Z" }, ] +[[package]] +name = "lark" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/34/28fff3ab31ccff1fd4f6c7c7b0ceb2b6968d8ea4950663eadcb5720591a0/lark-1.3.1.tar.gz", hash = "sha256:b426a7a6d6d53189d318f2b6236ab5d6429eaf09259f1ca33eb716eed10d2905", size = 382732, upload-time = "2025-10-27T18:25:56.653Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3d/14ce75ef66813643812f3093ab17e46d3a206942ce7376d31ec2d36229e7/lark-1.3.1-py3-none-any.whl", hash = "sha256:c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12", size = 113151, upload-time = "2025-10-27T18:25:54.882Z" }, +] + [[package]] name = "lazy-object-proxy" version = "1.12.0" @@ -1552,6 +2016,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/65/bd/606e2f7eb0da042bffd8711a7427f7a28ca501aa6b1e3367ae3c7d4dc489/licensecheck-2025.1.0-py3-none-any.whl", hash = "sha256:eb20131cd8f877e5396958fd7b00cdb2225436c37a59dba4cf36d36079133a17", size = 26681, upload-time = "2025-03-26T22:58:03.145Z" }, ] +[[package]] +name = "logistro" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/08/90/bfd7a6fab22bdfafe48ed3c4831713cb77b4779d18ade5e248d5dbc0ca22/logistro-2.0.1.tar.gz", hash = "sha256:8446affc82bab2577eb02bfcbcae196ae03129287557287b6a070f70c1985047", size = 8398, upload-time = "2025-11-01T02:41:18.81Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/6aa79ba3570bddd1bf7e951c6123f806751e58e8cce736bad77b2cf348d7/logistro-2.0.1-py3-none-any.whl", hash = "sha256:06ffa127b9fb4ac8b1972ae6b2a9d7fde57598bf5939cd708f43ec5bba2d31eb", size = 8555, upload-time = "2025-11-01T02:41:17.587Z" }, +] + +[[package]] +name = "lightgbm" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "scipy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/0b/a2e9f5c5da7ef047cc60cef37f86185088845e8433e54d2e7ed439cce8a3/lightgbm-4.6.0.tar.gz", hash = "sha256:cb1c59720eb569389c0ba74d14f52351b573af489f230032a1c9f314f8bab7fe", size = 1703705, upload-time = "2025-02-15T04:03:03.111Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/75/cffc9962cca296bc5536896b7e65b4a7cdeb8db208e71b9c0133c08f8f7e/lightgbm-4.6.0-py3-none-macosx_10_15_x86_64.whl", hash = "sha256:b7a393de8a334d5c8e490df91270f0763f83f959574d504c7ccb9eee4aef70ed", size = 2010151, upload-time = "2025-02-15T04:02:50.961Z" }, + { url = "https://files.pythonhosted.org/packages/21/1b/550ee378512b78847930f5d74228ca1fdba2a7fbdeaac9aeccc085b0e257/lightgbm-4.6.0-py3-none-macosx_12_0_arm64.whl", hash = "sha256:2dafd98d4e02b844ceb0b61450a660681076b1ea6c7adb8c566dfd66832aafad", size = 1592172, upload-time = "2025-02-15T04:02:53.937Z" }, + { url = "https://files.pythonhosted.org/packages/64/41/4fbde2c3d29e25ee7c41d87df2f2e5eda65b431ee154d4d462c31041846c/lightgbm-4.6.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:4d68712bbd2b57a0b14390cbf9376c1d5ed773fa2e71e099cac588703b590336", size = 3454567, upload-time = "2025-02-15T04:02:56.443Z" }, + { url = "https://files.pythonhosted.org/packages/42/86/dabda8fbcb1b00bcfb0003c3776e8ade1aa7b413dff0a2c08f457dace22f/lightgbm-4.6.0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:cb19b5afea55b5b61cbb2131095f50538bd608a00655f23ad5d25ae3e3bf1c8d", size = 3569831, upload-time = "2025-02-15T04:02:58.925Z" }, + { url = "https://files.pythonhosted.org/packages/5e/23/f8b28ca248bb629b9e08f877dd2965d1994e1674a03d67cd10c5246da248/lightgbm-4.6.0-py3-none-win_amd64.whl", hash = "sha256:37089ee95664b6550a7189d887dbf098e3eadab03537e411f52c63c121e3ba4b", size = 1451509, upload-time = "2025-02-15T04:03:01.515Z" }, +] + [[package]] name = "loguru" version = "0.7.3" @@ -1741,6 +2231,15 @@ name = "microsoft-python-type-stubs" version = "0" source = { git = "https://github.com/microsoft/python-type-stubs.git#692c37c3969d22612b295ddf7e7af5907204a386" } +[[package]] +name = "mistune" +version = "3.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/02/a7fb8b21d4d55ac93cdcde9d3638da5dd0ebdd3a4fed76c7725e10b81cbe/mistune-3.1.4.tar.gz", hash = "sha256:b5a7f801d389f724ec702840c11d8fc48f2b33519102fc7ee739e8177b672164", size = 94588, upload-time = "2025-08-29T07:20:43.594Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/f0/8282d9641415e9e33df173516226b404d367a0fc55e1a60424a152913abc/mistune-3.1.4-py3-none-any.whl", hash = "sha256:93691da911e5d9d2e23bc54472892aff676df27a75274962ff9edc210364266d", size = 53481, upload-time = "2025-08-29T07:20:42.218Z" }, +] + [[package]] name = "mlflow-skinny" version = "3.6.0" @@ -1956,6 +2455,70 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/82/7a9d0550484a62c6da82858ee9419f3dd1ccc9aa1c26a1e43da3ecd20b0d/natsort-8.4.0-py3-none-any.whl", hash = "sha256:4732914fb471f56b5cce04d7bae6f164a592c7712e1c85f9ef585e197299521c", size = 38268, upload-time = "2023-06-20T04:17:17.522Z" }, ] +[[package]] +name = "nbclient" +version = "0.10.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "nbformat" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/87/66/7ffd18d58eae90d5721f9f39212327695b749e23ad44b3881744eaf4d9e8/nbclient-0.10.2.tar.gz", hash = "sha256:90b7fc6b810630db87a6d0c2250b1f0ab4cf4d3c27a299b0cde78a4ed3fd9193", size = 62424, upload-time = "2024-12-19T10:32:27.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/6d/e7fa07f03a4a7b221d94b4d586edb754a9b0dc3c9e2c93353e9fa4e0d117/nbclient-0.10.2-py3-none-any.whl", hash = "sha256:4ffee11e788b4a27fabeb7955547e4318a5298f34342a4bfd01f2e1faaeadc3d", size = 25434, upload-time = "2024-12-19T10:32:24.139Z" }, +] + +[[package]] +name = "nbconvert" +version = "7.16.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "bleach", extra = ["css"] }, + { name = "defusedxml" }, + { name = "jinja2" }, + { name = "jupyter-core" }, + { name = "jupyterlab-pygments" }, + { name = "markupsafe" }, + { name = "mistune" }, + { name = "nbclient" }, + { name = "nbformat" }, + { name = "packaging" }, + { name = "pandocfilters" }, + { name = "pygments" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/59/f28e15fc47ffb73af68a8d9b47367a8630d76e97ae85ad18271b9db96fdf/nbconvert-7.16.6.tar.gz", hash = "sha256:576a7e37c6480da7b8465eefa66c17844243816ce1ccc372633c6b71c3c0f582", size = 857715, upload-time = "2025-01-28T09:29:14.724Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/9a/cd673b2f773a12c992f41309ef81b99da1690426bd2f96957a7ade0d3ed7/nbconvert-7.16.6-py3-none-any.whl", hash = "sha256:1375a7b67e0c2883678c48e506dc320febb57685e5ee67faa51b18a90f3a712b", size = 258525, upload-time = "2025-01-28T09:29:12.551Z" }, +] + +[[package]] +name = "nbformat" +version = "5.10.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastjsonschema" }, + { name = "jsonschema" }, + { name = "jupyter-core" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/fd/91545e604bc3dad7dca9ed03284086039b294c6b3d75c0d2fa45f9e9caf3/nbformat-5.10.4.tar.gz", hash = "sha256:322168b14f937a5d11362988ecac2a4952d3d8e3a2cbeb2319584631226d5b3a", size = 142749, upload-time = "2024-04-04T11:20:37.371Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/82/0340caa499416c78e5d8f5f05947ae4bc3cba53c9f038ab6e9ed964e22f1/nbformat-5.10.4-py3-none-any.whl", hash = "sha256:3b48d6c8fbca4b299bf3982ea7db1af21580e4fec269ad087b9e81588891200b", size = 78454, upload-time = "2024-04-04T11:20:34.895Z" }, +] + +[[package]] +name = "nest-asyncio" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418, upload-time = "2024-01-21T14:25:19.227Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, +] + [[package]] name = "networkx" version = "3.5" @@ -1974,6 +2537,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, ] +[[package]] +name = "notebook" +version = "7.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-server" }, + { name = "jupyterlab" }, + { name = "jupyterlab-server" }, + { name = "notebook-shim" }, + { name = "tornado" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/89/ac/a97041621250a4fc5af379fb377942841eea2ca146aab166b8fcdfba96c2/notebook-7.5.0.tar.gz", hash = "sha256:3b27eaf9913033c28dde92d02139414c608992e1df4b969c843219acf2ff95e4", size = 14052074, upload-time = "2025-11-19T08:36:20.093Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/96/00df2a4760f10f5af0f45c4955573cae6189931f9a30265a35865f8c1031/notebook-7.5.0-py3-none-any.whl", hash = "sha256:3300262d52905ca271bd50b22617681d95f08a8360d099e097726e6d2efb5811", size = 14460968, upload-time = "2025-11-19T08:36:15.869Z" }, +] + +[[package]] +name = "notebook-shim" +version = "0.2.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-server" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/54/d2/92fa3243712b9a3e8bafaf60aac366da1cada3639ca767ff4b5b3654ec28/notebook_shim-0.2.4.tar.gz", hash = "sha256:b4b2cfa1b65d98307ca24361f5b30fe785b53c3fd07b7a47e89acb5e6ac638cb", size = 13167, upload-time = "2024-02-14T23:35:18.353Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/33/bd5b9137445ea4b680023eb0469b2bb969d61303dedb2aac6560ff3d14a1/notebook_shim-0.2.4-py3-none-any.whl", hash = "sha256:411a5be4e9dc882a074ccbcae671eda64cceb068767e9a3419096986560e1cef", size = 13307, upload-time = "2024-02-14T23:35:16.286Z" }, +] + [[package]] name = "numpy" version = "2.3.5" @@ -2107,6 +2698,7 @@ beam = [ { name = "openstef-beam" }, ] models = [ + { name = "openstef-meta" }, { name = "openstef-models", extra = ["xgb-cpu"] }, ] @@ -2138,6 +2730,7 @@ requires-dist = [ { name = "openstef-beam", extras = ["all"], marker = "extra == 'all'", editable = "packages/openstef-beam" }, { name = "openstef-core", editable = "packages/openstef-core" }, { name = "openstef-core", marker = "extra == 'all'", editable = "packages/openstef-core" }, + { name = "openstef-meta", marker = "extra == 'models'", editable = "packages/openstef-meta" }, { name = "openstef-models", extras = ["xgb-cpu"], editable = "packages/openstef-models" }, { name = "openstef-models", extras = ["xgb-cpu"], marker = "extra == 'all'", editable = "packages/openstef-models" }, { name = "openstef-models", extras = ["xgb-cpu"], marker = "extra == 'models'", editable = "packages/openstef-models" }, @@ -2259,12 +2852,60 @@ requires-dist = [ { name = "sphinx-pyproject", specifier = ">=0.3.0" }, ] +[[package]] +name = "openstef-meta" +version = "0.0.0" +source = { editable = "packages/openstef-meta" } +dependencies = [ + { name = "openstef-beam" }, + { name = "openstef-core" }, + { name = "openstef-models" }, +] + +[package.metadata] +requires-dist = [ + { name = "openstef-beam", editable = "packages/openstef-beam" }, + { name = "openstef-core", editable = "packages/openstef-core" }, + { name = "openstef-models", editable = "packages/openstef-models" }, +] + +[[package]] +name = "openstef-examples" +version = "0.0.0" +source = { virtual = "examples" } +dependencies = [ + { name = "openstef" }, + { name = "openstef-beam" }, + { name = "openstef-core" }, + { name = "openstef-models" }, +] + +[package.optional-dependencies] +tutorials = [ + { name = "huggingface-hub" }, + { name = "jupyter" }, + { name = "kaleido" }, +] + +[package.metadata] +requires-dist = [ + { name = "huggingface-hub", marker = "extra == 'tutorials'", specifier = ">=1.2.2" }, + { name = "jupyter", marker = "extra == 'tutorials'", specifier = ">=1.1.1" }, + { name = "kaleido", marker = "extra == 'tutorials'" }, + { name = "openstef", editable = "." }, + { name = "openstef-beam", editable = "packages/openstef-beam" }, + { name = "openstef-core", editable = "packages/openstef-core" }, + { name = "openstef-models", editable = "packages/openstef-models" }, +] +provides-extras = ["tutorials"] + [[package]] name = "openstef-models" version = "0.0.0" source = { editable = "packages/openstef-models" } dependencies = [ { name = "holidays" }, + { name = "lightgbm" }, { name = "mlflow-skinny" }, { name = "openstef-beam" }, { name = "openstef-core" }, @@ -2272,6 +2913,7 @@ dependencies = [ { name = "pycountry" }, { name = "scikit-learn" }, { name = "scipy" }, + { name = "skops" }, ] [package.optional-dependencies] @@ -2286,13 +2928,15 @@ xgb-gpu = [ [package.metadata] requires-dist = [ { name = "holidays", specifier = ">=0.79" }, + { name = "lightgbm", specifier = ">=4.6" }, { name = "mlflow-skinny", specifier = ">=3,<4" }, { name = "openstef-beam", editable = "packages/openstef-beam" }, { name = "openstef-core", editable = "packages/openstef-core" }, { name = "pvlib", specifier = ">=0.13" }, { name = "pycountry", specifier = ">=24.6.1" }, - { name = "scikit-learn", specifier = ">=1.7.1,<2" }, + { name = "scikit-learn", specifier = ">=1.7.1,<1.8" }, { name = "scipy", specifier = ">=1.16.3,<2" }, + { name = "skops", specifier = ">=0.13" }, { name = "xgboost", marker = "sys_platform == 'darwin' and extra == 'xgb-cpu'", specifier = ">=3,<4" }, { name = "xgboost", marker = "extra == 'xgb-gpu'", specifier = ">=3,<4" }, { name = "xgboost-cpu", marker = "(sys_platform == 'linux' and extra == 'xgb-cpu') or (sys_platform == 'win32' and extra == 'xgb-cpu')", specifier = ">=3,<4" }, @@ -2369,6 +3013,59 @@ numpy = [ { name = "numpy-typing-compat" }, ] +[[package]] +name = "orjson" +version = "3.11.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/b8/333fdb27840f3bf04022d21b654a35f58e15407183aeb16f3b41aa053446/orjson-3.11.5.tar.gz", hash = "sha256:82393ab47b4fe44ffd0a7659fa9cfaacc717eb617c93cde83795f14af5c2e9d5", size = 5972347, upload-time = "2025-12-06T15:55:39.458Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a4/8052a029029b096a78955eadd68ab594ce2197e24ec50e6b6d2ab3f4e33b/orjson-3.11.5-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:334e5b4bff9ad101237c2d799d9fd45737752929753bf4faf4b207335a416b7d", size = 245347, upload-time = "2025-12-06T15:54:22.061Z" }, + { url = "https://files.pythonhosted.org/packages/64/67/574a7732bd9d9d79ac620c8790b4cfe0717a3d5a6eb2b539e6e8995e24a0/orjson-3.11.5-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:ff770589960a86eae279f5d8aa536196ebda8273a2a07db2a54e82b93bc86626", size = 129435, upload-time = "2025-12-06T15:54:23.615Z" }, + { url = "https://files.pythonhosted.org/packages/52/8d/544e77d7a29d90cf4d9eecd0ae801c688e7f3d1adfa2ebae5e1e94d38ab9/orjson-3.11.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed24250e55efbcb0b35bed7caaec8cedf858ab2f9f2201f17b8938c618c8ca6f", size = 132074, upload-time = "2025-12-06T15:54:24.694Z" }, + { url = "https://files.pythonhosted.org/packages/6e/57/b9f5b5b6fbff9c26f77e785baf56ae8460ef74acdb3eae4931c25b8f5ba9/orjson-3.11.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a66d7769e98a08a12a139049aac2f0ca3adae989817f8c43337455fbc7669b85", size = 130520, upload-time = "2025-12-06T15:54:26.185Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6d/d34970bf9eb33f9ec7c979a262cad86076814859e54eb9a059a52f6dc13d/orjson-3.11.5-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:86cfc555bfd5794d24c6a1903e558b50644e5e68e6471d66502ce5cb5fdef3f9", size = 136209, upload-time = "2025-12-06T15:54:27.264Z" }, + { url = "https://files.pythonhosted.org/packages/e7/39/bc373b63cc0e117a105ea12e57280f83ae52fdee426890d57412432d63b3/orjson-3.11.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a230065027bc2a025e944f9d4714976a81e7ecfa940923283bca7bbc1f10f626", size = 139837, upload-time = "2025-12-06T15:54:28.75Z" }, + { url = "https://files.pythonhosted.org/packages/cb/aa/7c4818c8d7d324da220f4f1af55c343956003aa4d1ce1857bdc1d396ba69/orjson-3.11.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b29d36b60e606df01959c4b982729c8845c69d1963f88686608be9ced96dbfaa", size = 137307, upload-time = "2025-12-06T15:54:29.856Z" }, + { url = "https://files.pythonhosted.org/packages/46/bf/0993b5a056759ba65145effe3a79dd5a939d4a070eaa5da2ee3180fbb13f/orjson-3.11.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c74099c6b230d4261fdc3169d50efc09abf38ace1a42ea2f9994b1d79153d477", size = 139020, upload-time = "2025-12-06T15:54:31.024Z" }, + { url = "https://files.pythonhosted.org/packages/65/e8/83a6c95db3039e504eda60fc388f9faedbb4f6472f5aba7084e06552d9aa/orjson-3.11.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e697d06ad57dd0c7a737771d470eedc18e68dfdefcdd3b7de7f33dfda5b6212e", size = 141099, upload-time = "2025-12-06T15:54:32.196Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b4/24fdc024abfce31c2f6812973b0a693688037ece5dc64b7a60c1ce69e2f2/orjson-3.11.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e08ca8a6c851e95aaecc32bc44a5aa75d0ad26af8cdac7c77e4ed93acf3d5b69", size = 413540, upload-time = "2025-12-06T15:54:33.361Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/01c0ec95d55ed0c11e4cae3e10427e479bba40c77312b63e1f9665e0737d/orjson-3.11.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e8b5f96c05fce7d0218df3fdfeb962d6b8cfff7e3e20264306b46dd8b217c0f3", size = 151530, upload-time = "2025-12-06T15:54:34.6Z" }, + { url = "https://files.pythonhosted.org/packages/f9/d4/f9ebc57182705bb4bbe63f5bbe14af43722a2533135e1d2fb7affa0c355d/orjson-3.11.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ddbfdb5099b3e6ba6d6ea818f61997bb66de14b411357d24c4612cf1ebad08ca", size = 141863, upload-time = "2025-12-06T15:54:35.801Z" }, + { url = "https://files.pythonhosted.org/packages/0d/04/02102b8d19fdcb009d72d622bb5781e8f3fae1646bf3e18c53d1bc8115b5/orjson-3.11.5-cp312-cp312-win32.whl", hash = "sha256:9172578c4eb09dbfcf1657d43198de59b6cef4054de385365060ed50c458ac98", size = 135255, upload-time = "2025-12-06T15:54:37.209Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fb/f05646c43d5450492cb387de5549f6de90a71001682c17882d9f66476af5/orjson-3.11.5-cp312-cp312-win_amd64.whl", hash = "sha256:2b91126e7b470ff2e75746f6f6ee32b9ab67b7a93c8ba1d15d3a0caaf16ec875", size = 133252, upload-time = "2025-12-06T15:54:38.401Z" }, + { url = "https://files.pythonhosted.org/packages/dc/a6/7b8c0b26ba18c793533ac1cd145e131e46fcf43952aa94c109b5b913c1f0/orjson-3.11.5-cp312-cp312-win_arm64.whl", hash = "sha256:acbc5fac7e06777555b0722b8ad5f574739e99ffe99467ed63da98f97f9ca0fe", size = 126777, upload-time = "2025-12-06T15:54:39.515Z" }, + { url = "https://files.pythonhosted.org/packages/10/43/61a77040ce59f1569edf38f0b9faadc90c8cf7e9bec2e0df51d0132c6bb7/orjson-3.11.5-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:3b01799262081a4c47c035dd77c1301d40f568f77cc7ec1bb7db5d63b0a01629", size = 245271, upload-time = "2025-12-06T15:54:40.878Z" }, + { url = "https://files.pythonhosted.org/packages/55/f9/0f79be617388227866d50edd2fd320cb8fb94dc1501184bb1620981a0aba/orjson-3.11.5-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:61de247948108484779f57a9f406e4c84d636fa5a59e411e6352484985e8a7c3", size = 129422, upload-time = "2025-12-06T15:54:42.403Z" }, + { url = "https://files.pythonhosted.org/packages/77/42/f1bf1549b432d4a78bfa95735b79b5dac75b65b5bb815bba86ad406ead0a/orjson-3.11.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:894aea2e63d4f24a7f04a1908307c738d0dce992e9249e744b8f4e8dd9197f39", size = 132060, upload-time = "2025-12-06T15:54:43.531Z" }, + { url = "https://files.pythonhosted.org/packages/25/49/825aa6b929f1a6ed244c78acd7b22c1481fd7e5fda047dc8bf4c1a807eb6/orjson-3.11.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ddc21521598dbe369d83d4d40338e23d4101dad21dae0e79fa20465dbace019f", size = 130391, upload-time = "2025-12-06T15:54:45.059Z" }, + { url = "https://files.pythonhosted.org/packages/42/ec/de55391858b49e16e1aa8f0bbbb7e5997b7345d8e984a2dec3746d13065b/orjson-3.11.5-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7cce16ae2f5fb2c53c3eafdd1706cb7b6530a67cc1c17abe8ec747f5cd7c0c51", size = 135964, upload-time = "2025-12-06T15:54:46.576Z" }, + { url = "https://files.pythonhosted.org/packages/1c/40/820bc63121d2d28818556a2d0a09384a9f0262407cf9fa305e091a8048df/orjson-3.11.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e46c762d9f0e1cfb4ccc8515de7f349abbc95b59cb5a2bd68df5973fdef913f8", size = 139817, upload-time = "2025-12-06T15:54:48.084Z" }, + { url = "https://files.pythonhosted.org/packages/09/c7/3a445ca9a84a0d59d26365fd8898ff52bdfcdcb825bcc6519830371d2364/orjson-3.11.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d7345c759276b798ccd6d77a87136029e71e66a8bbf2d2755cbdde1d82e78706", size = 137336, upload-time = "2025-12-06T15:54:49.426Z" }, + { url = "https://files.pythonhosted.org/packages/9a/b3/dc0d3771f2e5d1f13368f56b339c6782f955c6a20b50465a91acb79fe961/orjson-3.11.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75bc2e59e6a2ac1dd28901d07115abdebc4563b5b07dd612bf64260a201b1c7f", size = 138993, upload-time = "2025-12-06T15:54:50.939Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a2/65267e959de6abe23444659b6e19c888f242bf7725ff927e2292776f6b89/orjson-3.11.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:54aae9b654554c3b4edd61896b978568c6daa16af96fa4681c9b5babd469f863", size = 141070, upload-time = "2025-12-06T15:54:52.414Z" }, + { url = "https://files.pythonhosted.org/packages/63/c9/da44a321b288727a322c6ab17e1754195708786a04f4f9d2220a5076a649/orjson-3.11.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:4bdd8d164a871c4ec773f9de0f6fe8769c2d6727879c37a9666ba4183b7f8228", size = 413505, upload-time = "2025-12-06T15:54:53.67Z" }, + { url = "https://files.pythonhosted.org/packages/7f/17/68dc14fa7000eefb3d4d6d7326a190c99bb65e319f02747ef3ebf2452f12/orjson-3.11.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a261fef929bcf98a60713bf5e95ad067cea16ae345d9a35034e73c3990e927d2", size = 151342, upload-time = "2025-12-06T15:54:55.113Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c5/ccee774b67225bed630a57478529fc026eda33d94fe4c0eac8fe58d4aa52/orjson-3.11.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c028a394c766693c5c9909dec76b24f37e6a1b91999e8d0c0d5feecbe93c3e05", size = 141823, upload-time = "2025-12-06T15:54:56.331Z" }, + { url = "https://files.pythonhosted.org/packages/67/80/5d00e4155d0cd7390ae2087130637671da713959bb558db9bac5e6f6b042/orjson-3.11.5-cp313-cp313-win32.whl", hash = "sha256:2cc79aaad1dfabe1bd2d50ee09814a1253164b3da4c00a78c458d82d04b3bdef", size = 135236, upload-time = "2025-12-06T15:54:57.507Z" }, + { url = "https://files.pythonhosted.org/packages/95/fe/792cc06a84808dbdc20ac6eab6811c53091b42f8e51ecebf14b540e9cfe4/orjson-3.11.5-cp313-cp313-win_amd64.whl", hash = "sha256:ff7877d376add4e16b274e35a3f58b7f37b362abf4aa31863dadacdd20e3a583", size = 133167, upload-time = "2025-12-06T15:54:58.71Z" }, + { url = "https://files.pythonhosted.org/packages/46/2c/d158bd8b50e3b1cfdcf406a7e463f6ffe3f0d167b99634717acdaf5e299f/orjson-3.11.5-cp313-cp313-win_arm64.whl", hash = "sha256:59ac72ea775c88b163ba8d21b0177628bd015c5dd060647bbab6e22da3aad287", size = 126712, upload-time = "2025-12-06T15:54:59.892Z" }, + { url = "https://files.pythonhosted.org/packages/c2/60/77d7b839e317ead7bb225d55bb50f7ea75f47afc489c81199befc5435b50/orjson-3.11.5-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e446a8ea0a4c366ceafc7d97067bfd55292969143b57e3c846d87fc701e797a0", size = 245252, upload-time = "2025-12-06T15:55:01.127Z" }, + { url = "https://files.pythonhosted.org/packages/f1/aa/d4639163b400f8044cef0fb9aa51b0337be0da3a27187a20d1166e742370/orjson-3.11.5-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:53deb5addae9c22bbe3739298f5f2196afa881ea75944e7720681c7080909a81", size = 129419, upload-time = "2025-12-06T15:55:02.723Z" }, + { url = "https://files.pythonhosted.org/packages/30/94/9eabf94f2e11c671111139edf5ec410d2f21e6feee717804f7e8872d883f/orjson-3.11.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82cd00d49d6063d2b8791da5d4f9d20539c5951f965e45ccf4e96d33505ce68f", size = 132050, upload-time = "2025-12-06T15:55:03.918Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c8/ca10f5c5322f341ea9a9f1097e140be17a88f88d1cfdd29df522970d9744/orjson-3.11.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3fd15f9fc8c203aeceff4fda211157fad114dde66e92e24097b3647a08f4ee9e", size = 130370, upload-time = "2025-12-06T15:55:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/25/d4/e96824476d361ee2edd5c6290ceb8d7edf88d81148a6ce172fc00278ca7f/orjson-3.11.5-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9df95000fbe6777bf9820ae82ab7578e8662051bb5f83d71a28992f539d2cda7", size = 136012, upload-time = "2025-12-06T15:55:06.402Z" }, + { url = "https://files.pythonhosted.org/packages/85/8e/9bc3423308c425c588903f2d103cfcfe2539e07a25d6522900645a6f257f/orjson-3.11.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:92a8d676748fca47ade5bc3da7430ed7767afe51b2f8100e3cd65e151c0eaceb", size = 139809, upload-time = "2025-12-06T15:55:07.656Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3c/b404e94e0b02a232b957c54643ce68d0268dacb67ac33ffdee24008c8b27/orjson-3.11.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aa0f513be38b40234c77975e68805506cad5d57b3dfd8fe3baa7f4f4051e15b4", size = 137332, upload-time = "2025-12-06T15:55:08.961Z" }, + { url = "https://files.pythonhosted.org/packages/51/30/cc2d69d5ce0ad9b84811cdf4a0cd5362ac27205a921da524ff42f26d65e0/orjson-3.11.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa1863e75b92891f553b7922ce4ee10ed06db061e104f2b7815de80cdcb135ad", size = 138983, upload-time = "2025-12-06T15:55:10.595Z" }, + { url = "https://files.pythonhosted.org/packages/0e/87/de3223944a3e297d4707d2fe3b1ffb71437550e165eaf0ca8bbe43ccbcb1/orjson-3.11.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4be86b58e9ea262617b8ca6251a2f0d63cc132a6da4b5fcc8e0a4128782c829", size = 141069, upload-time = "2025-12-06T15:55:11.832Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/81d5087ae74be33bcae3ff2d80f5ccaa4a8fedc6d39bf65a427a95b8977f/orjson-3.11.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:b923c1c13fa02084eb38c9c065afd860a5cff58026813319a06949c3af5732ac", size = 413491, upload-time = "2025-12-06T15:55:13.314Z" }, + { url = "https://files.pythonhosted.org/packages/d0/6f/f6058c21e2fc1efaf918986dbc2da5cd38044f1a2d4b7b91ad17c4acf786/orjson-3.11.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:1b6bd351202b2cd987f35a13b5e16471cf4d952b42a73c391cc537974c43ef6d", size = 151375, upload-time = "2025-12-06T15:55:14.715Z" }, + { url = "https://files.pythonhosted.org/packages/54/92/c6921f17d45e110892899a7a563a925b2273d929959ce2ad89e2525b885b/orjson-3.11.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bb150d529637d541e6af06bbe3d02f5498d628b7f98267ff87647584293ab439", size = 141850, upload-time = "2025-12-06T15:55:15.94Z" }, + { url = "https://files.pythonhosted.org/packages/88/86/cdecb0140a05e1a477b81f24739da93b25070ee01ce7f7242f44a6437594/orjson-3.11.5-cp314-cp314-win32.whl", hash = "sha256:9cc1e55c884921434a84a0c3dd2699eb9f92e7b441d7f53f3941079ec6ce7499", size = 135278, upload-time = "2025-12-06T15:55:17.202Z" }, + { url = "https://files.pythonhosted.org/packages/e4/97/b638d69b1e947d24f6109216997e38922d54dcdcdb1b11c18d7efd2d3c59/orjson-3.11.5-cp314-cp314-win_amd64.whl", hash = "sha256:a4f3cb2d874e03bc7767c8f88adaa1a9a05cecea3712649c3b58589ec7317310", size = 133170, upload-time = "2025-12-06T15:55:18.468Z" }, + { url = "https://files.pythonhosted.org/packages/8f/dd/f4fff4a6fe601b4f8f3ba3aa6da8ac33d17d124491a3b804c662a70e1636/orjson-3.11.5-cp314-cp314-win_arm64.whl", hash = "sha256:38b22f476c351f9a1c43e5b07d8b5a02eb24a6ab8e75f700f7d479d4568346a5", size = 126713, upload-time = "2025-12-06T15:55:19.738Z" }, +] + [[package]] name = "packaging" version = "25.0" @@ -2438,6 +3135,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/40/96/1e4a035eaf4dce9610aac6e43026d0c6baa05773daf6d21e635a4fe19e21/pandas_stubs-2.3.2.250926-py3-none-any.whl", hash = "sha256:81121818453dcfe00f45c852f4dceee043640b813830f6e7bd084a4ef7ff7270", size = 159995, upload-time = "2025-09-26T19:50:38.241Z" }, ] +[[package]] +name = "pandocfilters" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/70/6f/3dd4940bbe001c06a65f88e36bad298bc7a0de5036115639926b0c5c0458/pandocfilters-1.5.1.tar.gz", hash = "sha256:002b4a555ee4ebc03f8b66307e287fa492e4a77b4ea14d3f934328297bb4939e", size = 8454, upload-time = "2024-01-18T20:08:13.726Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/af/4fbc8cab944db5d21b7e2a5b8e9211a03a79852b1157e2c102fcc61ac440/pandocfilters-1.5.1-py2.py3-none-any.whl", hash = "sha256:93be382804a9cdb0a7267585f157e5d1731bbe5545a85b268d6f5fe6232de2bc", size = 8663, upload-time = "2024-01-18T20:08:11.28Z" }, +] + [[package]] name = "parso" version = "0.8.5" @@ -2599,6 +3305,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/92/1b/5337af1a6a478d25a3e3c56b9b4b42b0a160314e02f4a0498d5322c8dac4/poethepoet-0.37.0-py3-none-any.whl", hash = "sha256:861790276315abcc8df1b4bd60e28c3d48a06db273edd3092f3c94e1a46e5e22", size = 90062, upload-time = "2025-08-11T18:00:27.595Z" }, ] +[[package]] +name = "prometheus-client" +version = "0.23.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/53/3edb5d68ecf6b38fcbcc1ad28391117d2a322d9a1a3eff04bfdb184d8c3b/prometheus_client-0.23.1.tar.gz", hash = "sha256:6ae8f9081eaaaf153a2e959d2e6c4f4fb57b12ef76c8c7980202f1e57b48b2ce", size = 80481, upload-time = "2025-09-18T20:47:25.043Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/db/14bafcb4af2139e046d03fd00dea7873e48eafe18b7d2797e73d6681f210/prometheus_client-0.23.1-py3-none-any.whl", hash = "sha256:dd1913e6e76b59cfe44e7a4b83e01afc9873c1bdfd2ed8739f1e76aeca115f99", size = 61145, upload-time = "2025-09-18T20:47:23.875Z" }, +] + +[[package]] +name = "prettytable" +version = "3.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/45/b0847d88d6cfeb4413566738c8bbf1e1995fad3d42515327ff32cc1eb578/prettytable-3.17.0.tar.gz", hash = "sha256:59f2590776527f3c9e8cf9fe7b66dd215837cca96a9c39567414cbc632e8ddb0", size = 67892, upload-time = "2025-11-14T17:33:20.212Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/8c/83087ebc47ab0396ce092363001fa37c17153119ee282700c0713a195853/prettytable-3.17.0-py3-none-any.whl", hash = "sha256:aad69b294ddbe3e1f95ef8886a060ed1666a0b83018bbf56295f6f226c43d287", size = 34433, upload-time = "2025-11-14T17:33:19.093Z" }, +] + [[package]] name = "prompt-toolkit" version = "3.0.52" @@ -2710,6 +3437,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/08/b4/46310463b4f6ceef310f8348786f3cff181cea671578e3d9743ba61a459e/protobuf-6.33.1-py3-none-any.whl", hash = "sha256:d595a9fd694fdeb061a62fbe10eb039cc1e444df81ec9bb70c7fc59ebcb1eafa", size = 170477, upload-time = "2025-11-13T16:44:17.633Z" }, ] +[[package]] +name = "psutil" +version = "7.1.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/88/bdd0a41e5857d5d703287598cbf08dad90aed56774ea52ae071bae9071b6/psutil-7.1.3.tar.gz", hash = "sha256:6c86281738d77335af7aec228328e944b30930899ea760ecf33a4dba66be5e74", size = 489059, upload-time = "2025-11-02T12:25:54.619Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/93/0c49e776b8734fef56ec9c5c57f923922f2cf0497d62e0f419465f28f3d0/psutil-7.1.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0005da714eee687b4b8decd3d6cc7c6db36215c9e74e5ad2264b90c3df7d92dc", size = 239751, upload-time = "2025-11-02T12:25:58.161Z" }, + { url = "https://files.pythonhosted.org/packages/6f/8d/b31e39c769e70780f007969815195a55c81a63efebdd4dbe9e7a113adb2f/psutil-7.1.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:19644c85dcb987e35eeeaefdc3915d059dac7bd1167cdcdbf27e0ce2df0c08c0", size = 240368, upload-time = "2025-11-02T12:26:00.491Z" }, + { url = "https://files.pythonhosted.org/packages/62/61/23fd4acc3c9eebbf6b6c78bcd89e5d020cfde4acf0a9233e9d4e3fa698b4/psutil-7.1.3-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95ef04cf2e5ba0ab9eaafc4a11eaae91b44f4ef5541acd2ee91d9108d00d59a7", size = 287134, upload-time = "2025-11-02T12:26:02.613Z" }, + { url = "https://files.pythonhosted.org/packages/30/1c/f921a009ea9ceb51aa355cb0cc118f68d354db36eae18174bab63affb3e6/psutil-7.1.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1068c303be3a72f8e18e412c5b2a8f6d31750fb152f9cb106b54090296c9d251", size = 289904, upload-time = "2025-11-02T12:26:05.207Z" }, + { url = "https://files.pythonhosted.org/packages/a6/82/62d68066e13e46a5116df187d319d1724b3f437ddd0f958756fc052677f4/psutil-7.1.3-cp313-cp313t-win_amd64.whl", hash = "sha256:18349c5c24b06ac5612c0428ec2a0331c26443d259e2a0144a9b24b4395b58fa", size = 249642, upload-time = "2025-11-02T12:26:07.447Z" }, + { url = "https://files.pythonhosted.org/packages/df/ad/c1cd5fe965c14a0392112f68362cfceb5230819dbb5b1888950d18a11d9f/psutil-7.1.3-cp313-cp313t-win_arm64.whl", hash = "sha256:c525ffa774fe4496282fb0b1187725793de3e7c6b29e41562733cae9ada151ee", size = 245518, upload-time = "2025-11-02T12:26:09.719Z" }, + { url = "https://files.pythonhosted.org/packages/2e/bb/6670bded3e3236eb4287c7bcdc167e9fae6e1e9286e437f7111caed2f909/psutil-7.1.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b403da1df4d6d43973dc004d19cee3b848e998ae3154cc8097d139b77156c353", size = 239843, upload-time = "2025-11-02T12:26:11.968Z" }, + { url = "https://files.pythonhosted.org/packages/b8/66/853d50e75a38c9a7370ddbeefabdd3d3116b9c31ef94dc92c6729bc36bec/psutil-7.1.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ad81425efc5e75da3f39b3e636293360ad8d0b49bed7df824c79764fb4ba9b8b", size = 240369, upload-time = "2025-11-02T12:26:14.358Z" }, + { url = "https://files.pythonhosted.org/packages/41/bd/313aba97cb5bfb26916dc29cf0646cbe4dd6a89ca69e8c6edce654876d39/psutil-7.1.3-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f33a3702e167783a9213db10ad29650ebf383946e91bc77f28a5eb083496bc9", size = 288210, upload-time = "2025-11-02T12:26:16.699Z" }, + { url = "https://files.pythonhosted.org/packages/c2/fa/76e3c06e760927a0cfb5705eb38164254de34e9bd86db656d4dbaa228b04/psutil-7.1.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fac9cd332c67f4422504297889da5ab7e05fd11e3c4392140f7370f4208ded1f", size = 291182, upload-time = "2025-11-02T12:26:18.848Z" }, + { url = "https://files.pythonhosted.org/packages/0f/1d/5774a91607035ee5078b8fd747686ebec28a962f178712de100d00b78a32/psutil-7.1.3-cp314-cp314t-win_amd64.whl", hash = "sha256:3792983e23b69843aea49c8f5b8f115572c5ab64c153bada5270086a2123c7e7", size = 250466, upload-time = "2025-11-02T12:26:21.183Z" }, + { url = "https://files.pythonhosted.org/packages/00/ca/e426584bacb43a5cb1ac91fae1937f478cd8fbe5e4ff96574e698a2c77cd/psutil-7.1.3-cp314-cp314t-win_arm64.whl", hash = "sha256:31d77fcedb7529f27bb3a0472bea9334349f9a04160e8e6e5020f22c59893264", size = 245756, upload-time = "2025-11-02T12:26:23.148Z" }, + { url = "https://files.pythonhosted.org/packages/ef/94/46b9154a800253e7ecff5aaacdf8ebf43db99de4a2dfa18575b02548654e/psutil-7.1.3-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2bdbcd0e58ca14996a42adf3621a6244f1bb2e2e528886959c72cf1e326677ab", size = 238359, upload-time = "2025-11-02T12:26:25.284Z" }, + { url = "https://files.pythonhosted.org/packages/68/3a/9f93cff5c025029a36d9a92fef47220ab4692ee7f2be0fba9f92813d0cb8/psutil-7.1.3-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:bc31fa00f1fbc3c3802141eede66f3a2d51d89716a194bf2cd6fc68310a19880", size = 239171, upload-time = "2025-11-02T12:26:27.23Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b1/5f49af514f76431ba4eea935b8ad3725cdeb397e9245ab919dbc1d1dc20f/psutil-7.1.3-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3bb428f9f05c1225a558f53e30ccbad9930b11c3fc206836242de1091d3e7dd3", size = 263261, upload-time = "2025-11-02T12:26:29.48Z" }, + { url = "https://files.pythonhosted.org/packages/e0/95/992c8816a74016eb095e73585d747e0a8ea21a061ed3689474fabb29a395/psutil-7.1.3-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56d974e02ca2c8eb4812c3f76c30e28836fffc311d55d979f1465c1feeb2b68b", size = 264635, upload-time = "2025-11-02T12:26:31.74Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/c3ed1a622b6ae2fd3c945a366e64eb35247a31e4db16cf5095e269e8eb3c/psutil-7.1.3-cp37-abi3-win_amd64.whl", hash = "sha256:f39c2c19fe824b47484b96f9692932248a54c43799a84282cfe58d05a6449efd", size = 247633, upload-time = "2025-11-02T12:26:33.887Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ad/33b2ccec09bf96c2b2ef3f9a6f66baac8253d7565d8839e024a6b905d45d/psutil-7.1.3-cp37-abi3-win_arm64.whl", hash = "sha256:bd0d69cee829226a761e92f28140bec9a5ee9d5b4fb4b0cc589068dbfff559b1", size = 244608, upload-time = "2025-11-02T12:26:36.136Z" }, +] + [[package]] name = "ptyprocess" version = "0.7.0" @@ -3037,6 +3790,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, ] +[[package]] +name = "pytest-timeout" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" }, +] + [[package]] name = "pytest-xdist" version = "3.8.0" @@ -3083,6 +3848,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, ] +[[package]] +name = "python-json-logger" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/29/bf/eca6a3d43db1dae7070f70e160ab20b807627ba953663ba07928cdd3dc58/python_json_logger-4.0.0.tar.gz", hash = "sha256:f58e68eb46e1faed27e0f574a55a0455eecd7b8a5b88b85a784519ba3cff047f", size = 17683, upload-time = "2025-10-06T04:15:18.984Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/e5/fecf13f06e5e5f67e8837d777d1bc43fac0ed2b77a676804df5c34744727/python_json_logger-4.0.0-py3-none-any.whl", hash = "sha256:af09c9daf6a813aa4cc7180395f50f2a9e5fa056034c9953aec92e381c5ba1e2", size = 15548, upload-time = "2025-10-06T04:15:17.553Z" }, +] + [[package]] name = "python-magic" version = "0.4.27" @@ -3117,6 +3891,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, ] +[[package]] +name = "pywinpty" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/bb/a7cc2967c5c4eceb6cc49cfe39447d4bfc56e6c865e7c2249b6eb978935f/pywinpty-3.0.2.tar.gz", hash = "sha256:1505cc4cb248af42cb6285a65c9c2086ee9e7e574078ee60933d5d7fa86fb004", size = 30669, upload-time = "2025-10-03T21:16:29.205Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/4e/1098484e042c9485f56f16eb2b69b43b874bd526044ee401512234cf9e04/pywinpty-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:99fdd9b455f0ad6419aba6731a7a0d2f88ced83c3c94a80ff9533d95fa8d8a9e", size = 2050391, upload-time = "2025-10-03T21:19:01.642Z" }, + { url = "https://files.pythonhosted.org/packages/fc/19/b757fe28008236a4a713e813283721b8a40aa60cd7d3f83549f2e25a3155/pywinpty-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:18f78b81e4cfee6aabe7ea8688441d30247b73e52cd9657138015c5f4ee13a51", size = 2050057, upload-time = "2025-10-03T21:19:26.732Z" }, + { url = "https://files.pythonhosted.org/packages/cb/44/cbae12ecf6f4fa4129c36871fd09c6bef4f98d5f625ecefb5e2449765508/pywinpty-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:663383ecfab7fc382cc97ea5c4f7f0bb32c2f889259855df6ea34e5df42d305b", size = 2049874, upload-time = "2025-10-03T21:18:53.923Z" }, + { url = "https://files.pythonhosted.org/packages/ca/15/f12c6055e2d7a617d4d5820e8ac4ceaff849da4cb124640ef5116a230771/pywinpty-3.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:28297cecc37bee9f24d8889e47231972d6e9e84f7b668909de54f36ca785029a", size = 2050386, upload-time = "2025-10-03T21:18:50.477Z" }, + { url = "https://files.pythonhosted.org/packages/de/24/c6907c5bb06043df98ad6a0a0ff5db2e0affcecbc3b15c42404393a3f72a/pywinpty-3.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:34b55ae9a1b671fe3eae071d86618110538e8eaad18fcb1531c0830b91a82767", size = 2049834, upload-time = "2025-10-03T21:19:25.688Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -3163,6 +3950,49 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "pyzmq" +version = "27.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "implementation_name == 'pypy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/0b/3c9baedbdf613ecaa7aa07027780b8867f57b6293b6ee50de316c9f3222b/pyzmq-27.1.0.tar.gz", hash = "sha256:ac0765e3d44455adb6ddbf4417dcce460fc40a05978c08efdf2948072f6db540", size = 281750, upload-time = "2025-09-08T23:10:18.157Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/e7/038aab64a946d535901103da16b953c8c9cc9c961dadcbf3609ed6428d23/pyzmq-27.1.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:452631b640340c928fa343801b0d07eb0c3789a5ffa843f6e1a9cee0ba4eb4fc", size = 1306279, upload-time = "2025-09-08T23:08:03.807Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5e/c3c49fdd0f535ef45eefcc16934648e9e59dace4a37ee88fc53f6cd8e641/pyzmq-27.1.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1c179799b118e554b66da67d88ed66cd37a169f1f23b5d9f0a231b4e8d44a113", size = 895645, upload-time = "2025-09-08T23:08:05.301Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/b0b2504cb4e903a74dcf1ebae157f9e20ebb6ea76095f6cfffea28c42ecd/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3837439b7f99e60312f0c926a6ad437b067356dc2bc2ec96eb395fd0fe804233", size = 652574, upload-time = "2025-09-08T23:08:06.828Z" }, + { url = "https://files.pythonhosted.org/packages/f8/9b/c108cdb55560eaf253f0cbdb61b29971e9fb34d9c3499b0e96e4e60ed8a5/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43ad9a73e3da1fab5b0e7e13402f0b2fb934ae1c876c51d0afff0e7c052eca31", size = 840995, upload-time = "2025-09-08T23:08:08.396Z" }, + { url = "https://files.pythonhosted.org/packages/c2/bb/b79798ca177b9eb0825b4c9998c6af8cd2a7f15a6a1a4272c1d1a21d382f/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0de3028d69d4cdc475bfe47a6128eb38d8bc0e8f4d69646adfbcd840facbac28", size = 1642070, upload-time = "2025-09-08T23:08:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/9c/80/2df2e7977c4ede24c79ae39dcef3899bfc5f34d1ca7a5b24f182c9b7a9ca/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:cf44a7763aea9298c0aa7dbf859f87ed7012de8bda0f3977b6fb1d96745df856", size = 2021121, upload-time = "2025-09-08T23:08:11.907Z" }, + { url = "https://files.pythonhosted.org/packages/46/bd/2d45ad24f5f5ae7e8d01525eb76786fa7557136555cac7d929880519e33a/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f30f395a9e6fbca195400ce833c731e7b64c3919aa481af4d88c3759e0cb7496", size = 1878550, upload-time = "2025-09-08T23:08:13.513Z" }, + { url = "https://files.pythonhosted.org/packages/e6/2f/104c0a3c778d7c2ab8190e9db4f62f0b6957b53c9d87db77c284b69f33ea/pyzmq-27.1.0-cp312-abi3-win32.whl", hash = "sha256:250e5436a4ba13885494412b3da5d518cd0d3a278a1ae640e113c073a5f88edd", size = 559184, upload-time = "2025-09-08T23:08:15.163Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7f/a21b20d577e4100c6a41795842028235998a643b1ad406a6d4163ea8f53e/pyzmq-27.1.0-cp312-abi3-win_amd64.whl", hash = "sha256:9ce490cf1d2ca2ad84733aa1d69ce6855372cb5ce9223802450c9b2a7cba0ccf", size = 619480, upload-time = "2025-09-08T23:08:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/78/c2/c012beae5f76b72f007a9e91ee9401cb88c51d0f83c6257a03e785c81cc2/pyzmq-27.1.0-cp312-abi3-win_arm64.whl", hash = "sha256:75a2f36223f0d535a0c919e23615fc85a1e23b71f40c7eb43d7b1dedb4d8f15f", size = 552993, upload-time = "2025-09-08T23:08:18.926Z" }, + { url = "https://files.pythonhosted.org/packages/60/cb/84a13459c51da6cec1b7b1dc1a47e6db6da50b77ad7fd9c145842750a011/pyzmq-27.1.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:93ad4b0855a664229559e45c8d23797ceac03183c7b6f5b4428152a6b06684a5", size = 1122436, upload-time = "2025-09-08T23:08:20.801Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b6/94414759a69a26c3dd674570a81813c46a078767d931a6c70ad29fc585cb/pyzmq-27.1.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:fbb4f2400bfda24f12f009cba62ad5734148569ff4949b1b6ec3b519444342e6", size = 1156301, upload-time = "2025-09-08T23:08:22.47Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ad/15906493fd40c316377fd8a8f6b1f93104f97a752667763c9b9c1b71d42d/pyzmq-27.1.0-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:e343d067f7b151cfe4eb3bb796a7752c9d369eed007b91231e817071d2c2fec7", size = 1341197, upload-time = "2025-09-08T23:08:24.286Z" }, + { url = "https://files.pythonhosted.org/packages/14/1d/d343f3ce13db53a54cb8946594e567410b2125394dafcc0268d8dda027e0/pyzmq-27.1.0-cp313-cp313t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:08363b2011dec81c354d694bdecaef4770e0ae96b9afea70b3f47b973655cc05", size = 897275, upload-time = "2025-09-08T23:08:26.063Z" }, + { url = "https://files.pythonhosted.org/packages/69/2d/d83dd6d7ca929a2fc67d2c3005415cdf322af7751d773524809f9e585129/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d54530c8c8b5b8ddb3318f481297441af102517602b569146185fa10b63f4fa9", size = 660469, upload-time = "2025-09-08T23:08:27.623Z" }, + { url = "https://files.pythonhosted.org/packages/3e/cd/9822a7af117f4bc0f1952dbe9ef8358eb50a24928efd5edf54210b850259/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3afa12c392f0a44a2414056d730eebc33ec0926aae92b5ad5cf26ebb6cc128", size = 847961, upload-time = "2025-09-08T23:08:29.672Z" }, + { url = "https://files.pythonhosted.org/packages/9a/12/f003e824a19ed73be15542f172fd0ec4ad0b60cf37436652c93b9df7c585/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c65047adafe573ff023b3187bb93faa583151627bc9c51fc4fb2c561ed689d39", size = 1650282, upload-time = "2025-09-08T23:08:31.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4a/e82d788ed58e9a23995cee70dbc20c9aded3d13a92d30d57ec2291f1e8a3/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:90e6e9441c946a8b0a667356f7078d96411391a3b8f80980315455574177ec97", size = 2024468, upload-time = "2025-09-08T23:08:33.543Z" }, + { url = "https://files.pythonhosted.org/packages/d9/94/2da0a60841f757481e402b34bf4c8bf57fa54a5466b965de791b1e6f747d/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:add071b2d25f84e8189aaf0882d39a285b42fa3853016ebab234a5e78c7a43db", size = 1885394, upload-time = "2025-09-08T23:08:35.51Z" }, + { url = "https://files.pythonhosted.org/packages/4f/6f/55c10e2e49ad52d080dc24e37adb215e5b0d64990b57598abc2e3f01725b/pyzmq-27.1.0-cp313-cp313t-win32.whl", hash = "sha256:7ccc0700cfdf7bd487bea8d850ec38f204478681ea02a582a8da8171b7f90a1c", size = 574964, upload-time = "2025-09-08T23:08:37.178Z" }, + { url = "https://files.pythonhosted.org/packages/87/4d/2534970ba63dd7c522d8ca80fb92777f362c0f321900667c615e2067cb29/pyzmq-27.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:8085a9fba668216b9b4323be338ee5437a235fe275b9d1610e422ccc279733e2", size = 641029, upload-time = "2025-09-08T23:08:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/f6/fa/f8aea7a28b0641f31d40dea42d7ef003fded31e184ef47db696bc74cd610/pyzmq-27.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6bb54ca21bcfe361e445256c15eedf083f153811c37be87e0514934d6913061e", size = 561541, upload-time = "2025-09-08T23:08:42.668Z" }, + { url = "https://files.pythonhosted.org/packages/87/45/19efbb3000956e82d0331bafca5d9ac19ea2857722fa2caacefb6042f39d/pyzmq-27.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ce980af330231615756acd5154f29813d553ea555485ae712c491cd483df6b7a", size = 1341197, upload-time = "2025-09-08T23:08:44.973Z" }, + { url = "https://files.pythonhosted.org/packages/48/43/d72ccdbf0d73d1343936296665826350cb1e825f92f2db9db3e61c2162a2/pyzmq-27.1.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1779be8c549e54a1c38f805e56d2a2e5c009d26de10921d7d51cfd1c8d4632ea", size = 897175, upload-time = "2025-09-08T23:08:46.601Z" }, + { url = "https://files.pythonhosted.org/packages/2f/2e/a483f73a10b65a9ef0161e817321d39a770b2acf8bcf3004a28d90d14a94/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7200bb0f03345515df50d99d3db206a0a6bee1955fbb8c453c76f5bf0e08fb96", size = 660427, upload-time = "2025-09-08T23:08:48.187Z" }, + { url = "https://files.pythonhosted.org/packages/f5/d2/5f36552c2d3e5685abe60dfa56f91169f7a2d99bbaf67c5271022ab40863/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01c0e07d558b06a60773744ea6251f769cd79a41a97d11b8bf4ab8f034b0424d", size = 847929, upload-time = "2025-09-08T23:08:49.76Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2a/404b331f2b7bf3198e9945f75c4c521f0c6a3a23b51f7a4a401b94a13833/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:80d834abee71f65253c91540445d37c4c561e293ba6e741b992f20a105d69146", size = 1650193, upload-time = "2025-09-08T23:08:51.7Z" }, + { url = "https://files.pythonhosted.org/packages/1c/0b/f4107e33f62a5acf60e3ded67ed33d79b4ce18de432625ce2fc5093d6388/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:544b4e3b7198dde4a62b8ff6685e9802a9a1ebf47e77478a5eb88eca2a82f2fd", size = 2024388, upload-time = "2025-09-08T23:08:53.393Z" }, + { url = "https://files.pythonhosted.org/packages/0d/01/add31fe76512642fd6e40e3a3bd21f4b47e242c8ba33efb6809e37076d9b/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cedc4c68178e59a4046f97eca31b148ddcf51e88677de1ef4e78cf06c5376c9a", size = 1885316, upload-time = "2025-09-08T23:08:55.702Z" }, + { url = "https://files.pythonhosted.org/packages/c4/59/a5f38970f9bf07cee96128de79590bb354917914a9be11272cfc7ff26af0/pyzmq-27.1.0-cp314-cp314t-win32.whl", hash = "sha256:1f0b2a577fd770aa6f053211a55d1c47901f4d537389a034c690291485e5fe92", size = 587472, upload-time = "2025-09-08T23:08:58.18Z" }, + { url = "https://files.pythonhosted.org/packages/70/d8/78b1bad170f93fcf5e3536e70e8fadac55030002275c9a29e8f5719185de/pyzmq-27.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:19c9468ae0437f8074af379e986c5d3d7d7bfe033506af442e8c879732bedbe0", size = 661401, upload-time = "2025-09-08T23:08:59.802Z" }, + { url = "https://files.pythonhosted.org/packages/81/d6/4bfbb40c9a0b42fc53c7cf442f6385db70b40f74a783130c5d0a5aa62228/pyzmq-27.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dc5dbf68a7857b59473f7df42650c621d7e8923fb03fa74a526890f4d33cc4d7", size = 575170, upload-time = "2025-09-08T23:09:01.418Z" }, +] + [[package]] name = "referencing" version = "0.36.2" @@ -3340,6 +4170,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/44/4e421b96b67b2daff264473f7465db72fbdf36a07e05494f50300cc7b0c6/rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa", size = 3490, upload-time = "2021-05-12T16:37:52.536Z" }, ] +[[package]] +name = "rfc3986-validator" +version = "0.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/88/f270de456dd7d11dcc808abfa291ecdd3f45ff44e3b549ffa01b126464d0/rfc3986_validator-0.1.1.tar.gz", hash = "sha256:3d44bde7921b3b9ec3ae4e3adca370438eccebc676456449b145d533b240d055", size = 6760, upload-time = "2019-10-28T16:00:19.144Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/51/17023c0f8f1869d8806b979a2bffa3f861f26a3f1a66b094288323fba52f/rfc3986_validator-0.1.1-py2.py3-none-any.whl", hash = "sha256:2f235c432ef459970b4306369336b9d5dbdda31b510ca1e327636e01f528bfa9", size = 4242, upload-time = "2019-10-28T16:00:13.976Z" }, +] + +[[package]] +name = "rfc3987-syntax" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lark" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2c/06/37c1a5557acf449e8e406a830a05bf885ac47d33270aec454ef78675008d/rfc3987_syntax-1.1.0.tar.gz", hash = "sha256:717a62cbf33cffdd16dfa3a497d81ce48a660ea691b1ddd7be710c22f00b4a0d", size = 14239, upload-time = "2025-07-18T01:05:05.015Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/71/44ce230e1b7fadd372515a97e32a83011f906ddded8d03e3c6aafbdedbb7/rfc3987_syntax-1.1.0-py3-none-any.whl", hash = "sha256:6c3d97604e4c5ce9f714898e05401a0445a641cfa276432b0a648c80856f6a3f", size = 8046, upload-time = "2025-07-18T01:05:03.843Z" }, +] + [[package]] name = "rich" version = "14.2.0" @@ -3627,6 +4478,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3d/03/941d4c31846e8a56f36e824a2b183d8a0896d54d2c5cdc59fa87b588e6d7/scoringrules-0.8.0-py3-none-any.whl", hash = "sha256:98eafc66fe83143d88da4b53d544896823390fdad01c9fd49424469eced3a716", size = 77078, upload-time = "2025-05-30T08:59:35.48Z" }, ] +[[package]] +name = "send2trash" +version = "1.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/3a/aec9b02217bb79b87bbc1a21bc6abc51e3d5dcf65c30487ac96c0908c722/Send2Trash-1.8.3.tar.gz", hash = "sha256:b18e7a3966d99871aefeb00cfbcfdced55ce4871194810fc71f4aa484b953abf", size = 17394, upload-time = "2024-04-07T00:01:09.267Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/b0/4562db6223154aa4e22f939003cb92514c79f3d4dccca3444253fd17f902/Send2Trash-1.8.3-py3-none-any.whl", hash = "sha256:0c31227e0bd08961c7665474a3d1ef7193929fedda4233843689baa056be46c9", size = 18072, upload-time = "2024-04-07T00:01:07.438Z" }, +] + [[package]] name = "setuptools" version = "80.9.0" @@ -3645,6 +4505,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] +[[package]] +name = "simplejson" +version = "3.20.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/f4/a1ac5ed32f7ed9a088d62a59d410d4c204b3b3815722e2ccfb491fa8251b/simplejson-3.20.2.tar.gz", hash = "sha256:5fe7a6ce14d1c300d80d08695b7f7e633de6cd72c80644021874d985b3393649", size = 85784, upload-time = "2025-09-26T16:29:36.64Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/9e/1a91e7614db0416885eab4136d49b7303de20528860ffdd798ce04d054db/simplejson-3.20.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:4376d5acae0d1e91e78baeba4ee3cf22fbf6509d81539d01b94e0951d28ec2b6", size = 93523, upload-time = "2025-09-26T16:28:00.356Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2b/d2413f5218fc25608739e3d63fe321dfa85c5f097aa6648dbe72513a5f12/simplejson-3.20.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f8fe6de652fcddae6dec8f281cc1e77e4e8f3575249e1800090aab48f73b4259", size = 75844, upload-time = "2025-09-26T16:28:01.756Z" }, + { url = "https://files.pythonhosted.org/packages/ad/f1/efd09efcc1e26629e120fef59be059ce7841cc6e1f949a4db94f1ae8a918/simplejson-3.20.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:25ca2663d99328d51e5a138f22018e54c9162438d831e26cfc3458688616eca8", size = 75655, upload-time = "2025-09-26T16:28:03.037Z" }, + { url = "https://files.pythonhosted.org/packages/97/ec/5c6db08e42f380f005d03944be1af1a6bd501cc641175429a1cbe7fb23b9/simplejson-3.20.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:12a6b2816b6cab6c3fd273d43b1948bc9acf708272074c8858f579c394f4cbc9", size = 150335, upload-time = "2025-09-26T16:28:05.027Z" }, + { url = "https://files.pythonhosted.org/packages/81/f5/808a907485876a9242ec67054da7cbebefe0ee1522ef1c0be3bfc90f96f6/simplejson-3.20.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac20dc3fcdfc7b8415bfc3d7d51beccd8695c3f4acb7f74e3a3b538e76672868", size = 158519, upload-time = "2025-09-26T16:28:06.5Z" }, + { url = "https://files.pythonhosted.org/packages/66/af/b8a158246834645ea890c36136584b0cc1c0e4b83a73b11ebd9c2a12877c/simplejson-3.20.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:db0804d04564e70862ef807f3e1ace2cc212ef0e22deb1b3d6f80c45e5882c6b", size = 148571, upload-time = "2025-09-26T16:28:07.715Z" }, + { url = "https://files.pythonhosted.org/packages/20/05/ed9b2571bbf38f1a2425391f18e3ac11cb1e91482c22d644a1640dea9da7/simplejson-3.20.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:979ce23ea663895ae39106946ef3d78527822d918a136dbc77b9e2b7f006237e", size = 152367, upload-time = "2025-09-26T16:28:08.921Z" }, + { url = "https://files.pythonhosted.org/packages/81/2c/bad68b05dd43e93f77994b920505634d31ed239418eb6a88997d06599983/simplejson-3.20.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a2ba921b047bb029805726800819675249ef25d2f65fd0edb90639c5b1c3033c", size = 150205, upload-time = "2025-09-26T16:28:10.086Z" }, + { url = "https://files.pythonhosted.org/packages/69/46/90c7fc878061adafcf298ce60cecdee17a027486e9dce507e87396d68255/simplejson-3.20.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:12d3d4dc33770069b780cc8f5abef909fe4a3f071f18f55f6d896a370fd0f970", size = 151823, upload-time = "2025-09-26T16:28:11.329Z" }, + { url = "https://files.pythonhosted.org/packages/ab/27/b85b03349f825ae0f5d4f780cdde0bbccd4f06c3d8433f6a3882df887481/simplejson-3.20.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:aff032a59a201b3683a34be1169e71ddda683d9c3b43b261599c12055349251e", size = 158997, upload-time = "2025-09-26T16:28:12.917Z" }, + { url = "https://files.pythonhosted.org/packages/71/ad/d7f3c331fb930638420ac6d236db68e9f4c28dab9c03164c3cd0e7967e15/simplejson-3.20.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:30e590e133b06773f0dc9c3f82e567463df40598b660b5adf53eb1c488202544", size = 154367, upload-time = "2025-09-26T16:28:14.393Z" }, + { url = "https://files.pythonhosted.org/packages/f0/46/5c67324addd40fa2966f6e886cacbbe0407c03a500db94fb8bb40333fcdf/simplejson-3.20.2-cp312-cp312-win32.whl", hash = "sha256:8d7be7c99939cc58e7c5bcf6bb52a842a58e6c65e1e9cdd2a94b697b24cddb54", size = 74285, upload-time = "2025-09-26T16:28:15.931Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c9/5cc2189f4acd3a6e30ffa9775bf09b354302dbebab713ca914d7134d0f29/simplejson-3.20.2-cp312-cp312-win_amd64.whl", hash = "sha256:2c0b4a67e75b945489052af6590e7dca0ed473ead5d0f3aad61fa584afe814ab", size = 75969, upload-time = "2025-09-26T16:28:17.017Z" }, + { url = "https://files.pythonhosted.org/packages/5e/9e/f326d43f6bf47f4e7704a4426c36e044c6bedfd24e072fb8e27589a373a5/simplejson-3.20.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:90d311ba8fcd733a3677e0be21804827226a57144130ba01c3c6a325e887dd86", size = 93530, upload-time = "2025-09-26T16:28:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/35/28/5a4b8f3483fbfb68f3f460bc002cef3a5735ef30950e7c4adce9c8da15c7/simplejson-3.20.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:feed6806f614bdf7f5cb6d0123cb0c1c5f40407ef103aa935cffaa694e2e0c74", size = 75846, upload-time = "2025-09-26T16:28:19.12Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4d/30dfef83b9ac48afae1cf1ab19c2867e27b8d22b5d9f8ca7ce5a0a157d8c/simplejson-3.20.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6b1d8d7c3e1a205c49e1aee6ba907dcb8ccea83651e6c3e2cb2062f1e52b0726", size = 75661, upload-time = "2025-09-26T16:28:20.219Z" }, + { url = "https://files.pythonhosted.org/packages/09/1d/171009bd35c7099d72ef6afd4bb13527bab469965c968a17d69a203d62a6/simplejson-3.20.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:552f55745044a24c3cb7ec67e54234be56d5d6d0e054f2e4cf4fb3e297429be5", size = 150579, upload-time = "2025-09-26T16:28:21.337Z" }, + { url = "https://files.pythonhosted.org/packages/61/ae/229bbcf90a702adc6bfa476e9f0a37e21d8c58e1059043038797cbe75b8c/simplejson-3.20.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c2da97ac65165d66b0570c9e545786f0ac7b5de5854d3711a16cacbcaa8c472d", size = 158797, upload-time = "2025-09-26T16:28:22.53Z" }, + { url = "https://files.pythonhosted.org/packages/90/c5/fefc0ac6b86b9108e302e0af1cf57518f46da0baedd60a12170791d56959/simplejson-3.20.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f59a12966daa356bf68927fca5a67bebac0033cd18b96de9c2d426cd11756cd0", size = 148851, upload-time = "2025-09-26T16:28:23.733Z" }, + { url = "https://files.pythonhosted.org/packages/43/f1/b392952200f3393bb06fbc4dd975fc63a6843261705839355560b7264eb2/simplejson-3.20.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133ae2098a8e162c71da97cdab1f383afdd91373b7ff5fe65169b04167da976b", size = 152598, upload-time = "2025-09-26T16:28:24.962Z" }, + { url = "https://files.pythonhosted.org/packages/f4/b4/d6b7279e52a3e9c0fa8c032ce6164e593e8d9cf390698ee981ed0864291b/simplejson-3.20.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7977640af7b7d5e6a852d26622057d428706a550f7f5083e7c4dd010a84d941f", size = 150498, upload-time = "2025-09-26T16:28:26.114Z" }, + { url = "https://files.pythonhosted.org/packages/62/22/ec2490dd859224326d10c2fac1353e8ad5c84121be4837a6dd6638ba4345/simplejson-3.20.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b530ad6d55e71fa9e93e1109cf8182f427a6355848a4ffa09f69cc44e1512522", size = 152129, upload-time = "2025-09-26T16:28:27.552Z" }, + { url = "https://files.pythonhosted.org/packages/33/ce/b60214d013e93dd9e5a705dcb2b88b6c72bada442a97f79828332217f3eb/simplejson-3.20.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:bd96a7d981bf64f0e42345584768da4435c05b24fd3c364663f5fbc8fabf82e3", size = 159359, upload-time = "2025-09-26T16:28:28.667Z" }, + { url = "https://files.pythonhosted.org/packages/99/21/603709455827cdf5b9d83abe726343f542491ca8dc6a2528eb08de0cf034/simplejson-3.20.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f28ee755fadb426ba2e464d6fcf25d3f152a05eb6b38e0b4f790352f5540c769", size = 154717, upload-time = "2025-09-26T16:28:30.288Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f9/dc7f7a4bac16cf7eb55a4df03ad93190e11826d2a8950052949d3dfc11e2/simplejson-3.20.2-cp313-cp313-win32.whl", hash = "sha256:472785b52e48e3eed9b78b95e26a256f59bb1ee38339be3075dad799e2e1e661", size = 74289, upload-time = "2025-09-26T16:28:31.809Z" }, + { url = "https://files.pythonhosted.org/packages/87/10/d42ad61230436735c68af1120622b28a782877146a83d714da7b6a2a1c4e/simplejson-3.20.2-cp313-cp313-win_amd64.whl", hash = "sha256:a1a85013eb33e4820286139540accbe2c98d2da894b2dcefd280209db508e608", size = 75972, upload-time = "2025-09-26T16:28:32.883Z" }, + { url = "https://files.pythonhosted.org/packages/05/5b/83e1ff87eb60ca706972f7e02e15c0b33396e7bdbd080069a5d1b53cf0d8/simplejson-3.20.2-py3-none-any.whl", hash = "sha256:3b6bb7fb96efd673eac2e4235200bfffdc2353ad12c54117e1e4e2fc485ac017", size = 57309, upload-time = "2025-09-26T16:29:35.312Z" }, +] + [[package]] name = "six" version = "1.17.0" @@ -3654,6 +4549,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "skops" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "packaging" }, + { name = "prettytable" }, + { name = "scikit-learn" }, + { name = "scipy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/0c/5ec987633e077dd0076178ea6ade2d6e57780b34afea0b497fb507d7a1ed/skops-0.13.0.tar.gz", hash = "sha256:66949fd3c95cbb5c80270fbe40293c0fe1e46cb4a921860e42584dd9c20ebeb1", size = 581312, upload-time = "2025-08-06T09:48:14.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/e8/6a2b2030f0689f894432b9c2f0357f2f3286b2a00474827e04b8fe9eea13/skops-0.13.0-py3-none-any.whl", hash = "sha256:55e2cccb18c86f5916e4cfe5acf55ed7b0eecddf08a151906414c092fa5926dc", size = 131200, upload-time = "2025-08-06T09:48:13.356Z" }, +] + [[package]] name = "smmap" version = "5.0.2" @@ -3918,6 +4829,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, ] +[[package]] +name = "terminado" +version = "0.18.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ptyprocess", marker = "os_name != 'nt'" }, + { name = "pywinpty", marker = "os_name == 'nt'" }, + { name = "tornado" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/11/965c6fd8e5cc254f1fe142d547387da17a8ebfd75a3455f637c663fb38a0/terminado-0.18.1.tar.gz", hash = "sha256:de09f2c4b85de4765f7714688fff57d3e75bad1f909b589fde880460c753fd2e", size = 32701, upload-time = "2024-03-12T14:34:39.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/9e/2064975477fdc887e47ad42157e214526dcad8f317a948dee17e1659a62f/terminado-0.18.1-py3-none-any.whl", hash = "sha256:a4468e1b37bb318f8a86514f65814e1afc977cf29b3992a4500d9dd305dcceb0", size = 14154, upload-time = "2024-03-12T14:34:36.569Z" }, +] + [[package]] name = "threadpoolctl" version = "3.6.0" @@ -3927,6 +4852,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, ] +[[package]] +name = "tinycss2" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/fd/7a5ee21fd08ff70d3d33a5781c255cbe779659bd03278feb98b19ee550f4/tinycss2-1.4.0.tar.gz", hash = "sha256:10c0972f6fc0fbee87c3edb76549357415e94548c1ae10ebccdea16fb404a9b7", size = 87085, upload-time = "2024-10-24T14:58:29.895Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/34/ebdc18bae6aa14fbee1a08b63c015c72b64868ff7dae68808ab500c492e2/tinycss2-1.4.0-py3-none-any.whl", hash = "sha256:3a49cf47b7675da0b15d0c6e1df8df4ebd96e9394bb905a5775adb0d884c5289", size = 26610, upload-time = "2024-10-24T14:58:28.029Z" }, +] + [[package]] name = "toml-fmt-common" version = "1.1.0" @@ -3986,6 +4923,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bd/75/8539d011f6be8e29f339c42e633aae3cb73bffa95dd0f9adec09b9c58e85/tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0", size = 38901, upload-time = "2025-06-05T07:13:43.546Z" }, ] +[[package]] +name = "tornado" +version = "6.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7f/2e/3d22d478f27cb4b41edd4db7f10cd7846d0a28ea443342de3dba97035166/tornado-6.5.3.tar.gz", hash = "sha256:16abdeb0211796ffc73765bc0a20119712d68afeeaf93d1a3f2edf6b3aee8d5a", size = 513348, upload-time = "2025-12-11T04:16:42.225Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/e9/bf22f66e1d5d112c0617974b5ce86666683b32c09b355dfcd59f8d5c8ef6/tornado-6.5.3-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:2dd7d7e8d3e4635447a8afd4987951e3d4e8d1fb9ad1908c54c4002aabab0520", size = 443860, upload-time = "2025-12-11T04:16:26.638Z" }, + { url = "https://files.pythonhosted.org/packages/ca/9c/594b631f0b8dc5977080c7093d1e96f1377c10552577d2c31bb0208c9362/tornado-6.5.3-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:5977a396f83496657779f59a48c38096ef01edfe4f42f1c0634b791dde8165d0", size = 442118, upload-time = "2025-12-11T04:16:28.32Z" }, + { url = "https://files.pythonhosted.org/packages/78/f6/685b869f5b5b9d9547571be838c6106172082751696355b60fc32a4988ed/tornado-6.5.3-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f72ac800be2ac73ddc1504f7aa21069a4137e8d70c387172c063d363d04f2208", size = 445700, upload-time = "2025-12-11T04:16:29.64Z" }, + { url = "https://files.pythonhosted.org/packages/91/4c/f0d19edf24912b7f21ae5e941f7798d132ad4d9b71441c1e70917a297265/tornado-6.5.3-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c43c4fc4f5419c6561cfb8b884a8f6db7b142787d47821e1a0e1296253458265", size = 445041, upload-time = "2025-12-11T04:16:30.799Z" }, + { url = "https://files.pythonhosted.org/packages/eb/2b/e02da94f4a4aef2bb3b923c838ef284a77548a5f06bac2a8682b36b4eead/tornado-6.5.3-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de8b3fed4b3afb65d542d7702ac8767b567e240f6a43020be8eaef59328f117b", size = 445270, upload-time = "2025-12-11T04:16:32.316Z" }, + { url = "https://files.pythonhosted.org/packages/58/e2/7a7535d23133443552719dba526dacbb7415f980157da9f14950ddb88ad6/tornado-6.5.3-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:dbc4b4c32245b952566e17a20d5c1648fbed0e16aec3fc7e19f3974b36e0e47c", size = 445957, upload-time = "2025-12-11T04:16:33.913Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1f/9ff92eca81ff17a86286ec440dcd5eab0400326eb81761aa9a4eecb1ffb9/tornado-6.5.3-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:db238e8a174b4bfd0d0238b8cfcff1c14aebb4e2fcdafbf0ea5da3b81caceb4c", size = 445371, upload-time = "2025-12-11T04:16:35.093Z" }, + { url = "https://files.pythonhosted.org/packages/70/b1/1d03ae4526a393b0b839472a844397337f03c7f3a1e6b5c82241f0e18281/tornado-6.5.3-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:892595c100cd9b53a768cbfc109dfc55dec884afe2de5290611a566078d9692d", size = 445348, upload-time = "2025-12-11T04:16:36.679Z" }, + { url = "https://files.pythonhosted.org/packages/4b/7d/7c181feadc8941f418d0d26c3790ee34ffa4bd0a294bc5201d44ebd19c1e/tornado-6.5.3-cp39-abi3-win32.whl", hash = "sha256:88141456525fe291e47bbe1ba3ffb7982549329f09b4299a56813923af2bd197", size = 446433, upload-time = "2025-12-11T04:16:38.332Z" }, + { url = "https://files.pythonhosted.org/packages/34/98/4f7f938606e21d0baea8c6c39a7c8e95bdf8e50b0595b1bb6f0de2af7a6e/tornado-6.5.3-cp39-abi3-win_amd64.whl", hash = "sha256:ba4b513d221cc7f795a532c1e296f36bcf6a60e54b15efd3f092889458c69af1", size = 446842, upload-time = "2025-12-11T04:16:39.867Z" }, + { url = "https://files.pythonhosted.org/packages/7a/27/0e3fca4c4edf33fb6ee079e784c63961cd816971a45e5e4cacebe794158d/tornado-6.5.3-cp39-abi3-win_arm64.whl", hash = "sha256:278c54d262911365075dd45e0b6314308c74badd6ff9a54490e7daccdd5ed0ea", size = 445863, upload-time = "2025-12-11T04:16:41.099Z" }, +] + [[package]] name = "tqdm" version = "4.67.1" @@ -4059,6 +5015,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, ] +[[package]] +name = "uri-template" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/31/c7/0336f2bd0bcbada6ccef7aaa25e443c118a704f828a0620c6fa0207c1b64/uri-template-1.3.0.tar.gz", hash = "sha256:0e00f8eb65e18c7de20d595a14336e9f337ead580c70934141624b6d1ffdacc7", size = 21678, upload-time = "2023-06-21T01:49:05.374Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/00/3fca040d7cf8a32776d3d81a00c8ee7457e00f80c649f1e4a863c8321ae9/uri_template-1.3.0-py3-none-any.whl", hash = "sha256:a44a133ea12d44a0c0f06d7d42a52d71282e77e2f937d8abd5655b8d56fc1363", size = 11140, upload-time = "2023-06-21T01:49:03.467Z" }, +] + [[package]] name = "url-normalize" version = "2.2.1" @@ -4198,6 +5163,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1", size = 37286, upload-time = "2025-09-22T16:29:51.641Z" }, ] +[[package]] +name = "webcolors" +version = "25.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/7a/eb316761ec35664ea5174709a68bbd3389de60d4a1ebab8808bfc264ed67/webcolors-25.10.0.tar.gz", hash = "sha256:62abae86504f66d0f6364c2a8520de4a0c47b80c03fc3a5f1815fedbef7c19bf", size = 53491, upload-time = "2025-10-31T07:51:03.977Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/cc/e097523dd85c9cf5d354f78310927f1656c422bd7b2613b2db3e3f9a0f2c/webcolors-25.10.0-py3-none-any.whl", hash = "sha256:032c727334856fc0b968f63daa252a1ac93d33db2f5267756623c210e57a4f1d", size = 14905, upload-time = "2025-10-31T07:51:01.778Z" }, +] + +[[package]] +name = "webencodings" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721, upload-time = "2017-04-05T20:21:34.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" }, +] + +[[package]] +name = "websocket-client" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, +] + [[package]] name = "websockets" version = "15.0.1" @@ -4241,6 +5233,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/52/24/ab44c871b0f07f491e5d2ad12c9bd7358e527510618cb1b803a88e986db1/werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e", size = 224498, upload-time = "2024-11-08T15:52:16.132Z" }, ] +[[package]] +name = "widgetsnbextension" +version = "4.0.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/f4/c67440c7fb409a71b7404b7aefcd7569a9c0d6bd071299bf4198ae7a5d95/widgetsnbextension-4.0.15.tar.gz", hash = "sha256:de8610639996f1567952d763a5a41af8af37f2575a41f9852a38f947eb82a3b9", size = 1097402, upload-time = "2025-11-01T21:15:55.178Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/0e/fa3b193432cfc60c93b42f3be03365f5f909d2b3ea410295cf36df739e31/widgetsnbextension-4.0.15-py3-none-any.whl", hash = "sha256:8156704e4346a571d9ce73b84bee86a29906c9abfd7223b7228a28899ccf3366", size = 2196503, upload-time = "2025-11-01T21:15:53.565Z" }, +] + [[package]] name = "win32-setctime" version = "1.2.0"